fix: repair OpenCode model routing and selection (#4508)
OpenCode Zen and Go are mixed-API-surface providers — different models behind them use different API surfaces (GPT on Zen uses codex_responses, Claude on Zen uses anthropic_messages, MiniMax on Go uses anthropic_messages, GLM/Kimi on Go use chat_completions). Changes: - Add normalize_opencode_model_id() and opencode_model_api_mode() to models.py for model ID normalization and API surface routing - Add _provider_supports_explicit_api_mode() to runtime_provider.py to prevent stale api_mode from leaking across provider switches - Wire opencode routing into all three api_mode resolution paths: pool entry, api_key provider, and explicit runtime - Add api_mode field to ModelSwitchResult for propagation through the switch pipeline - Consolidate _PROVIDER_MODELS from main.py into models.py (single source of truth, eliminates duplicate dict) - Add opencode normalization to setup wizard and model picker flows - Add opencode block to _normalize_model_for_provider in CLI - Add opencode-zen/go fallback model lists to setup.py Tests: 160 targeted tests pass (26 new tests covering normalization, api_mode routing per provider/model, persistence, and setup wizard normalization). Based on PR #3017 by SaM13997. Co-authored-by: SaM13997 <139419381+SaM13997@users.noreply.github.com>
This commit is contained in:
@@ -9,7 +9,9 @@ from hermes_cli.models import (
|
||||
fetch_api_models,
|
||||
github_model_reasoning_efforts,
|
||||
normalize_copilot_model_id,
|
||||
normalize_opencode_model_id,
|
||||
normalize_provider,
|
||||
opencode_model_api_mode,
|
||||
parse_model_input,
|
||||
probe_api_models,
|
||||
provider_label,
|
||||
@@ -339,6 +341,28 @@ class TestCopilotNormalization:
|
||||
}]
|
||||
assert copilot_model_api_mode("gpt-5.4", catalog=catalog) == "codex_responses"
|
||||
|
||||
def test_normalize_opencode_model_id_strips_provider_prefix(self):
|
||||
assert normalize_opencode_model_id("opencode-go", "opencode-go/kimi-k2.5") == "kimi-k2.5"
|
||||
assert normalize_opencode_model_id("opencode-zen", "opencode-zen/claude-sonnet-4-6") == "claude-sonnet-4-6"
|
||||
assert normalize_opencode_model_id("opencode-go", "glm-5") == "glm-5"
|
||||
|
||||
def test_opencode_zen_api_modes_match_docs(self):
|
||||
assert opencode_model_api_mode("opencode-zen", "gpt-5.4") == "codex_responses"
|
||||
assert opencode_model_api_mode("opencode-zen", "gpt-5.3-codex") == "codex_responses"
|
||||
assert opencode_model_api_mode("opencode-zen", "opencode-zen/gpt-5.4") == "codex_responses"
|
||||
assert opencode_model_api_mode("opencode-zen", "claude-sonnet-4-6") == "anthropic_messages"
|
||||
assert opencode_model_api_mode("opencode-zen", "opencode-zen/claude-sonnet-4-6") == "anthropic_messages"
|
||||
assert opencode_model_api_mode("opencode-zen", "gemini-3-flash") == "chat_completions"
|
||||
assert opencode_model_api_mode("opencode-zen", "minimax-m2.5") == "chat_completions"
|
||||
|
||||
def test_opencode_go_api_modes_match_docs(self):
|
||||
assert opencode_model_api_mode("opencode-go", "glm-5") == "chat_completions"
|
||||
assert opencode_model_api_mode("opencode-go", "opencode-go/glm-5") == "chat_completions"
|
||||
assert opencode_model_api_mode("opencode-go", "kimi-k2.5") == "chat_completions"
|
||||
assert opencode_model_api_mode("opencode-go", "opencode-go/kimi-k2.5") == "chat_completions"
|
||||
assert opencode_model_api_mode("opencode-go", "minimax-m2.5") == "anthropic_messages"
|
||||
assert opencode_model_api_mode("opencode-go", "opencode-go/minimax-m2.5") == "anthropic_messages"
|
||||
|
||||
|
||||
# -- validate — format checks -----------------------------------------------
|
||||
|
||||
|
||||
@@ -101,7 +101,14 @@ class TestDetectProviderForModel:
|
||||
assert result[0] == "openrouter"
|
||||
assert result[1] == "anthropic/claude-opus-4.6"
|
||||
|
||||
def test_bare_name_gets_openrouter_slug(self):
|
||||
def test_bare_name_gets_openrouter_slug(self, monkeypatch):
|
||||
for env_var in (
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN",
|
||||
"CLAUDE_CODE_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
):
|
||||
monkeypatch.delenv(env_var, raising=False)
|
||||
"""Bare model names should get mapped to full OpenRouter slugs."""
|
||||
result = detect_provider_for_model("claude-opus-4.6", "openai-codex")
|
||||
assert result is not None
|
||||
|
||||
@@ -186,6 +186,22 @@ class TestNormalizeModelForProvider:
|
||||
assert changed is True
|
||||
assert cli.model == "claude-opus-4.6"
|
||||
|
||||
def test_opencode_go_prefix_stripped(self):
|
||||
cli = _make_cli(model="opencode-go/kimi-k2.5")
|
||||
cli.api_mode = "chat_completions"
|
||||
changed = cli._normalize_model_for_provider("opencode-go")
|
||||
assert changed is True
|
||||
assert cli.model == "kimi-k2.5"
|
||||
assert cli.api_mode == "chat_completions"
|
||||
|
||||
def test_opencode_zen_claude_sets_messages_mode(self):
|
||||
cli = _make_cli(model="opencode-zen/claude-sonnet-4-6")
|
||||
cli.api_mode = "chat_completions"
|
||||
changed = cli._normalize_model_for_provider("opencode-zen")
|
||||
assert changed is True
|
||||
assert cli.model == "claude-sonnet-4-6"
|
||||
assert cli.api_mode == "anthropic_messages"
|
||||
|
||||
def test_default_model_replaced(self):
|
||||
"""No model configured (empty default) gets swapped for codex."""
|
||||
import cli as _cli_mod
|
||||
|
||||
@@ -210,3 +210,50 @@ class TestProviderPersistsAfterModelSave:
|
||||
assert model.get("base_url") == "acp://copilot"
|
||||
assert model.get("default") == "gpt-5.4"
|
||||
assert model.get("api_mode") == "chat_completions"
|
||||
|
||||
def test_opencode_go_models_are_selectable_and_persist_normalized(self, config_home, monkeypatch):
|
||||
from hermes_cli.main import _model_flow_api_key_provider
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-key")
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["opencode-go/kimi-k2.5", "opencode-go/minimax-m2.7"]), \
|
||||
patch("hermes_cli.auth._prompt_model_selection", return_value="kimi-k2.5"), \
|
||||
patch("hermes_cli.auth.deactivate_provider"), \
|
||||
patch("builtins.input", return_value=""):
|
||||
_model_flow_api_key_provider(load_config(), "opencode-go", "opencode-go/kimi-k2.5")
|
||||
|
||||
import yaml
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model.get("provider") == "opencode-go"
|
||||
assert model.get("default") == "kimi-k2.5"
|
||||
assert model.get("api_mode") == "chat_completions"
|
||||
|
||||
def test_opencode_go_same_provider_switch_recomputes_api_mode(self, config_home, monkeypatch):
|
||||
from hermes_cli.main import _model_flow_api_key_provider
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-key")
|
||||
(config_home / "config.yaml").write_text(
|
||||
"model:\n"
|
||||
" default: kimi-k2.5\n"
|
||||
" provider: opencode-go\n"
|
||||
" base_url: https://opencode.ai/zen/go/v1\n"
|
||||
" api_mode: chat_completions\n"
|
||||
)
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["opencode-go/kimi-k2.5", "opencode-go/minimax-m2.5"]), \
|
||||
patch("hermes_cli.auth._prompt_model_selection", return_value="minimax-m2.5"), \
|
||||
patch("hermes_cli.auth.deactivate_provider"), \
|
||||
patch("builtins.input", return_value=""):
|
||||
_model_flow_api_key_provider(load_config(), "opencode-go", "kimi-k2.5")
|
||||
|
||||
import yaml
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model.get("provider") == "opencode-go"
|
||||
assert model.get("default") == "minimax-m2.5"
|
||||
assert model.get("api_mode") == "anthropic_messages"
|
||||
|
||||
@@ -643,6 +643,34 @@ def test_model_config_api_mode(monkeypatch):
|
||||
assert resolved["base_url"] == "http://127.0.0.1:9208/v1"
|
||||
|
||||
|
||||
def test_model_config_api_mode_ignored_when_provider_differs(monkeypatch):
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "zai")
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"_get_model_config",
|
||||
lambda: {
|
||||
"provider": "opencode-go",
|
||||
"default": "minimax-m2.5",
|
||||
"api_mode": "anthropic_messages",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"resolve_api_key_provider_credentials",
|
||||
lambda provider: {
|
||||
"provider": provider,
|
||||
"api_key": "test-key",
|
||||
"base_url": "https://api.z.ai/api/paas/v4",
|
||||
"source": "env",
|
||||
},
|
||||
)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="zai")
|
||||
|
||||
assert resolved["provider"] == "zai"
|
||||
assert resolved["api_mode"] == "chat_completions"
|
||||
|
||||
|
||||
def test_invalid_api_mode_ignored(monkeypatch):
|
||||
"""Invalid api_mode values should fall back to chat_completions."""
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
|
||||
@@ -808,6 +836,78 @@ def test_alibaba_anthropic_endpoint_override_uses_anthropic_messages(monkeypatch
|
||||
assert resolved["base_url"] == "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
|
||||
|
||||
def test_opencode_zen_gpt_defaults_to_responses(monkeypatch):
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-zen")
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {"default": "gpt-5.4"})
|
||||
monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "test-opencode-zen-key")
|
||||
monkeypatch.delenv("OPENCODE_ZEN_BASE_URL", raising=False)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="opencode-zen")
|
||||
|
||||
assert resolved["provider"] == "opencode-zen"
|
||||
assert resolved["api_mode"] == "codex_responses"
|
||||
assert resolved["base_url"] == "https://opencode.ai/zen/v1"
|
||||
|
||||
|
||||
def test_opencode_zen_claude_defaults_to_messages(monkeypatch):
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-zen")
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {"default": "claude-sonnet-4-6"})
|
||||
monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "test-opencode-zen-key")
|
||||
monkeypatch.delenv("OPENCODE_ZEN_BASE_URL", raising=False)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="opencode-zen")
|
||||
|
||||
assert resolved["provider"] == "opencode-zen"
|
||||
assert resolved["api_mode"] == "anthropic_messages"
|
||||
assert resolved["base_url"] == "https://opencode.ai/zen/v1"
|
||||
|
||||
|
||||
def test_opencode_go_minimax_defaults_to_messages(monkeypatch):
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-go")
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {"default": "minimax-m2.5"})
|
||||
monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-opencode-go-key")
|
||||
monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="opencode-go")
|
||||
|
||||
assert resolved["provider"] == "opencode-go"
|
||||
assert resolved["api_mode"] == "anthropic_messages"
|
||||
assert resolved["base_url"] == "https://opencode.ai/zen/go/v1"
|
||||
|
||||
|
||||
def test_opencode_go_glm_defaults_to_chat_completions(monkeypatch):
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-go")
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {"default": "glm-5"})
|
||||
monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-opencode-go-key")
|
||||
monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="opencode-go")
|
||||
|
||||
assert resolved["provider"] == "opencode-go"
|
||||
assert resolved["api_mode"] == "chat_completions"
|
||||
assert resolved["base_url"] == "https://opencode.ai/zen/go/v1"
|
||||
|
||||
|
||||
def test_opencode_go_configured_api_mode_still_overrides_default(monkeypatch):
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-go")
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"_get_model_config",
|
||||
lambda: {
|
||||
"provider": "opencode-go",
|
||||
"default": "minimax-m2.5",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
)
|
||||
monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-opencode-go-key")
|
||||
monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="opencode-go")
|
||||
|
||||
assert resolved["provider"] == "opencode-go"
|
||||
assert resolved["api_mode"] == "chat_completions"
|
||||
|
||||
|
||||
def test_named_custom_provider_anthropic_api_mode(monkeypatch):
|
||||
"""Custom providers should accept api_mode: anthropic_messages."""
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-anthropic-proxy")
|
||||
|
||||
@@ -22,6 +22,8 @@ def mock_provider_registry():
|
||||
"kimi-coding": FakePConfig("Kimi Coding", ["KIMI_API_KEY"], "KIMI_BASE_URL", "https://api.kimi.example"),
|
||||
"minimax": FakePConfig("MiniMax", ["MINIMAX_API_KEY"], "MINIMAX_BASE_URL", "https://api.minimax.example"),
|
||||
"minimax-cn": FakePConfig("MiniMax CN", ["MINIMAX_API_KEY"], "MINIMAX_CN_BASE_URL", "https://api.minimax-cn.example"),
|
||||
"opencode-zen": FakePConfig("OpenCode Zen", ["OPENCODE_ZEN_API_KEY"], "OPENCODE_ZEN_BASE_URL", "https://opencode.ai/zen/v1"),
|
||||
"opencode-go": FakePConfig("OpenCode Go", ["OPENCODE_GO_API_KEY"], "OPENCODE_GO_BASE_URL", "https://opencode.ai/zen/go/v1"),
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +36,8 @@ class TestSetupProviderModelSelection:
|
||||
("kimi-coding", ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"]),
|
||||
("minimax", ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"]),
|
||||
("minimax-cn", ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"]),
|
||||
("opencode-zen", ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-4-6", "gemini-3-flash"]),
|
||||
("opencode-go", ["glm-5", "kimi-k2.5", "minimax-m2.5", "minimax-m2.7"]),
|
||||
])
|
||||
@patch("hermes_cli.models.fetch_api_models", return_value=[])
|
||||
@patch("hermes_cli.config.get_env_value", return_value="fake-key")
|
||||
@@ -122,3 +126,30 @@ class TestSetupProviderModelSelection:
|
||||
)
|
||||
|
||||
assert config["model"]["default"] == "my-custom-model"
|
||||
|
||||
@patch("hermes_cli.models.fetch_api_models", return_value=["opencode-go/kimi-k2.5", "opencode-go/minimax-m2.7"])
|
||||
@patch("hermes_cli.config.get_env_value", return_value="fake-key")
|
||||
def test_opencode_live_models_are_normalized_for_selection(
|
||||
self, mock_env, mock_fetch, mock_provider_registry
|
||||
):
|
||||
from hermes_cli.setup import _setup_provider_model_selection
|
||||
|
||||
captured_choices = {}
|
||||
|
||||
def fake_prompt_choice(label, choices, default):
|
||||
captured_choices["choices"] = choices
|
||||
return len(choices) - 1
|
||||
|
||||
with patch("hermes_cli.auth.PROVIDER_REGISTRY", mock_provider_registry):
|
||||
_setup_provider_model_selection(
|
||||
config={"model": {}},
|
||||
provider_id="opencode-go",
|
||||
current_model="opencode-go/kimi-k2.5",
|
||||
prompt_choice=fake_prompt_choice,
|
||||
prompt_fn=lambda _: None,
|
||||
)
|
||||
|
||||
offered = captured_choices["choices"]
|
||||
assert "kimi-k2.5" in offered
|
||||
assert "minimax-m2.7" in offered
|
||||
assert all("opencode-go/" not in choice for choice in offered)
|
||||
|
||||
Reference in New Issue
Block a user