Fix #5211: Preserve dots in OpenCode Go model names
OpenCode Go model names with dots (minimax-m2.7, glm-4.5, kimi-k2.5) were being mangled to hyphens (minimax-m2-7), causing HTTP 401 errors. Two code paths were affected: 1. model_normalize.py: opencode-go was incorrectly in DOT_TO_HYPHEN_PROVIDERS 2. run_agent.py: _anthropic_preserve_dots() did not check for opencode-go Fix: - Remove opencode-go from _DOT_TO_HYPHEN_PROVIDERS (dots are correct for Go) - Add opencode-go to _anthropic_preserve_dots() provider check - Add opencode.ai/zen/go to base_url fallback check - Add regression tests in tests/test_model_normalize.py Co-authored-by: jacob3712 <jacob3712@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
116
tests/test_model_normalize.py
Normal file
116
tests/test_model_normalize.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user