diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 22ab0fa3f..76dace065 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 8f23980ac..0208bc9fe 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 14861c37a..6de69ab30 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: