From 02f1dd08579727fcf47a867e1a0c157bffaa561b Mon Sep 17 00:00:00 2001 From: gexin Date: Tue, 7 Jul 2026 18:03:14 +1000 Subject: [PATCH] fix(moa): route Copilot slots by target model --- hermes_cli/models.py | 18 +++------ hermes_cli/runtime_provider.py | 21 ++++++++-- .../hermes_cli/test_copilot_model_api_mode.py | 23 +++++++++++ .../test_copilot_runtime_api_mode.py | 40 +++++++++++++++++++ 4 files changed, 86 insertions(+), 16 deletions(-) create mode 100644 tests/hermes_cli/test_copilot_model_api_mode.py create mode 100644 tests/hermes_cli/test_copilot_runtime_api_mode.py diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 81dfbecac920..2eb1b14cc2af 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -3649,19 +3649,11 @@ def copilot_model_api_mode( if _should_use_copilot_responses_api(normalized): return "codex_responses" - # Secondary: check catalog for non-GPT-5 models (Claude via /v1/messages, etc.) - if catalog: - catalog_entry = next((item for item in catalog if item.get("id") == normalized), None) - if isinstance(catalog_entry, dict): - supported_endpoints = { - str(endpoint).strip() - for endpoint in (catalog_entry.get("supported_endpoints") or []) - if str(endpoint).strip() - } - # For non-GPT-5 models, check if they only support messages API - if "/v1/messages" in supported_endpoints and "/chat/completions" not in supported_endpoints: - return "anthropic_messages" - + # Copilot's Claude models are exposed through its OpenAI-compatible chat + # endpoint, not through Hermes' native Anthropic adapter. The live catalog may + # advertise /v1/messages, but the Copilot token/header scheme is handled by + # the OpenAI client path; selecting anthropic_messages would send the wrong + # auth/wire shape. Keep non-GPT Copilot slots on chat_completions. return "chat_completions" diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index aad776f86a85..ea407d45caba 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -317,13 +317,24 @@ def _provider_supports_explicit_api_mode(provider: Optional[str], configured_pro return normalized_configured == normalized_provider -def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str: +def _copilot_runtime_api_mode( + model_cfg: Dict[str, Any], + api_key: str, + *, + target_model: Optional[str] = None, +) -> str: configured_provider = str(model_cfg.get("provider") or "").strip().lower() configured_mode = _parse_api_mode(model_cfg.get("api_mode")) if configured_mode and _provider_supports_explicit_api_mode("copilot", configured_provider): return configured_mode - model_name = str(model_cfg.get("default") or "").strip() + # Use the model being resolved for this runtime, not the persisted global + # default. MoA slots, fallback models, and mid-session model switches all + # resolve credentials for a target model that can differ from config.yaml's + # model.default. If we derive Copilot api_mode from the stale default, a + # Claude/Gemini MoA slot can inherit codex_responses from a GPT-5 default and + # fail with "model ... does not support Responses API". + model_name = str(target_model or model_cfg.get("default") or "").strip() if not model_name: return "chat_completions" @@ -449,7 +460,11 @@ def _resolve_runtime_from_pool_entry( api_mode = "chat_completions" base_url = _nous_inference_base_url_override() or base_url elif provider == "copilot": - api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", "")) + api_mode = _copilot_runtime_api_mode( + model_cfg, + getattr(entry, "runtime_api_key", ""), + target_model=effective_model, + ) base_url = base_url or PROVIDER_REGISTRY["copilot"].inference_base_url elif provider == "azure-foundry": # Azure Foundry: read api_mode and base_url from config diff --git a/tests/hermes_cli/test_copilot_model_api_mode.py b/tests/hermes_cli/test_copilot_model_api_mode.py new file mode 100644 index 000000000000..dfd3af4611f3 --- /dev/null +++ b/tests/hermes_cli/test_copilot_model_api_mode.py @@ -0,0 +1,23 @@ +"""Tests for Copilot model API-mode routing.""" + +from __future__ import annotations + + +def test_copilot_claude_stays_on_chat_completions_even_if_catalog_lists_messages(): + from hermes_cli.models import copilot_model_api_mode + + catalog = [ + { + "id": "claude-opus-4.8", + "supported_endpoints": ["/v1/messages"], + } + ] + + assert copilot_model_api_mode("claude-opus-4.8", catalog=catalog) == "chat_completions" + + +def test_copilot_gpt5_still_uses_responses_api(): + from hermes_cli.models import copilot_model_api_mode + + assert copilot_model_api_mode("gpt-5.5", catalog=[]) == "codex_responses" + assert copilot_model_api_mode("gpt-5-mini", catalog=[]) == "chat_completions" diff --git a/tests/hermes_cli/test_copilot_runtime_api_mode.py b/tests/hermes_cli/test_copilot_runtime_api_mode.py new file mode 100644 index 000000000000..c5e1135535d5 --- /dev/null +++ b/tests/hermes_cli/test_copilot_runtime_api_mode.py @@ -0,0 +1,40 @@ +"""Regression tests for Copilot runtime api_mode resolution.""" + +from __future__ import annotations + + +def test_copilot_runtime_api_mode_uses_target_model_over_stale_config_default(monkeypatch): + """MoA/fallback slots must derive Copilot api_mode from the slot model. + + Repro: user's main/default Copilot model is GPT-5.5 (Responses API), but a + MoA reference slot is Copilot Claude Opus (Chat Completions). Runtime + resolution previously looked only at model.default and returned + codex_responses for the Claude slot, causing Copilot to reject it with + "model ... does not support Responses API". + """ + from hermes_cli import runtime_provider as rp + + monkeypatch.setattr( + "hermes_cli.models.copilot_model_api_mode", + lambda model, api_key=None: "codex_responses" if str(model).startswith("gpt-5") else "chat_completions", + ) + + assert rp._copilot_runtime_api_mode( + {"provider": "copilot", "default": "gpt-5.5"}, + "token", + target_model="claude-opus-4.8", + ) == "chat_completions" + + +def test_copilot_runtime_api_mode_still_uses_default_without_target(monkeypatch): + from hermes_cli import runtime_provider as rp + + monkeypatch.setattr( + "hermes_cli.models.copilot_model_api_mode", + lambda model, api_key=None: "codex_responses" if str(model).startswith("gpt-5") else "chat_completions", + ) + + assert rp._copilot_runtime_api_mode( + {"provider": "copilot", "default": "gpt-5.5"}, + "token", + ) == "codex_responses"