From 29d5d36b146994e5fbe8dfb15dd46f3e36983399 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 17 Apr 2026 04:19:36 -0700 Subject: [PATCH] fix(copilot): normalize vendor-prefixed and dash-notation model IDs (#6879) (#11561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Copilot API returns HTTP 400 "model_not_supported" when it receives a model ID it doesn't recognize (vendor-prefixed like `anthropic/claude-sonnet-4.6` or dash-notation like `claude-sonnet-4-6`). Two bugs combined to leave both formats unhandled: 1. `_COPILOT_MODEL_ALIASES` in hermes_cli/models.py only covered bare dot-notation and vendor-prefixed dot-notation. Hermes' default Claude IDs elsewhere use hyphens (anthropic native format), and users with an aggregator-style config who switch `model.provider` to `copilot` inherit `anthropic/claude-X-4.6` — neither case was in the table. 2. The Copilot branch of `normalize_model_for_provider()` only stripped the vendor prefix when it matched the target provider (`copilot/`) or was the special-cased `openai/` for openai-codex. Every other vendor prefix survived to the Copilot request unchanged. Fix: - Add dash-notation aliases (`claude-{opus,sonnet,haiku}-4-{5,6}` and the `anthropic/`-prefixed variants) to the alias table. - Rewire the Copilot / Copilot-ACP branch of `normalize_model_for_provider()` to delegate to the existing `normalize_copilot_model_id()`. That function already does alias lookups, catalog-aware resolution, and vendor-prefix fallback — it was being bypassed for the generic normalisation entry point. Because `switch_model()` already calls `normalize_model_for_provider()` for every `/model` switch (line 685 in model_switch.py), this single fix covers the CLI startup path (cli.py), the `/model` slash command path, and the gateway load-from-config path. Closes #6879 Credits dsr-restyn (#6743) who independently diagnosed the dash-notation case; their aliases are folded into this consolidated fix alongside the vendor-prefix stripping repair. --- hermes_cli/model_normalize.py | 21 +++++++++- hermes_cli/models.py | 13 ++++++ tests/hermes_cli/test_model_normalize.py | 53 ++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 22ab0fa3f6..76dace065a 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -374,7 +374,26 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: return bare return _dots_to_hyphens(bare) - # --- Copilot: strip matching provider prefix, keep dots --- + # --- Copilot / Copilot ACP: delegate to the Copilot-specific + # normalizer. It knows about the alias table (vendor-prefix + # stripping for Anthropic/OpenAI, dash-to-dot repair for Claude) + # and live-catalog lookups. Without this, vendor-prefixed or + # dash-notation Claude IDs survive to the Copilot API and hit + # HTTP 400 "model_not_supported". See issue #6879. + if provider in {"copilot", "copilot-acp"}: + try: + from hermes_cli.models import normalize_copilot_model_id + + normalized = normalize_copilot_model_id(name) + if normalized: + return normalized + except Exception: + # Fall through to the generic strip-vendor behaviour below + # if the Copilot-specific path is unavailable for any reason. + pass + + # --- Copilot / Copilot ACP / openai-codex fallback: + # strip matching provider prefix, keep dots --- if provider in _STRIP_VENDOR_ONLY_PROVIDERS: stripped = _strip_matching_provider_prefix(name, provider) if stripped == name and name.startswith("openai/"): diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 8f23980acf..0208bc9fea 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1488,6 +1488,19 @@ _COPILOT_MODEL_ALIASES = { "anthropic/claude-sonnet-4.6": "claude-sonnet-4.6", "anthropic/claude-sonnet-4.5": "claude-sonnet-4.5", "anthropic/claude-haiku-4.5": "claude-haiku-4.5", + # Dash-notation fallbacks: Hermes' default Claude IDs elsewhere use + # hyphens (anthropic native format), but Copilot's API only accepts + # dot-notation. Accept both so users who configure copilot + a + # default hyphenated Claude model don't hit HTTP 400 + # "model_not_supported". See issue #6879. + "claude-opus-4-6": "claude-opus-4.6", + "claude-sonnet-4-6": "claude-sonnet-4.6", + "claude-sonnet-4-5": "claude-sonnet-4.5", + "claude-haiku-4-5": "claude-haiku-4.5", + "anthropic/claude-opus-4-6": "claude-opus-4.6", + "anthropic/claude-sonnet-4-6": "claude-sonnet-4.6", + "anthropic/claude-sonnet-4-5": "claude-sonnet-4.5", + "anthropic/claude-haiku-4-5": "claude-haiku-4.5", } diff --git a/tests/hermes_cli/test_model_normalize.py b/tests/hermes_cli/test_model_normalize.py index 14861c37a1..6de69ab30c 100644 --- a/tests/hermes_cli/test_model_normalize.py +++ b/tests/hermes_cli/test_model_normalize.py @@ -93,6 +93,59 @@ class TestCopilotDotPreservation: assert result == expected +# ── Copilot model-name normalization (issue #6879 regression) ────────── + +class TestCopilotModelNormalization: + """Copilot requires bare dot-notation model IDs. + + Regression coverage for issue #6879 and the broken Copilot branch + that previously left vendor-prefixed Anthropic IDs (e.g. + ``anthropic/claude-sonnet-4.6``) and dash-notation Claude IDs (e.g. + ``claude-sonnet-4-6``) unchanged, causing the Copilot API to reject + the request with HTTP 400 "model_not_supported". + """ + + @pytest.mark.parametrize("model,expected", [ + # Vendor-prefixed Anthropic IDs — prefix must be stripped. + ("anthropic/claude-opus-4.6", "claude-opus-4.6"), + ("anthropic/claude-sonnet-4.6", "claude-sonnet-4.6"), + ("anthropic/claude-sonnet-4.5", "claude-sonnet-4.5"), + ("anthropic/claude-haiku-4.5", "claude-haiku-4.5"), + # Vendor-prefixed OpenAI IDs — prefix must be stripped. + ("openai/gpt-5.4", "gpt-5.4"), + ("openai/gpt-4o", "gpt-4o"), + ("openai/gpt-4o-mini", "gpt-4o-mini"), + # Dash-notation Claude IDs — must be converted to dot-notation. + ("claude-opus-4-6", "claude-opus-4.6"), + ("claude-sonnet-4-6", "claude-sonnet-4.6"), + ("claude-sonnet-4-5", "claude-sonnet-4.5"), + ("claude-haiku-4-5", "claude-haiku-4.5"), + # Combined: vendor-prefixed + dash-notation. + ("anthropic/claude-opus-4-6", "claude-opus-4.6"), + ("anthropic/claude-sonnet-4-6", "claude-sonnet-4.6"), + # Already-canonical inputs pass through unchanged. + ("claude-sonnet-4.6", "claude-sonnet-4.6"), + ("gpt-5.4", "gpt-5.4"), + ("gpt-5-mini", "gpt-5-mini"), + ]) + def test_copilot_normalization(self, model, expected): + assert normalize_model_for_provider(model, "copilot") == expected + + @pytest.mark.parametrize("model,expected", [ + ("anthropic/claude-sonnet-4.6", "claude-sonnet-4.6"), + ("claude-sonnet-4-6", "claude-sonnet-4.6"), + ("claude-opus-4-6", "claude-opus-4.6"), + ("openai/gpt-5.4", "gpt-5.4"), + ]) + def test_copilot_acp_normalization(self, model, expected): + """Copilot ACP shares the same API expectations as HTTP Copilot.""" + assert normalize_model_for_provider(model, "copilot-acp") == expected + + def test_openai_codex_still_strips_openai_prefix(self): + """Regression: openai-codex must still strip the openai/ prefix.""" + assert normalize_model_for_provider("openai/gpt-5.4", "openai-codex") == "gpt-5.4" + + # ── Aggregator providers (regression) ────────────────────────────────── class TestAggregatorProviders: