diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 08370f38e46..c58aa5bd1ab 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -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) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 8a97d577c0c..9d05edc0665 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -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: @@ -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: diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index daee627e738..2f39d013e8e 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -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, ) ) diff --git a/cecli/helpers/model_config/pipeline.py b/cecli/helpers/model_config/pipeline.py index 2ff83805c34..c253a3d330d 100644 --- a/cecli/helpers/model_config/pipeline.py +++ b/cecli/helpers/model_config/pipeline.py @@ -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 diff --git a/cecli/helpers/responses.py b/cecli/helpers/responses.py index e455d08db03..ba1155a7ad9 100644 --- a/cecli/helpers/responses.py +++ b/cecli/helpers/responses.py @@ -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: @@ -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 @@ -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) diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py index f96fed7baef..c5170726ac5 100644 --- a/cecli/mcp/server.py +++ b/cecli/mcp/server.py @@ -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() diff --git a/cecli/models.py b/cecli/models.py index f66744f7936..77e9ccb4ecd 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -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", diff --git a/cecli/resources/providers.json b/cecli/resources/providers.json index 20f87122be4..eead2fe85f2 100644 --- a/cecli/resources/providers.json +++ b/cecli/resources/providers.json @@ -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": [ diff --git a/cecli/tools/utils/base_tool.py b/cecli/tools/utils/base_tool.py index b749c148ad6..3cdebd882df 100644 --- a/cecli/tools/utils/base_tool.py +++ b/cecli/tools/utils/base_tool.py @@ -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 = ( diff --git a/cecli/tools/validations/validations.py b/cecli/tools/validations/validations.py index e6dc8e5cd90..f23306fc419 100644 --- a/cecli/tools/validations/validations.py +++ b/cecli/tools/validations/validations.py @@ -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]} diff --git a/requirements/requirements.in b/requirements/requirements.in index 284f3914e7d..5e1d782ce49 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -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" \ No newline at end of file +tree_sitter_languages==1.10.2; python_version <= "3.12" diff --git a/tests/helpers/test_llms_github_copilot_auth.py b/tests/helpers/test_llms_github_copilot_auth.py index e96ecbd6b60..b1e0c815634 100644 --- a/tests/helpers/test_llms_github_copilot_auth.py +++ b/tests/helpers/test_llms_github_copilot_auth.py @@ -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" diff --git a/tests/helpers/test_llms_providers_json.py b/tests/helpers/test_llms_providers_json.py index b59068bad77..4a1a080e26b 100644 --- a/tests/helpers/test_llms_providers_json.py +++ b/tests/helpers/test_llms_providers_json.py @@ -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 } diff --git a/tests/helpers/test_model_config.py b/tests/helpers/test_model_config.py index 3d3675a4ec7..4bf6ac677cc 100644 --- a/tests/helpers/test_model_config.py +++ b/tests/helpers/test_model_config.py @@ -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]) @@ -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,