Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1627,9 +1627,9 @@ def get_servers_context(self):
inactive_servers = []
for server in connected_servers:
name = server.name
if incl and name not in incl:
if incl and name.lower() not in incl:
inactive_servers.append(name)
elif name in excl:
elif name.lower() in excl:
inactive_servers.append(name)
else:
active_servers.append(name)
Expand Down
8 changes: 4 additions & 4 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -939,10 +939,10 @@ def get_announcements(self):
for server_name, server_tools in self.mcp_tools:
if (
self.registered_servers["included"]
and server_name not in self.registered_servers["included"]
and server_name.lower() not in self.registered_servers["included"]
):
continue
if server_name in self.registered_servers["excluded"]:
if server_name.lower() in self.registered_servers["excluded"]:
continue
mcp_servers.append(server_name)
if mcp_servers:
Expand Down Expand Up @@ -3356,10 +3356,10 @@ def get_tool_list(self):
# Apply per-instance server filtering
if (
self.registered_servers["included"]
and server_name not in self.registered_servers["included"]
and server_name.lower() not in self.registered_servers["included"]
):
continue
if server_name in self.registered_servers["excluded"]:
if server_name.lower() in self.registered_servers["excluded"]:
continue

for tool in server_tools:
Expand Down
10 changes: 9 additions & 1 deletion cecli/helpers/agents/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,11 +565,19 @@ async def _create_sub_agent_coder(
)

if agent_config:
# Reset the per-instance tool/server filters so AgentCoder.post_init()
# rebuilds them from *this* sub-agent's own agent-config (tools_includelist/
# excludelist, servers_includelist/excludelist) instead of inheriting the
# parent's filters verbatim. Deliberately do NOT reset mcp_manager here:
# doing so used to discard the parent's already-connected MCP servers and
# replace them with a brand new, empty manager, making it impossible for a
# sub-agent's servers_includelist to ever match anything. Leaving mcp_manager
# unset lets it inherit from from_coder (see Coder.create()) so the same
# connected MCP servers remain available and can then be filtered.
kwargs.update(
dict(
registered_tools=None,
registered_servers=None,
mcp_manager=None,
)
)

Expand Down
34 changes: 20 additions & 14 deletions cecli/helpers/model_config/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,43 +225,49 @@ def _find_record(sources, model_name, provider, route):

Lookup order:
1. Exact match on the full model name.
2. Exact match on the route (name after the provider prefix).
3. Progressively shortened routes, preferring newer model families
2. Exact match on same-provider route families, progressively shortened
(``gpt-5.6-luna`` -> ``gpt-5.6`` -> ``gpt-5`` -> ``gpt``).
3. Exact match on the bare route and its families when there is no
provider prefix, or when a bare record is provider-neutral or has
the same provider.
4. Closest same-provider match by longest shared route prefix.
"""
if not sources:
return None

for key in _candidate_keys(model_name, route, provider):
for key, is_bare in _candidate_keys(model_name, route, provider):
record = _lookup_entry(sources, key)

if record:
if record and (
not is_bare
or not record.get("litellm_provider")
or record.get("litellm_provider") == provider
):
return record

return _closest_provider_match(sources, provider, route)


def _candidate_keys(model_name, route, provider):
"""Ordered lookup keys: full name, same-provider families, route, bare families.
"""Return ordered ``(key, is_bare)`` lookup candidates.

Same-provider family candidates come before the bare route so a provider
prefix is never silently dropped for a bare (possibly different-provider)
record, e.g. ``github_copilot/gpt-5.6-luna`` should resolve to the
``github_copilot/gpt-5`` family rather than the bare ``gpt-5.6-luna``
(openai) record.
Provider-scoped candidates always precede bare candidates. For explicitly
prefixed models, callers must reject bare records that declare another
provider, so a provider prefix is never silently dropped. Provider-neutral
bare records remain compatible, and unprefixed model names keep their
existing bare lookup behavior.
"""
keys = [model_name]
keys = [(model_name, False)]
shortened = _shorten_route(route) if provider and route else []

if provider and route:
keys.extend(f"{provider}/{candidate}" for candidate in shortened)
keys.extend((f"{provider}/{candidate}", False) for candidate in shortened)

if route and route != model_name:
keys.append(route)
keys.append((route, True))

if provider and route:
keys.extend(shortened)
keys.extend((candidate, True) for candidate in shortened)

return keys

Expand Down
19 changes: 17 additions & 2 deletions cecli/helpers/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,11 +366,18 @@ def unprefix_tool_call(tool_call):
return server_name, result


def parse_tool_arguments(args_string: str) -> dict:
def parse_tool_arguments(args_string: str) -> dict | list:
"""Parse tool-call arguments, merging glued ``{…}{} {…}`` object fragments.

Also unwraps a single ``arguments``/``parameters``/``params`` wrapper key
that some models emit when mirroring the OpenAI wire format.

Returns a dict in almost all cases. A bare top-level JSON array (e.g. a
model emitting ``[...]`` directly as the arguments instead of wrapping it
under the tool's single required array parameter, as EditFile's `edits`
or ReadFile's `read`) is returned as-is rather than collapsed to ``{}``,
so ``BaseTool.process_response`` can still wrap it using the tool's
schema instead of silently losing the data.
"""
text = (args_string or "").strip()
if not text:
Expand All @@ -379,6 +386,8 @@ def parse_tool_arguments(args_string: str) -> dict:
parsed = json.loads(text)
if isinstance(parsed, dict):
return coerce_tool_structure(parsed)
if isinstance(parsed, list):
return parsed
except json.JSONDecodeError:
pass

Expand All @@ -393,12 +402,18 @@ def parse_tool_arguments(args_string: str) -> dict:
lone = try_parse_json_value(chunks[0])
if isinstance(lone, dict):
return coerce_tool_structure(lone)
if isinstance(lone, list):
return lone
try:
json_string = json_repair.repair_json(chunks[0], ensure_ascii=False)
single = json.loads(json_string)
except json.JSONDecodeError as err:
return {"@error": f"Malformed JSON arguments: {err}"}
return coerce_tool_structure(single) if isinstance(single, dict) else {}
if isinstance(single, dict):
return coerce_tool_structure(single)
if isinstance(single, list):
return single
return {}

merged = merge_glued_json_objects(chunks)

Expand Down
16 changes: 16 additions & 0 deletions cecli/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,22 @@ async def connect(self):
url = self.config.get("url")
headers = self.config.get("headers", {})

# Only use OAuth when no explicit Authorization header is provided.
# httpx rejects OAuthClientProvider unless it is actually needed,
# and a static Bearer token should bypass the OAuth flow entirely.
has_auth_header = any(k.lower() == "authorization" for k in headers)
oauth_provider = None
if not has_auth_header:
oauth_provider = await self._create_oauth_provider()

# Only use OAuth when no explicit Authorization header is provided.
# httpx rejects OAuthClientProvider unless it is actually needed,
# and a static Bearer token should bypass the OAuth flow entirely.
has_auth_header = any(k.lower() == "authorization" for k in headers)
oauth_provider = None
if not has_auth_header:
oauth_provider = await self._create_oauth_provider()

oauth_provider = None
if not headers:
oauth_provider = await self._create_oauth_provider()
Expand Down
6 changes: 6 additions & 0 deletions cecli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,12 @@ def fast_validate_environment(self):
provider = pieces[0]
else:
provider = None
if provider == "github_copilot":
from cecli.helpers.llms.providers.github_copilot import copilot_api_key

if copilot_api_key():
return dict(keys_in_environment=["github_copilot"], missing_keys=[])
return dict(keys_in_environment=False, missing_keys=[])
keymap = dict(
openrouter="OPENROUTER_API_KEY",
openai="OPENAI_API_KEY",
Expand Down
5 changes: 5 additions & 0 deletions cecli/resources/providers.json
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@
],
"display_name": "gradient_ai"
},
"github_copilot": {
"api_base": "https://api.githubcopilot.com",
"api_key_env": [],
"display_name": "github_copilot"
},
"groq": {
"api_base": "https://api.groq.com/openai/v1",
"api_key_env": [
Expand Down
38 changes: 21 additions & 17 deletions cecli/tools/utils/base_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,25 +63,29 @@ def process_response(cls, coder, params, _convert=True):
required_params = function_schema["parameters"]["required"]
properties = function_schema["parameters"].get("properties", {})

# Auto-correction: If a required parameter is missing but it's an array,
# and the current params look like a single item of that array, wrap it.
if len(required_params) == 1:
missing_param = required_params[0]
if missing_param not in params and params:
param_schema = properties.get(missing_param, {})
if param_schema.get("type") == "array":
params = {missing_param: [params]}
# Auto-correction: fix common shape mistakes (a bare array or
# a single item of that array sent directly as the whole
# arguments, instead of wrapped under the expected key) BEFORE
# checking for missing required parameters. Otherwise a
# recoverable shape (e.g. a bare `[...]` array) gets rejected
# before it has a chance to be normalized.
params = ToolValidations._basic_validations(params, cls.SCHEMA)

# Auto-correction: If a required parameter is present but is a dict instead of an array
for param_name in required_params:
if param_name in params:
param_schema = properties.get(param_name, {})
if param_schema.get("type") == "array" and isinstance(
params[param_name], dict
):
params[param_name] = [params[param_name]]

missing_params = [param for param in required_params if param not in params]
if isinstance(params, dict):
for param_name in required_params:
if param_name in params:
param_schema = properties.get(param_name, {})
if param_schema.get("type") == "array" and isinstance(
params[param_name], dict
):
params[param_name] = [params[param_name]]

missing_params = [
param
for param in required_params
if not isinstance(params, dict) or param not in params
]
if missing_params:
tool_name = function_schema.get("name", "Unknown Tool")
error_msg = (
Expand Down
24 changes: 18 additions & 6 deletions cecli/tools/validations/validations.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,16 +198,28 @@ def _basic_validations(cls, params: object, schema: dict | None = None) -> dict:

parameters = function_schema["parameters"]
properties = parameters.get("properties", {})

# Only auto-correct when there is exactly one property and it is an array
if len(properties) == 1:
required = parameters.get("required", [])

# Determine the single array-typed parameter to auto-correct into,
# if any. Prefer the schema's `required` list (covers tools that also
# declare optional properties alongside their one required array,
# e.g. EditFile's optional `change_id`); fall back to "exactly one
# property total" when the schema doesn't declare `required` at all.
single_param_name = None
if len(required) == 1:
single_param_name = required[0]
elif not required and len(properties) == 1:
single_param_name = next(iter(properties.keys()))
param_schema = properties[single_param_name]

if single_param_name is not None:
param_schema = properties.get(single_param_name, {})
if param_schema.get("type") == "array":
# Case 1: LLM emitted the array directly (bare list)
# Case 1: LLM emitted the array directly (bare list) → wrap
# it as-is under the expected key, don't nest it again.
if isinstance(params, list):
return {single_param_name: params}
# Case 2: LLM emitted a dict missing the expected key → wrap it
# Case 2: LLM emitted a single item of the array directly
# as a dict, missing the expected wrapper key → wrap it.
if isinstance(params, dict) and single_param_name not in params:
return {single_param_name: [params]}

Expand Down
2 changes: 1 addition & 1 deletion requirements/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,4 @@ packaging>=25.0
tree-sitter==0.23.2; python_version < "3.10"
tree-sitter>=0.25.1; python_version >= "3.10"
tree-sitter-language-pack<=0.13.0
tree_sitter_languages==1.10.2; python_version <= "3.12"
tree_sitter_languages==1.10.2; python_version <= "3.12"
5 changes: 2 additions & 3 deletions tests/helpers/test_llms_github_copilot_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,11 +347,10 @@ def test_resolve_model_config_uses_session_endpoint(auth, tmp_path):
def test_resolve_model_config_claude_family_messages(auth, tmp_path):
_seed_api_key(tmp_path)

# github_copilot/claude-sonnet-4.5 is the bundled copilot record; the
# bare claude-sonnet-5 key resolves to the anthropic provider.
resolved = llms_config.resolve_model_config("github_copilot/claude-sonnet-4.5")
resolved = llms_config.resolve_model_config("github_copilot/claude-sonnet-5")

assert resolved["provider"] == "github_copilot"
assert resolved["api_base"] == "https://tenant.githubcopilot.com"
assert resolved["family"] == "messages"


Expand Down
1 change: 1 addition & 0 deletions tests/helpers/test_llms_providers_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
EXPECTED_FAMILIES = {
"chatgpt": "responses", # ChatGPT subscription routes via /v1/responses
"bedrock": "bedrock", # AWS Bedrock Converse wire
"github_copilot": "responses", # Copilot routes non-Claude models via /v1/responses
}


Expand Down
17 changes: 17 additions & 0 deletions tests/helpers/test_model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ def test_route_match_without_provider_prefix():
assert config["llm"]["litellm_provider"] == "openai"


def test_bare_model_uses_bare_provider_record():
metadata = {"claude-sonnet-5": _record(litellm_provider="anthropic")}
config = get_default_config("claude-sonnet-5", [metadata])

assert config["llm"]["litellm_provider"] == "anthropic"


def test_family_fallback_to_newer_family():
metadata = {"gpt-5": _record()}
config = get_default_config("openai/gpt-5.6-luna", [metadata])
Expand Down Expand Up @@ -643,6 +650,16 @@ def test_provider_prefix_keeps_provider_on_family_fallback():
assert config["llm"]["litellm_provider"] == "github_copilot"


def test_copilot_claude_fallback_skips_bare_anthropic_record():
metadata = {
"claude-sonnet-5": _record(litellm_provider="anthropic"),
"github_copilot/claude-sonnet-4": _record(litellm_provider="github_copilot"),
}
config = get_default_config("github_copilot/claude-sonnet-5", [metadata])

assert config["llm"]["litellm_provider"] == "github_copilot"


def test_responses_mode_sets_temperature_and_store():
responses = _record(
supports_reasoning=False,
Expand Down
Loading