diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 68e8dc898e..8f4ee670cd 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** follows the same dot-to-hyphen convention as - Anthropic: ``claude-sonnet-4-6``. +- **OpenCode Zen** preserves dots for GPT/GLM/Gemini/Kimi/MiniMax-style + model IDs, but Claude still uses hyphenated native names like + ``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``. @@ -67,7 +68,6 @@ _AGGREGATOR_PROVIDERS: frozenset[str] = frozenset({ # Providers that want bare names with dots replaced by hyphens. _DOT_TO_HYPHEN_PROVIDERS: frozenset[str] = frozenset({ "anthropic", - "opencode-zen", }) # Providers that want bare names with dots preserved. @@ -329,6 +329,9 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: >>> normalize_model_for_provider("claude-sonnet-4.6", "opencode-zen") 'claude-sonnet-4-6' + >>> normalize_model_for_provider("minimax-m2.5-free", "opencode-zen") + 'minimax-m2.5-free' + >>> normalize_model_for_provider("deepseek-v3", "deepseek") 'deepseek-chat' @@ -351,7 +354,16 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: if provider in _AGGREGATOR_PROVIDERS: return _prepend_vendor(name) - # --- Anthropic / OpenCode: strip matching provider prefix, dots -> hyphens --- + # --- OpenCode Zen: Claude stays hyphenated; other models keep dots --- + if provider == "opencode-zen": + bare = _strip_matching_provider_prefix(name, provider) + if "/" in bare: + return bare + if bare.lower().startswith("claude-"): + return _dots_to_hyphens(bare) + return bare + + # --- Anthropic: strip matching provider prefix, dots -> hyphens --- if provider in _DOT_TO_HYPHEN_PROVIDERS: bare = _strip_matching_provider_prefix(name, provider) if "/" in bare: diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 26edd8c301..8308b102e5 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -130,6 +130,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "gemma-4-26b-it", ], "zai": [ + "glm-5.1", "glm-5", "glm-5-turbo", "glm-4.7", diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 5fa22afe9a..1fabec8472 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -104,7 +104,7 @@ _DEFAULT_PROVIDER_MODELS = { "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite", "gemma-4-31b-it", "gemma-4-26b-it", ], - "zai": ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"], + "zai": ["glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"], "kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], "minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"], "minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"], diff --git a/run_agent.py b/run_agent.py index 36452bc682..37572db5e1 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5856,11 +5856,12 @@ class AIAgent: """True when using an anthropic-compatible endpoint that preserves dots in model names. Alibaba/DashScope keeps dots (e.g. qwen3.5-plus). MiniMax keeps dots (e.g. MiniMax-M2.7). - OpenCode Go keeps dots (e.g. minimax-m2.7).""" - if (getattr(self, "provider", "") or "").lower() in {"alibaba", "minimax", "minimax-cn", "opencode-go"}: + OpenCode Go/Zen keeps dots for non-Claude models (e.g. minimax-m2.5-free). + ZAI/Zhipu keeps dots (e.g. glm-4.7, glm-5.1).""" + if (getattr(self, "provider", "") or "").lower() in {"alibaba", "minimax", "minimax-cn", "opencode-go", "opencode-zen", "zai"}: return True base = (getattr(self, "base_url", "") or "").lower() - return "dashscope" in base or "aliyuncs" in base or "minimax" in base or "opencode.ai/zen/go" in base + return "dashscope" in base or "aliyuncs" in base or "minimax" in base or "opencode.ai/zen/" in base or "bigmodel.cn" in base def _is_qwen_portal(self) -> bool: """Return True when the base URL targets Qwen Portal.""" diff --git a/tests/agent/test_minimax_provider.py b/tests/agent/test_minimax_provider.py index 1673bfd944..85c9c95206 100644 --- a/tests/agent/test_minimax_provider.py +++ b/tests/agent/test_minimax_provider.py @@ -308,6 +308,34 @@ class TestMinimaxPreserveDots: from run_agent import AIAgent assert AIAgent._anthropic_preserve_dots(agent) is False + def test_opencode_zen_provider_preserves_dots(self): + from types import SimpleNamespace + agent = SimpleNamespace(provider="opencode-zen", base_url="") + from run_agent import AIAgent + assert AIAgent._anthropic_preserve_dots(agent) is True + + def test_opencode_zen_url_preserves_dots(self): + from types import SimpleNamespace + agent = SimpleNamespace(provider="custom", base_url="https://opencode.ai/zen/v1") + from run_agent import AIAgent + assert AIAgent._anthropic_preserve_dots(agent) is True + + def test_zai_provider_preserves_dots(self): + from types import SimpleNamespace + agent = SimpleNamespace(provider="zai", base_url="") + from run_agent import AIAgent + assert AIAgent._anthropic_preserve_dots(agent) is True + + def test_bigmodel_cn_url_preserves_dots(self): + from types import SimpleNamespace + agent = SimpleNamespace(provider="custom", base_url="https://open.bigmodel.cn/api/paas/v4") + from run_agent import AIAgent + assert AIAgent._anthropic_preserve_dots(agent) is True + + def test_normalize_preserves_m25_free_dot(self): + from agent.anthropic_adapter import normalize_model_name + assert normalize_model_name("minimax-m2.5-free", preserve_dots=True) == "minimax-m2.5-free" + def test_normalize_preserves_m27_dot(self): from agent.anthropic_adapter import normalize_model_name assert normalize_model_name("MiniMax-M2.7", preserve_dots=True) == "MiniMax-M2.7" diff --git a/tests/hermes_cli/test_model_normalize.py b/tests/hermes_cli/test_model_normalize.py index 0bca8d52e3..14861c37a1 100644 --- a/tests/hermes_cli/test_model_normalize.py +++ b/tests/hermes_cli/test_model_normalize.py @@ -54,14 +54,19 @@ class TestAnthropicDotToHyphen: # ── OpenCode Zen regression ──────────────────────────────────────────── -class TestOpenCodeZenDotToHyphen: - """OpenCode Zen follows Anthropic convention (dots→hyphens).""" +class TestOpenCodeZenModelNormalization: + """OpenCode Zen preserves dots for most models, but Claude stays hyphenated.""" @pytest.mark.parametrize("model,expected", [ ("claude-sonnet-4.6", "claude-sonnet-4-6"), - ("glm-4.5", "glm-4-5"), + ("opencode-zen/claude-opus-4.5", "claude-opus-4-5"), + ("glm-4.5", "glm-4.5"), + ("glm-5.1", "glm-5.1"), + ("gpt-5.4", "gpt-5.4"), + ("minimax-m2.5-free", "minimax-m2.5-free"), + ("kimi-k2.5", "kimi-k2.5"), ]) - def test_zen_converts_dots(self, model, expected): + def test_zen_normalizes_models(self, model, expected): result = normalize_model_for_provider(model, "opencode-zen") assert result == expected @@ -69,6 +74,10 @@ class TestOpenCodeZenDotToHyphen: result = normalize_model_for_provider("opencode-zen/claude-sonnet-4.6", "opencode-zen") assert result == "claude-sonnet-4-6" + def test_zen_strips_vendor_prefix_for_non_claude(self): + result = normalize_model_for_provider("opencode-zen/glm-5.1", "opencode-zen") + assert result == "glm-5.1" + # ── Copilot dot preservation (regression) ──────────────────────────────