OpenCode provider model routing and normalization fixes
NousResearch/hermes-agent · cluster 3014 · agent
Data as of
Nothing open on GitHub. Fix merged in #4508. Check it covers your case.
The short answer
- Known?
- Yes. 7 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #4508. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
A cluster of bugs, fixes, and a feature addition centered on OpenCode provider support, including incorrect API surface routing, model name normalization, base URL handling, and 404 errors affecting specific models like GLM, Kimi, and Zen/ZAI providers.
Open issues0
Open PRs0
Fixes on record
-
#4508 fix: repair OpenCode model routing and selection
files:
cli.py,hermes_cli/auth.py,hermes_cli/main.py,hermes_cli/model_switch.py,hermes_cli/models.py,hermes_cli/runtime_provider.py,hermes_cli/setup.py,tests/hermes_cli/test_model_validation.py,tests/hermes_cli/test_models.py,tests/test_codex_models.py,tests/test_model_provider_persistence.py,tests/test_runtime_provider_resolution.py,tests/test_setup_model_selection.pyShow the diff
-
cli.py@@ -1545,6 +1545,28 @@ def _normalize_model_for_provider(self, resolved_provider: str) -> bool: pass return changed + if resolved_provider in {"opencode-zen", "opencode-go"}: + try: + from hermes_cli.models import normalize_opencode_model_id, opencode_model_api_mode + + canonical = normalize_opencode_model_id(resolved_provider, current_model) + if canonical and canonical != current_model: + if not self._model_is_default: + self.console.print( + f"[yellow]⚠️ Stripped provider prefix from '{current_model}'; using '{canonical}' for {resolved_provider}.[/]" + ) + self.model = canonical + current_model = canonical + changed = True + + resolved_mode = opencode_model_api_mode(resolved_provider, current_model) + if resolved_mode != self.api_mode: + self.api_mode = resolved_mode + changed = True + except Exception: + pass + return changed + if resolved_provider != "openai-codex": return False -
hermes_cli/auth.py@@ -200,6 +200,10 @@ class ProviderConfig: id="opencode-go", name="OpenCode Go", auth_type="api_key", + # OpenCode Go mixes API surfaces by model: + # - GLM / Kimi use OpenAI-compatible chat completions under /v1 + # - MiniMax models use Anthropic Messages under /v1/messages + # Keep the provider base at /v1 and select api_mode per-model. inference_base_url="https://opencode.ai/zen/go/v1", api_key_env_vars=("OPENCODE_GO_API_KEY",), base_url_env_var="OPENCODE_GO_BASE_URL", -
hermes_cli/main.py@@ -1604,81 +1604,8 @@ def _model_flow_named_custom(config, provider_info): print(f" Provider: {name} ({base_url})") -# Curated model lists for direct API-key providers -_PROVIDER_MODELS = { - "copilot-acp": [ - "copilot-acp", - ], - "copilot": [ - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5-mini", - "gpt-5.3-codex", - "gpt-5.2-codex", - "gpt-4.1", - "gpt-4o", - "gpt-4o-mini", - "claude-opus-4.6", - "claude-sonnet-4.6", - "claude-sonnet-4.5", - "claude-haiku-4.5", - "gemini-2.5-pro", - "grok-code-fast-1", - ], - "zai": [ - "glm-5", - "glm-4.7", - "glm-4.5", - "glm-4.5-flash", - ], - "kimi-coding": [ - "kimi-for-coding", - "kimi-k2.5", - "kimi-k2-thinking", - "kimi-k2-thinking-turbo", - "kimi-k2-turbo-preview", - "kimi-k2-0905-preview", - ], - "moonshot": [ - "kimi-k2.5", - "kimi-k2-thinking", - "kimi-k2-turbo-preview", - "kimi-k2-0905-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", - ], - "kilocode": [ - "anthropic/claude-opus-4.6", - "anthropic/claude-sonnet-4.6", - "openai/gpt-5.4", - "google/gemini-3-pro-preview", - "google/gemini-3-flash-preview", - ], - # Curated HF model list — only agentic models that map to OpenRouter defaults. - # Format: HF model ID → OpenRouter equivalent noted in comment - "huggingface": [ - "Qwen/Qwen3.5-397B-A17B", # ↔ qwen/qwen3.5-plus - "Qwen/Qwen3.5-35B-A3B", # ↔ qwen/qwen3.5-35b-a3b - "deepseek-ai/DeepSeek-V3.2", # ↔ deepseek/deepseek-chat - "moonshotai/Kimi-K2.5", # ↔ moonshotai/kimi-k2.5 - "MiniMaxAI/MiniMax-M2.5", # ↔ minimax/minimax-m2.5 - "zai-org/GLM-5", # ↔ z-ai/glm-5 - "XiaomiMiMo/MiMo-V2-Flash", # ↔ xiaomi/mimo-v2-pro - "moonshotai/Kimi-K2-Thinking", # ↔ moonshotai/kimi-k2-thinking - ], -} +# Curated model lists for direct API-key providers — single source in models.py +from hermes_cli.models import _PROVIDER_MODELS def _current_reasoning_effort(config) -> str: @@ -2147,12 +2074,13 @@ def _model_flow_kimi(config, current_model=""): def _model_flow_api_key_provider(config, provider_id, current_model=""): - """Generic flow for API-key providers (z.ai, MiniMax).""" + """Generic flow for API-key providers (z.ai, MiniMax, OpenCode, etc.).""" from hermes_cli.auth import ( PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice, deactivate_provider, ) from hermes_cli.config import get_env_value, save_env_value, load_config, save_config + from hermes_cli.models import fetch_api_models, opencode_model_api_mode, normalize_opencode_model_id pconfig = PROVIDER_REGISTRY[provider_id] key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" @@ -2206,7 +2134,6 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): # Curated list is substantial — use it directly, skip live probe live_models = None else: - from hermes_cli.models import fetch_api_models api_key_for_probe = [redacted:secret-assignment] or (get_env_value(key_env) if key_env else "") live_models = fetch_api_models(api_key_for_probe, effective_base) @@ -2219,6 +2146,11 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): print(f" Showing {len(model_list)} curated models — use \"Enter custom model name\" for others.") # else: no defaults either, will fall through to raw input + if provider_id in {"opencode-zen", "opencode-go"}: + model_list = [normalize_opencode_model_id(provider_id, mid) for mid in model_list] + current_model = normalize_opencode_model_id(provider_id, current_model) + model_list = list(dict.fromkeys(mid for mid in model_list if mid)) + if model_list: selected = _prompt_model_selection(model_list, current_model=current_model) else: @@ -2228,9 +2160,12 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): selected = None if selected: + if provider_id in {"opencode-zen", "opencode-go"}: + selected = normalize_opencode_model_id(provider_id, selected) + _save_model_choice(selected) - # Update config with provider and base URL + # Update config with provider, base URL, and provider-specific API mode cfg = load_config() model = cfg.get("model") if not isinstance(model, dict): @@ -2238,7 +2173,10 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): cfg["model"] = model model["provider"] = provider_id model["base_url"] = effective_base - model.pop("api_mode", None) # let runtime auto-detect from URL + if provider_id in {"opencode-zen", "opencode-go"}: + model["api_mode"] = opencode_model_api_mode(provider_id, selected) + else: + model.pop("api_mode", None) save_config(cfg) deactivate_provider() -
hermes_cli/model_switch.py@@ -26,6 +26,7 @@ class ModelSwitchResult: provider_changed: bool = False api_key: str = "" base_url: str = "" + api_mode: str = "" persist: bool = False error_message: str = "" warning_message: str = "" @@ -73,6 +74,7 @@ def switch_model( detect_provider_for_model, validate_requested_model, _PROVIDER_LABELS, + opencode_model_api_mode, ) from hermes_cli.runtime_provider import resolve_runtime_provider @@ -98,11 +100,13 @@ def switch_model( # Step 4: Resolve credentials for target provider api_key = [redacted:secret-assignment] base_url = current_base_url + api_mode = "" if provider_changed: try: runtime = resolve_runtime_provider(requested=target_provider) api_key = [redacted:secret-assignment]"api_key", "") base_url = runtime.get("base_url", "") + api_mode = runtime.get("api_mode", "") except Exception as e: provider_label = _PROVIDER_LABELS.get(target_provider, target_provider) if target_provider == "custom": @@ -130,6 +134,7 @@ def switch_model( runtime = resolve_runtime_provider(requested=current_provider) api_key = [redacted:secret-assignment]"api_key", "") base_url = runtime.get("base_url", "") + api_mode = runtime.get("api_mode", "") except Exception: pass @@ -166,6 +171,12 @@ def switch_model( and ("localhost" in (base_url or "") or "127.0.0.1" in (base_url or "")) ) + if target_provider in {"opencode-zen", "opencode-go"}: + # Recompute against the requested new model, not the currently-configured + # model used during runtime resolution. OpenCode mixes API surfaces by + # model family, so a same-provider model switch can change api_mode. + api_mode = opencode_model_api_mode(target_provider, new_model) + return ModelSwitchResult( success=True, new_model=new_model, @@ -173,6 +184,7 @@ def switch_model( provider_changed=provider_changed, api_key=[redacted:secret-assignment] base_url=base_url, + api_mode=api_mode, persist=bool(validation.get("persist")), warning_message=validation.get("message") or "", is_custom_target=is_custom_target, -
hermes_cli/models.py@@ -125,6 +125,12 @@ "kimi-k2-turbo-preview", "kimi-k2-0905-preview", ], + "moonshot": [ + "kimi-k2.5", + "kimi-k2-thinking", + "kimi-k2-turbo-preview", + "kimi-k2-0905-preview", + ], "minimax": [ "MiniMax-M2.7", "MiniMax-M2.7-highspeed", @@ -948,6 +954,53 @@ def copilot_model_api_mode( return "chat_completions" +def normalize_opencode_model_id(provider_id: Optional[str], model_id: Optional[str]) -> str: + """Normalize OpenCode config IDs to the bare model slug used in API requests.""" + provider = normalize_provider(provider_id) + current = str(model_id or "").strip() + if not current or provider not in {"opencode-zen", "opencode-go"}: + return current + + prefix = f"{provider}/" + if current.lower().startswith(prefix): + return current[len(prefix):] + return current + + +def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) -> str: + """Determine the API mode for an OpenCode Zen / Go model. + + OpenCode routes different models behind different API surfaces: + + - GPT-5 / Codex models on Zen use ``/v1/responses`` + - Claude models on Zen use ``/v1/messages`` + - MiniMax models on Go use ``/v1/messages`` + - GLM / Kimi on Go use ``/v1/chat/completions`` + - Other Zen models (Gemini, GLM, Kimi, MiniMax, Qwen, etc.) use + ``/v1/chat/completions`` + + This follows the published OpenCode docs for Zen and Go endpoints. + """ + provider = normalize_provider(provider_id) + normalized = normalize_opencode_model_id(provider_id, model_id).lower() + if not normalized: + return "chat_completions" + + if provider == "opencode-go": + if normalized.startswith("minimax-"): + return "anthropic_messages" + return "chat_completions" + + if provider == "opencode-zen": + if normalized.startswith("claude-"): + return "anthropic_messages" + if normalized.startswith("gpt-"): + return "codex_responses" + return "chat_completions" + + return "chat_completions" + + def github_model_reasoning_efforts( model_id: Optional[str], *, -
hermes_cli/runtime_provider.py@@ -82,9 +82,27 @@ def _get_model_config() -> Dict[str, Any]: return {} +def _provider_supports_explicit_api_mode(provider: Optional[str], configured_provider: Optional[str] = None) -> bool: + """Check whether a persisted api_mode should be honored for a given provider. + + Prevents stale api_mode from a previous provider leaking into a + different one after a model/provider switch. Only applies the + persisted mode when the config's provider matches the runtime + provider (or when no configured provider is recorded). + """ + normalized_provider = (provider or "").strip().lower() + normalized_configured = (configured_provider or "").strip().lower() + if not normalized_configured: + return True + if normalized_provider == "custom": + return normalized_configured == "custom" or normalized_configured.startswith("custom:") + return normalized_configured == normalized_provider + + def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str: + configured_provider = str(model_cfg.get("provider") or "").strip().lower() configured_mode = _parse_api_mode(model_cfg.get("api_mode")) - if configured_mode: + if configured_mode and _provider_supports_explicit_api_mode("copilot", configured_provider): return configured_mode model_name = str(model_cfg.get("default") or "").strip() @@ -140,9 +158,13 @@ def _resolve_runtime_from_pool_entry( elif provider == "copilot": api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", "")) else: + configured_provider = str(model_cfg.get("provider") or "").strip().lower() configured_mode = _parse_api_mode(model_cfg.get("api_mode")) - if configured_mode: + if configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider): api_mode = configured_mode + elif provider in ("opencode-zen", "opencode-go"): + from hermes_cli.models import opencode_model_api_mode + api_mode = opencode_model_api_mode(provider, model_cfg.get("default", "")) elif base_url.rstrip("/").endswith("/anthropic"): api_mode = "anthropic_messages" @@ -666,10 +688,14 @@ def resolve_runtime_provider( if provider == "copilot": api_mode = _copilot_runtime_api_mode(model_cfg, creds.get("api_key", "")) else: - # Check explicit api_mode from model config first + configured_provider = str(model_cfg.get("provider") or "").strip().lower() + # Only honor persisted api_mode when it belongs to the same provider family. configured_mode = _parse_api_mode(model_cfg.get("api_mode")) - if configured_mode: + if configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider): api_mode = configured_mode + elif provider in ("opencode-zen", "opencode-go"): + from hermes_cli.models import opencode_model_api_mode + api_mode = opencode_model_api_mode(provider, model_cfg.get("default", "")) # Auto-detect Anthropic-compatible endpoints by URL convention # (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic) elif base_url.rstrip("/").endswith("/anthropic"): -
hermes_cli/setup.py@@ -108,6 +108,8 @@ def _supports_same_provider_pool_setup(provider: str) -> bool: "minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"], "ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"], "kilocode": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"], + "opencode-zen": ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-4-6", "gemini-3-flash", "glm-5", "kimi-k2.5", "minimax-m2.7"], + "opencode-go": ["glm-5", "kimi-k2.5", "minimax-m2.5", "minimax-m2.7"], "huggingface": [ "Qwen/Qwen3.5-397B-A17B", "Qwen/Qwen3-235B-A22B-Thinking-2507", "Qwen/Qwen3-Coder-480B-A35B-Instruct", "deepseek-ai/DeepSeek-R1-0528", @@ -183,6 +185,8 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c fetch_api_models, fetch_github_model_catalog, normalize_copilot_model_id, + normalize_opencode_model_id, + opencode_model_api_mode, ) pconfig = PROVIDER_REGISTRY[provider_id] @@ -236,6 +240,11 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c f" Use \"Custom model\" if the model you expect isn't listed." ) + if provider_id in {"opencode-zen", "opencode-go"}: + provider_models = [normalize_opencode_model_id(provider_id, mid) for mid in provider_models] + current_model = normalize_opencode_model_id(provider_id, current_model) + provider_models = list(dict.fromkeys(mid for mid in provider_models if mid)) + model_choices = list(provider_models) model_choices.append("Custom model") model_choices.append(f"Keep current ({current_model})") @@ -253,6 +262,8 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c catalog=catalog, api_key=[redacted:secret-assignment] ) or selected_model + elif provider_id in {"opencode-zen", "opencode-go"}: + selected_model = normalize_opencode_model_id(provider_id, selected_model) _set_default_model(config, selected_model) elif model_idx == len(provider_models): custom = prompt_fn("Enter model name") @@ -263,6 +274,8 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c catalog=catalog, api_key=[redacted:secret-assignment] ) or custom + elif provider_id in {"opencode-zen", "opencode-go"}: + selected_model = normalize_opencode_model_id(provider_id, custom) else: selected_model = custom _set_default_model(config, selected_model) @@ -294,6 +307,10 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c catalog=catalog, api_key=[redacted:secret-assignment] ) + elif provider_id in {"opencode-zen", "opencode-go"} and selected_model: + model_cfg = _model_config_dict(config) + model_cfg["api_mode"] = opencode_model_api_mode(provider_id, selected_model) + config["model"] = model_cfg def _sync_model_from_disk(config: Dict[str, Any]) -> None: -
tests/hermes_cli/test_model_validation.py@@ -9,7 +9,9 @@ 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 @@ def test_copilot_api_mode_with_catalog_only_responses(self): }] 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 ----------------------------------------------- -
tests/hermes_cli/test_models.py@@ -101,7 +101,14 @@ def test_openrouter_slug_match(self): 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 -
tests/test_codex_models.py@@ -186,6 +186,22 @@ def test_any_provider_prefix_stripped(self): 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): """The untouched default (anthropic/claude-opus-4.6) gets swapped.""" import cli as _cli_mod -
tests/test_model_provider_persistence.py@@ -210,3 +210,50 @@ def test_copilot_acp_provider_saved_when_selected(self, config_home): 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" -
tests/test_runtime_provider_resolution.py@@ -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") -
tests/test_setup_model_selection.py@@ -22,6 +22,8 @@ def __init__(self, name, env_vars, base_url_env, inference_url): "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 @@ def fake_prompt_choice(label, choices, default): ) 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)
-
-
#15106 fix(opencode): derive api_mode from target model, not stale config default
files:
hermes_cli/model_switch.py,hermes_cli/runtime_provider.py,tests/hermes_cli/test_model_switch_custom_providers.py,tests/hermes_cli/test_model_switch_opencode_anthropic.py,tests/hermes_cli/test_ollama_cloud_auth.pyShow the diff
-
hermes_cli/model_switch.py@@ -771,7 +771,10 @@ def switch_model( if provider_changed or explicit_provider: try: - runtime = resolve_runtime_provider(requested=target_provider) + runtime = resolve_runtime_provider( + requested=target_provider, + target_model=new_model, + ) api_key = [redacted:secret-assignment]"api_key", "") base_url = runtime.get("base_url", "") api_mode = runtime.get("api_mode", "") @@ -788,7 +791,10 @@ def switch_model( ) else: try: - runtime = resolve_runtime_provider(requested=current_provider) + runtime = resolve_runtime_provider( + requested=current_provider, + target_model=new_model, + ) api_key = [redacted:secret-assignment]"api_key", "") base_url = runtime.get("base_url", "") api_mode = runtime.get("api_mode", "") -
hermes_cli/runtime_provider.py@@ -160,8 +160,16 @@ def _resolve_runtime_from_pool_entry( requested_provider: str, model_cfg: Optional[Dict[str, Any]] = None, pool: Optional[CredentialPool] = None, + target_model: Optional[str] = None, ) -> Dict[str, Any]: model_cfg = model_cfg or _get_model_config() + # When the caller is resolving for a specific target model (e.g. a /model + # mid-session switch), prefer that over the persisted model.default. This + # prevents api_mode being computed from a stale config default that no + # longer matches the model actually being used — the bug that caused + # opencode-zen /v1 to be stripped for chat_completions requests when + # config.default was still a Claude model. + effective_model = (target_model or model_cfg.get("default") or "") base_url = (getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or "").rstrip("/") api_key = [redacted:secret-assignment] "runtime_api_key", None) or getattr(entry, "access_token", "") api_mode = "chat_completions" @@ -207,7 +215,7 @@ def _resolve_runtime_from_pool_entry( api_mode = configured_mode elif provider in ("opencode-zen", "opencode-go"): from hermes_cli.models import opencode_model_api_mode - api_mode = opencode_model_api_mode(provider, model_cfg.get("default", "")) + api_mode = opencode_model_api_mode(provider, effective_model) else: # Auto-detect Anthropic-compatible endpoints (/anthropic suffix, # Kimi /coding, api.openai.com → codex_responses, api.x.ai → @@ -697,8 +705,18 @@ def resolve_runtime_provider( requested: Optional[str] = None, explicit_api_key: [redacted:secret-assignment] = None, explicit_base_url: Optional[str] = None, + target_model: Optional[str] = None, ) -> Dict[str, Any]: - """Resolve runtime provider credentials for agent execution.""" + """Resolve runtime provider credentials for agent execution. + + target_model: Optional override for model_cfg.get("default") when + computing provider-specific api_mode (e.g. OpenCode Zen/Go where different + models route through different API surfaces). Callers performing an + explicit mid-session model switch should pass the new model here so + api_mode is derived from the model they are switching TO, not the stale + persisted default. Other callers can leave it None to preserve existing + behavior (api_mode derived from config). + """ requested_provider = resolve_requested_provider(requested) custom_runtime = _resolve_named_custom_runtime( @@ -780,6 +798,7 @@ def resolve_runtime_provider( requested_provider=requested_provider, model_cfg=model_cfg, pool=pool, + target_model=target_model, ) if provider == "nous": @@ -998,7 +1017,11 @@ def resolve_runtime_provider( api_mode = configured_mode elif provider in ("opencode-zen", "opencode-go"): from hermes_cli.models import opencode_model_api_mode - api_mode = opencode_model_api_mode(provider, model_cfg.get("default", "")) + # Prefer the target_model from the caller (explicit mid-session + # switch) over the stale model.default; see _resolve_runtime_from_pool_entry + # for the same rationale. + _effective = target_model or model_cfg.get("default", "") + api_mode = opencode_model_api_mode(provider, _effective) else: # Auto-detect Anthropic-compatible endpoints by URL convention # (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic) -
tests/hermes_cli/test_model_switch_custom_providers.py@@ -69,7 +69,7 @@ def test_switch_model_accepts_explicit_named_custom_provider(monkeypatch): """Shared /model switch pipeline should accept --provider for custom_providers.""" monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", - lambda requested: { + lambda **kwargs: { "api_key": "no-key-required", "base_url": "http://127.0.0.1:4141/v1", "api_mode": "chat_completions", -
tests/hermes_cli/test_model_switch_opencode_anthropic.py@@ -250,3 +250,126 @@ def _raise_after_capture(api_key, base_url, **kwargs): f"agent.switch_model did not strip /v1; passed {captured.get('base_url')} " "to build_anthropic_client" ) + + + +class TestStaleConfigDefaultDoesNotWedgeResolver: + """Regression for the real bug Quentin hit. + + When ``model.default`` in config.yaml is an OpenCode Anthropic-routed model + (e.g. ``claude-sonnet-4-6`` on opencode-zen) and the user does ``/model + kimi-k2.6 --provider opencode-zen`` session-only, the resolver must derive + api_mode from the model being requested, not the persisted default. The + earlier bug computed api_mode from ``model_cfg.get("default")``, flipped it + to ``anthropic_messages`` based on the stale Claude default, and stripped + ``/v1``. The chat_completions override in switch_model() fixed api_mode but + never re-added ``/v1``, so requests landed on ``https://opencode.ai/zen`` + and got OpenCode's website 404 HTML page. + + These tests use the REAL ``resolve_runtime_provider`` (not a mock) so a + regression in the target_model plumbing surfaces immediately. + """ + + def test_kimi_switch_keeps_v1_despite_claude_config_default(self, tmp_path, monkeypatch): + import yaml + import importlib + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "test-key") + (tmp_path / "config.yaml").write_text(yaml.safe_dump({ + "model": {"provider": "opencode-zen", "default": "claude-sonnet-4-6"}, + })) + + # Re-import with the new HERMES_HOME so config cache is fresh. + import hermes_cli.config as _cfg_mod + importlib.reload(_cfg_mod) + import hermes_cli.runtime_provider as _rp_mod + importlib.reload(_rp_mod) + import hermes_cli.model_switch as _ms_mod + importlib.reload(_ms_mod) + + result = _ms_mod.switch_model( + raw_input="kimi-k2.6", + current_provider="opencode-zen", + current_model="claude-sonnet-4-6", + current_base_url="https://opencode.ai/zen", # stripped from prior claude turn + current_api_key="[redacted:secret-assignment]", + is_global=False, + explicit_provider="opencode-zen", + ) + + assert result.success, f"switch failed: {result.error_message}" + assert result.base_url == "https://opencode.ai/zen/v1", ( + f"base_url wedged at {result.base_url!r} - stale Claude config.default " + "caused api_mode to be computed as anthropic_messages, stripping /v1, " + "and chat_completions override never re-added it." + ) + assert result.api_mode == "chat_completions" + + def test_go_glm_switch_keeps_v1_despite_minimax_config_default(self, tmp_path, monkeypatch): + import yaml + import importlib + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-key") + monkeypatch.delenv("OPENCODE_ZEN_API_KEY", raising=False) + (tmp_path / "config.yaml").write_text(yaml.safe_dump({ + "model": {"provider": "opencode-go", "default": "minimax-m2.7"}, + })) + + import hermes_cli.config as _cfg_mod + importlib.reload(_cfg_mod) + import hermes_cli.runtime_provider as _rp_mod + importlib.reload(_rp_mod) + import hermes_cli.model_switch as _ms_mod + importlib.reload(_ms_mod) + + result = _ms_mod.switch_model( + raw_input="glm-5.1", + current_provider="opencode-go", + current_model="minimax-m2.7", + current_base_url="https://opencode.ai/zen/go", # stripped from prior minimax turn + current_api_key="[redacted:secret-assignment]", + is_global=False, + explicit_provider="opencode-go", + ) + + assert result.success, f"switch failed: {result.error_message}" + assert result.base_url == "https://opencode.ai/zen/go/v1" + assert result.api_mode == "chat_completions" + + def test_claude_switch_still_strips_v1_with_kimi_config_default(self, tmp_path, monkeypatch): + """Inverse case: config default is chat_completions, switch TO anthropic_messages. + + Guards that the target_model plumbing does not break the original + strip-for-anthropic behavior. + """ + import yaml + import importlib + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "test-key") + (tmp_path / "config.yaml").write_text(yaml.safe_dump({ + "model": {"provider": "opencode-zen", "default": "kimi-k2.6"}, + })) + + import hermes_cli.config as _cfg_mod + importlib.reload(_cfg_mod) + import hermes_cli.runtime_provider as _rp_mod + importlib.reload(_rp_mod) + import hermes_cli.model_switch as _ms_mod + importlib.reload(_ms_mod) + + result = _ms_mod.switch_model( + raw_input="claude-sonnet-4-6", + current_provider="opencode-zen", + current_model="kimi-k2.6", + current_base_url="https://opencode.ai/zen/v1", + current_api_key="[redacted:secret-assignment]", + is_global=False, + explicit_provider="opencode-zen", + ) + + assert result.success, f"switch failed: {result.error_message}" + assert result.base_url == "https://opencode.ai/zen" + assert result.api_mode == "anthropic_messages" -
tests/hermes_cli/test_ollama_cloud_auth.py@@ -518,7 +518,7 @@ def test_switch_model_uses_alias_base_url(self, monkeypatch): monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", - lambda requested: {"api_key": "", "base_url": "", "api_mode": "openai_compat", "provider": "custom"}, + lambda **kwargs: {"api_key": "", "base_url": "", "api_mode": "openai_compat", "provider": "custom"}, ) monkeypatch.setattr("hermes_cli.models.validate_requested_model", @@ -544,7 +544,7 @@ def test_switch_model_alias_no_api_key_gets_default(self, monkeypatch): lambda raw, prov: ("custom", "local-model", "local")) monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", - lambda requested: {"api_key": "", "base_url": "", "api_mode": "openai_compat", "provider": "custom"}, + lambda **kwargs: {"api_key": "", "base_url": "", "api_mode": "openai_compat", "provider": "custom"}, ) monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **kw: {"accepted": True, "persist": True, "recognized": True, "message": None})
-
2 PRs closed without merging
From the close comments: done elsewhere 1 · other 1
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.27: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab