diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index f2b07363e..378e1e192 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -8,8 +8,9 @@ Different LLM providers expect model identifiers in different formats: hyphens: ``claude-sonnet-4-6``. - **Copilot** expects bare names *with* dots preserved: ``claude-sonnet-4.6``. -- **OpenCode** (Zen & Go) follows the same dot-to-hyphen convention as +- **OpenCode Zen** follows the same dot-to-hyphen convention as Anthropic: ``claude-sonnet-4-6``. +- **OpenCode Go** preserves dots in model names: ``minimax-m2.7``. - **DeepSeek** only accepts two model identifiers: ``deepseek-chat`` and ``deepseek-reasoner``. - **Custom** and remaining providers pass the name through as-is. @@ -67,7 +68,6 @@ _AGGREGATOR_PROVIDERS: frozenset[str] = frozenset({ _DOT_TO_HYPHEN_PROVIDERS: frozenset[str] = frozenset({ "anthropic", "opencode-zen", - "opencode-go", }) # Providers that want bare names with dots preserved. diff --git a/run_agent.py b/run_agent.py index 688b25db7..5d45532d8 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5224,11 +5224,13 @@ class AIAgent: return transformed def _anthropic_preserve_dots(self) -> bool: - """True when using Alibaba/DashScope anthropic-compatible endpoint (model names keep dots, e.g. qwen3.5-plus).""" - if (getattr(self, "provider", "") or "").lower() == "alibaba": + """True when using an anthropic-compatible endpoint that preserves dots in model names. + Alibaba/DashScope keeps dots (e.g. qwen3.5-plus). + OpenCode Go keeps dots (e.g. minimax-m2.7).""" + if (getattr(self, "provider", "") or "").lower() in {"alibaba", "opencode-go"}: return True base = (getattr(self, "base_url", "") or "").lower() - return "dashscope" in base or "aliyuncs" in base + return "dashscope" in base or "aliyuncs" in base or "opencode.ai/zen/go" in base def _build_api_kwargs(self, api_messages: list) -> dict: """Build the keyword arguments dict for the active API mode.""" diff --git a/tests/test_model_normalize.py b/tests/test_model_normalize.py new file mode 100644 index 000000000..1c94c9db7 --- /dev/null +++ b/tests/test_model_normalize.py @@ -0,0 +1,116 @@ +"""Tests for hermes_cli.model_normalize — provider-aware model name normalization. + +Covers issue #5211: opencode-go model names with dots (e.g. minimax-m2.7) +must NOT be mangled to hyphens (minimax-m2-7). +""" +import pytest + +from hermes_cli.model_normalize import ( + normalize_model_for_provider, + _DOT_TO_HYPHEN_PROVIDERS, + _AGGREGATOR_PROVIDERS, + detect_vendor, +) + + +# ── Regression: issue #5211 ──────────────────────────────────────────── + +class TestIssue5211OpenCodeGoDotPreservation: + """OpenCode Go model names with dots must pass through unchanged.""" + + @pytest.mark.parametrize("model,expected", [ + ("minimax-m2.7", "minimax-m2.7"), + ("minimax-m2.5", "minimax-m2.5"), + ("glm-4.5", "glm-4.5"), + ("kimi-k2.5", "kimi-k2.5"), + ("some-model-1.0.3", "some-model-1.0.3"), + ]) + def test_opencode_go_preserves_dots(self, model, expected): + result = normalize_model_for_provider(model, "opencode-go") + assert result == expected, f"Expected {expected!r}, got {result!r}" + + def test_opencode_go_not_in_dot_to_hyphen_set(self): + """opencode-go must NOT be in the dot-to-hyphen provider set.""" + assert "opencode-go" not in _DOT_TO_HYPHEN_PROVIDERS + + +# ── Anthropic dot-to-hyphen conversion (regression) ──────────────────── + +class TestAnthropicDotToHyphen: + """Anthropic API still needs dots→hyphens.""" + + @pytest.mark.parametrize("model,expected", [ + ("claude-sonnet-4.6", "claude-sonnet-4-6"), + ("claude-opus-4.5", "claude-opus-4-5"), + ]) + def test_anthropic_converts_dots(self, model, expected): + result = normalize_model_for_provider(model, "anthropic") + assert result == expected + + def test_anthropic_strips_vendor_prefix(self): + result = normalize_model_for_provider("anthropic/claude-sonnet-4.6", "anthropic") + assert result == "claude-sonnet-4-6" + + +# ── OpenCode Zen regression ──────────────────────────────────────────── + +class TestOpenCodeZenDotToHyphen: + """OpenCode Zen follows Anthropic convention (dots→hyphens).""" + + @pytest.mark.parametrize("model,expected", [ + ("claude-sonnet-4.6", "claude-sonnet-4-6"), + ("glm-4.5", "glm-4-5"), + ]) + def test_zen_converts_dots(self, model, expected): + result = normalize_model_for_provider(model, "opencode-zen") + assert result == expected + + def test_zen_strips_vendor_prefix(self): + result = normalize_model_for_provider("opencode-zen/claude-sonnet-4.6", "opencode-zen") + assert result == "claude-sonnet-4-6" + + +# ── Copilot dot preservation (regression) ────────────────────────────── + +class TestCopilotDotPreservation: + """Copilot preserves dots in model names.""" + + @pytest.mark.parametrize("model,expected", [ + ("claude-sonnet-4.6", "claude-sonnet-4.6"), + ("gpt-5.4", "gpt-5.4"), + ]) + def test_copilot_preserves_dots(self, model, expected): + result = normalize_model_for_provider(model, "copilot") + assert result == expected + + +# ── Aggregator providers (regression) ────────────────────────────────── + +class TestAggregatorProviders: + """Aggregators need vendor/model slugs.""" + + def test_openrouter_prepends_vendor(self): + result = normalize_model_for_provider("claude-sonnet-4.6", "openrouter") + assert result == "anthropic/claude-sonnet-4.6" + + def test_nous_prepends_vendor(self): + result = normalize_model_for_provider("gpt-5.4", "nous") + assert result == "openai/gpt-5.4" + + def test_vendor_already_present(self): + result = normalize_model_for_provider("anthropic/claude-sonnet-4.6", "openrouter") + assert result == "anthropic/claude-sonnet-4.6" + + +# ── detect_vendor ────────────────────────────────────────────────────── + +class TestDetectVendor: + @pytest.mark.parametrize("model,expected", [ + ("claude-sonnet-4.6", "anthropic"), + ("gpt-5.4-mini", "openai"), + ("minimax-m2.7", "minimax"), + ("glm-4.5", "z-ai"), + ("kimi-k2.5", "moonshotai"), + ]) + def test_detects_known_vendors(self, model, expected): + assert detect_vendor(model) == expected