{"repo":"NousResearch/hermes-agent","public_id":"3014","display_label":"OpenCode provider model routing and normalization fixes","summary":"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.","subsystem":"agent","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":4508},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-24T11:58:46+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2723809523809524},"fix_edges":[{"issue_number":13194,"pr_number":15106,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":16878,"pr_number":4508,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":3014,"title":"Fix OpenCode model selection, normalization, and mixed API-surface routing","url":null,"state":"closed"},{"kind":"issue","number":13194,"title":"[Bug]: OpenCode Zen routes kimi-k2.5-free to /messages instead of /chat/completions","url":null,"state":"closed"},{"kind":"issue","number":57288,"title":"[Bug]: Some OpenCode models (like GLM 5.2) fail with `HTTP 404 — Not Found | opencode`","url":null,"state":"closed"},{"kind":"pr","number":3017,"title":"fix: repair OpenCode model routing and selection","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":4508,"title":"fix: repair OpenCode model routing and selection","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -1545,6 +1545,28 @@ def _normalize_model_for_provider(self, resolved_provider: str) -> bool:\n                 pass\n             return changed\n \n+        if resolved_provider in {\"opencode-zen\", \"opencode-go\"}:\n+            try:\n+                from hermes_cli.models import normalize_opencode_model_id, opencode_model_api_mode\n+\n+                canonical = normalize_opencode_model_id(resolved_provider, current_model)\n+                if canonical and canonical != current_model:\n+                    if not self._model_is_default:\n+                        self.console.print(\n+                            f\"[yellow]⚠️  Stripped provider prefix from '{current_model}'; using '{canonical}' for {resolved_provider}.[/]\"\n+                        )\n+                    self.model = canonical\n+                    current_model = canonical\n+                    changed = True\n+\n+                resolved_mode = opencode_model_api_mode(resolved_provider, current_model)\n+                if resolved_mode != self.api_mode:\n+                    self.api_mode = resolved_mode\n+                    changed = True\n+            except Exception:\n+                pass\n+            return changed\n+\n         if resolved_provider != \"openai-codex\":\n             return False\n "]},{"file":"hermes_cli/auth.py","hunks":["@@ -200,6 +200,10 @@ class ProviderConfig:\n         id=\"opencode-go\",\n         name=\"OpenCode Go\",\n         auth_type=\"api_key\",\n+        # OpenCode Go mixes API surfaces by model:\n+        # - GLM / Kimi use OpenAI-compatible chat completions under /v1\n+        # - MiniMax models use Anthropic Messages under /v1/messages\n+        # Keep the provider base at /v1 and select api_mode per-model.\n         inference_base_url=\"https://opencode.ai/zen/go/v1\",\n         api_key_env_vars=(\"OPENCODE_GO_API_KEY\",),\n         base_url_env_var=\"OPENCODE_GO_BASE_URL\","]},{"file":"hermes_cli/main.py","hunks":["@@ -1604,81 +1604,8 @@ def _model_flow_named_custom(config, provider_info):\n     print(f\"   Provider: {name} ({base_url})\")\n \n \n-# Curated model lists for direct API-key providers\n-_PROVIDER_MODELS = {\n-    \"copilot-acp\": [\n-        \"copilot-acp\",\n-    ],\n-    \"copilot\": [\n-        \"gpt-5.4\",\n-        \"gpt-5.4-mini\",\n-        \"gpt-5-mini\",\n-        \"gpt-5.3-codex\",\n-        \"gpt-5.2-codex\",\n-        \"gpt-4.1\",\n-        \"gpt-4o\",\n-        \"gpt-4o-mini\",\n-        \"claude-opus-4.6\",\n-        \"claude-sonnet-4.6\",\n-        \"claude-sonnet-4.5\",\n-        \"claude-haiku-4.5\",\n-        \"gemini-2.5-pro\",\n-        \"grok-code-fast-1\",\n-    ],\n-    \"zai\": [\n-        \"glm-5\",\n-        \"glm-4.7\",\n-        \"glm-4.5\",\n-        \"glm-4.5-flash\",\n-    ],\n-    \"kimi-coding\": [\n-        \"kimi-for-coding\",\n-        \"kimi-k2.5\",\n-        \"kimi-k2-thinking\",\n-        \"kimi-k2-thinking-turbo\",\n-        \"kimi-k2-turbo-preview\",\n-        \"kimi-k2-0905-preview\",\n-    ],\n-    \"moonshot\": [\n-        \"kimi-k2.5\",\n-        \"kimi-k2-thinking\",\n-        \"kimi-k2-turbo-preview\",\n-        \"kimi-k2-0905-preview\",\n-    ],\n-    \"minimax\": [\n-        \"MiniMax-M2.7\",\n-        \"MiniMax-M2.7-highspeed\",\n-        \"MiniMax-M2.5\",\n-        \"MiniMax-M2.5-highspeed\",\n-        \"MiniMax-M2.1\",\n-    ],\n-    \"minimax-cn\": [\n-        \"MiniMax-M2.7\",\n-        \"MiniMax-M2.7-highspeed\",\n-        \"MiniMax-M2.5\",\n-        \"MiniMax-M2.5-highspeed\",\n-        \"MiniMax-M2.1\",\n-    ],\n-    \"kilocode\": [\n-        \"anthropic/claude-opus-4.6\",\n-        \"anthropic/claude-sonnet-4.6\",\n-        \"openai/gpt-5.4\",\n-        \"google/gemini-3-pro-preview\",\n-        \"google/gemini-3-flash-preview\",\n-    ],\n-    # Curated HF model list — only agentic models that map to OpenRouter defaults.\n-    # Format: HF model ID → OpenRouter equivalent noted in comment\n-    \"huggingface\": [\n-        \"Qwen/Qwen3.5-397B-A17B\",                  # ↔ qwen/qwen3.5-plus\n-        \"Qwen/Qwen3.5-35B-A3B\",                     # ↔ qwen/qwen3.5-35b-a3b\n-        \"deepseek-ai/DeepSeek-V3.2\",                # ↔ deepseek/deepseek-chat\n-        \"moonshotai/Kimi-K2.5\",                      # ↔ moonshotai/kimi-k2.5\n-        \"MiniMaxAI/MiniMax-M2.5\",                    # ↔ minimax/minimax-m2.5\n-        \"zai-org/GLM-5\",                             # ↔ z-ai/glm-5\n-        \"XiaomiMiMo/MiMo-V2-Flash\",                 # ↔ xiaomi/mimo-v2-pro\n-        \"moonshotai/Kimi-K2-Thinking\",               # ↔ moonshotai/kimi-k2-thinking\n-    ],\n-}\n+# Curated model lists for direct API-key providers — single source in models.py\n+from hermes_cli.models import _PROVIDER_MODELS\n \n \n def _current_reasoning_effort(config) -> str:","@@ -2147,12 +2074,13 @@ def _model_flow_kimi(config, current_model=\"\"):\n \n \n def _model_flow_api_key_provider(config, provider_id, current_model=\"\"):\n-    \"\"\"Generic flow for API-key providers (z.ai, MiniMax).\"\"\"\n+    \"\"\"Generic flow for API-key providers (z.ai, MiniMax, OpenCode, etc.).\"\"\"\n     from hermes_cli.auth import (\n         PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,\n         deactivate_provider,\n     )\n     from hermes_cli.config import get_env_value, save_env_value, load_config, save_config\n+    from hermes_cli.models import fetch_api_models, opencode_model_api_mode, normalize_opencode_model_id\n \n     pconfig = PROVIDER_REGISTRY[provider_id]\n     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=\"\"):\n         # Curated list is substantial — use it directly, skip live probe\n         live_models = None\n     else:\n-        from hermes_cli.models import fetch_api_models\n         api_key_for_probe = [redacted:secret-assignment] or (get_env_value(key_env) if key_env else \"\")\n         live_models = fetch_api_models(api_key_for_probe, effective_base)\n ","@@ -2219,6 +2146,11 @@ def _model_flow_api_key_provider(config, provider_id, current_model=\"\"):\n             print(f\"  Showing {len(model_list)} curated models — use \\\"Enter custom model name\\\" for others.\")\n         # else: no defaults either, will fall through to raw input\n \n+    if provider_id in {\"opencode-zen\", \"opencode-go\"}:\n+        model_list = [normalize_opencode_model_id(provider_id, mid) for mid in model_list]\n+        current_model = normalize_opencode_model_id(provider_id, current_model)\n+        model_list = list(dict.fromkeys(mid for mid in model_list if mid))\n+\n     if model_list:\n         selected = _prompt_model_selection(model_list, current_model=current_model)\n     else:","@@ -2228,9 +2160,12 @@ def _model_flow_api_key_provider(config, provider_id, current_model=\"\"):\n             selected = None\n \n     if selected:\n+        if provider_id in {\"opencode-zen\", \"opencode-go\"}:\n+            selected = normalize_opencode_model_id(provider_id, selected)\n+\n         _save_model_choice(selected)\n \n-        # Update config with provider and base URL\n+        # Update config with provider, base URL, and provider-specific API mode\n         cfg = load_config()\n         model = cfg.get(\"model\")\n         if not isinstance(model, dict):","@@ -2238,7 +2173,10 @@ def _model_flow_api_key_provider(config, provider_id, current_model=\"\"):\n             cfg[\"model\"] = model\n         model[\"provider\"] = provider_id\n         model[\"base_url\"] = effective_base\n-        model.pop(\"api_mode\", None)  # let runtime auto-detect from URL\n+        if provider_id in {\"opencode-zen\", \"opencode-go\"}:\n+            model[\"api_mode\"] = opencode_model_api_mode(provider_id, selected)\n+        else:\n+            model.pop(\"api_mode\", None)\n         save_config(cfg)\n         deactivate_provider()\n "]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -26,6 +26,7 @@ class ModelSwitchResult:\n     provider_changed: bool = False\n     api_key: str = \"\"\n     base_url: str = \"\"\n+    api_mode: str = \"\"\n     persist: bool = False\n     error_message: str = \"\"\n     warning_message: str = \"\"","@@ -73,6 +74,7 @@ def switch_model(\n         detect_provider_for_model,\n         validate_requested_model,\n         _PROVIDER_LABELS,\n+        opencode_model_api_mode,\n     )\n     from hermes_cli.runtime_provider import resolve_runtime_provider\n ","@@ -98,11 +100,13 @@ def switch_model(\n     # Step 4: Resolve credentials for target provider\n     api_key = [redacted:secret-assignment]\n     base_url = current_base_url\n+    api_mode = \"\"\n     if provider_changed:\n         try:\n             runtime = resolve_runtime_provider(requested=target_provider)\n             api_key = [redacted:secret-assignment]\"api_key\", \"\")\n             base_url = runtime.get(\"base_url\", \"\")\n+            api_mode = runtime.get(\"api_mode\", \"\")\n         except Exception as e:\n             provider_label = _PROVIDER_LABELS.get(target_provider, target_provider)\n             if target_provider == \"custom\":","@@ -130,6 +134,7 @@ def switch_model(\n             runtime = resolve_runtime_provider(requested=current_provider)\n             api_key = [redacted:secret-assignment]\"api_key\", \"\")\n             base_url = runtime.get(\"base_url\", \"\")\n+            api_mode = runtime.get(\"api_mode\", \"\")\n         except Exception:\n             pass\n ","@@ -166,6 +171,12 @@ def switch_model(\n         and (\"localhost\" in (base_url or \"\") or \"127.0.0.1\" in (base_url or \"\"))\n     )\n \n+    if target_provider in {\"opencode-zen\", \"opencode-go\"}:\n+        # Recompute against the requested new model, not the currently-configured\n+        # model used during runtime resolution. OpenCode mixes API surfaces by\n+        # model family, so a same-provider model switch can change api_mode.\n+        api_mode = opencode_model_api_mode(target_provider, new_model)\n+\n     return ModelSwitchResult(\n         success=True,\n         new_model=new_model,","@@ -173,6 +184,7 @@ def switch_model(\n         provider_changed=provider_changed,\n         api_key=[redacted:secret-assignment]\n         base_url=base_url,\n+        api_mode=api_mode,\n         persist=bool(validation.get(\"persist\")),\n         warning_message=validation.get(\"message\") or \"\",\n         is_custom_target=is_custom_target,"]},{"file":"hermes_cli/models.py","hunks":["@@ -125,6 +125,12 @@\n         \"kimi-k2-turbo-preview\",\n         \"kimi-k2-0905-preview\",\n     ],\n+    \"moonshot\": [\n+        \"kimi-k2.5\",\n+        \"kimi-k2-thinking\",\n+        \"kimi-k2-turbo-preview\",\n+        \"kimi-k2-0905-preview\",\n+    ],\n     \"minimax\": [\n         \"MiniMax-M2.7\",\n         \"MiniMax-M2.7-highspeed\",","@@ -948,6 +954,53 @@ def copilot_model_api_mode(\n     return \"chat_completions\"\n \n \n+def normalize_opencode_model_id(provider_id: Optional[str], model_id: Optional[str]) -> str:\n+    \"\"\"Normalize OpenCode config IDs to the bare model slug used in API requests.\"\"\"\n+    provider = normalize_provider(provider_id)\n+    current = str(model_id or \"\").strip()\n+    if not current or provider not in {\"opencode-zen\", \"opencode-go\"}:\n+        return current\n+\n+    prefix = f\"{provider}/\"\n+    if current.lower().startswith(prefix):\n+        return current[len(prefix):]\n+    return current\n+\n+\n+def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) -> str:\n+    \"\"\"Determine the API mode for an OpenCode Zen / Go model.\n+\n+    OpenCode routes different models behind different API surfaces:\n+\n+    - GPT-5 / Codex models on Zen use ``/v1/responses``\n+    - Claude models on Zen use ``/v1/messages``\n+    - MiniMax models on Go use ``/v1/messages``\n+    - GLM / Kimi on Go use ``/v1/chat/completions``\n+    - Other Zen models (Gemini, GLM, Kimi, MiniMax, Qwen, etc.) use\n+      ``/v1/chat/completions``\n+\n+    This follows the published OpenCode docs for Zen and Go endpoints.\n+    \"\"\"\n+    provider = normalize_provider(provider_id)\n+    normalized = normalize_opencode_model_id(provider_id, model_id).lower()\n+    if not normalized:\n+        return \"chat_completions\"\n+\n+    if provider == \"opencode-go\":\n+        if normalized.startswith(\"minimax-\"):\n+            return \"anthropic_messages\"\n+        return \"chat_completions\"\n+\n+    if provider == \"opencode-zen\":\n+        if normalized.startswith(\"claude-\"):\n+            return \"anthropic_messages\"\n+        if normalized.startswith(\"gpt-\"):\n+            return \"codex_responses\"\n+        return \"chat_completions\"\n+\n+    return \"chat_completions\"\n+\n+\n def github_model_reasoning_efforts(\n     model_id: Optional[str],\n     *,"]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -82,9 +82,27 @@ def _get_model_config() -> Dict[str, Any]:\n     return {}\n \n \n+def _provider_supports_explicit_api_mode(provider: Optional[str], configured_provider: Optional[str] = None) -> bool:\n+    \"\"\"Check whether a persisted api_mode should be honored for a given provider.\n+\n+    Prevents stale api_mode from a previous provider leaking into a\n+    different one after a model/provider switch.  Only applies the\n+    persisted mode when the config's provider matches the runtime\n+    provider (or when no configured provider is recorded).\n+    \"\"\"\n+    normalized_provider = (provider or \"\").strip().lower()\n+    normalized_configured = (configured_provider or \"\").strip().lower()\n+    if not normalized_configured:\n+        return True\n+    if normalized_provider == \"custom\":\n+        return normalized_configured == \"custom\" or normalized_configured.startswith(\"custom:\")\n+    return normalized_configured == normalized_provider\n+\n+\n def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str:\n+    configured_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n     configured_mode = _parse_api_mode(model_cfg.get(\"api_mode\"))\n-    if configured_mode:\n+    if configured_mode and _provider_supports_explicit_api_mode(\"copilot\", configured_provider):\n         return configured_mode\n \n     model_name = str(model_cfg.get(\"default\") or \"\").strip()","@@ -140,9 +158,13 @@ def _resolve_runtime_from_pool_entry(\n     elif provider == \"copilot\":\n         api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, \"runtime_api_key\", \"\"))\n     else:\n+        configured_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n         configured_mode = _parse_api_mode(model_cfg.get(\"api_mode\"))\n-        if configured_mode:\n+        if configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider):\n             api_mode = configured_mode\n+        elif provider in (\"opencode-zen\", \"opencode-go\"):\n+            from hermes_cli.models import opencode_model_api_mode\n+            api_mode = opencode_model_api_mode(provider, model_cfg.get(\"default\", \"\"))\n         elif base_url.rstrip(\"/\").endswith(\"/anthropic\"):\n             api_mode = \"anthropic_messages\"\n ","@@ -666,10 +688,14 @@ def resolve_runtime_provider(\n         if provider == \"copilot\":\n             api_mode = _copilot_runtime_api_mode(model_cfg, creds.get(\"api_key\", \"\"))\n         else:\n-            # Check explicit api_mode from model config first\n+            configured_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n+            # Only honor persisted api_mode when it belongs to the same provider family.\n             configured_mode = _parse_api_mode(model_cfg.get(\"api_mode\"))\n-            if configured_mode:\n+            if configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider):\n                 api_mode = configured_mode\n+            elif provider in (\"opencode-zen\", \"opencode-go\"):\n+                from hermes_cli.models import opencode_model_api_mode\n+                api_mode = opencode_model_api_mode(provider, model_cfg.get(\"default\", \"\"))\n             # Auto-detect Anthropic-compatible endpoints by URL convention\n             # (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)\n             elif base_url.rstrip(\"/\").endswith(\"/anthropic\"):"]},{"file":"hermes_cli/setup.py","hunks":["@@ -108,6 +108,8 @@ def _supports_same_provider_pool_setup(provider: str) -> bool:\n     \"minimax-cn\": [\"MiniMax-M2.7\", \"MiniMax-M2.7-highspeed\", \"MiniMax-M2.5\", \"MiniMax-M2.5-highspeed\", \"MiniMax-M2.1\"],\n     \"ai-gateway\": [\"anthropic/claude-opus-4.6\", \"anthropic/claude-sonnet-4.6\", \"openai/gpt-5\", \"google/gemini-3-flash\"],\n     \"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\"],\n+    \"opencode-zen\": [\"gpt-5.4\", \"gpt-5.3-codex\", \"claude-sonnet-4-6\", \"gemini-3-flash\", \"glm-5\", \"kimi-k2.5\", \"minimax-m2.7\"],\n+    \"opencode-go\": [\"glm-5\", \"kimi-k2.5\", \"minimax-m2.5\", \"minimax-m2.7\"],\n     \"huggingface\": [\n         \"Qwen/Qwen3.5-397B-A17B\", \"Qwen/Qwen3-235B-A22B-Thinking-2507\",\n         \"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\n         fetch_api_models,\n         fetch_github_model_catalog,\n         normalize_copilot_model_id,\n+        normalize_opencode_model_id,\n+        opencode_model_api_mode,\n     )\n \n     pconfig = PROVIDER_REGISTRY[provider_id]","@@ -236,6 +240,11 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c\n                 f\"    Use \\\"Custom model\\\" if the model you expect isn't listed.\"\n             )\n \n+    if provider_id in {\"opencode-zen\", \"opencode-go\"}:\n+        provider_models = [normalize_opencode_model_id(provider_id, mid) for mid in provider_models]\n+        current_model = normalize_opencode_model_id(provider_id, current_model)\n+        provider_models = list(dict.fromkeys(mid for mid in provider_models if mid))\n+\n     model_choices = list(provider_models)\n     model_choices.append(\"Custom model\")\n     model_choices.append(f\"Keep current ({current_model})\")","@@ -253,6 +262,8 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c\n                 catalog=catalog,\n                 api_key=[redacted:secret-assignment]\n             ) or selected_model\n+        elif provider_id in {\"opencode-zen\", \"opencode-go\"}:\n+            selected_model = normalize_opencode_model_id(provider_id, selected_model)\n         _set_default_model(config, selected_model)\n     elif model_idx == len(provider_models):\n         custom = prompt_fn(\"Enter model name\")","@@ -263,6 +274,8 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c\n                     catalog=catalog,\n                     api_key=[redacted:secret-assignment]\n                 ) or custom\n+            elif provider_id in {\"opencode-zen\", \"opencode-go\"}:\n+                selected_model = normalize_opencode_model_id(provider_id, custom)\n             else:\n                 selected_model = custom\n             _set_default_model(config, selected_model)","@@ -294,6 +307,10 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c\n             catalog=catalog,\n             api_key=[redacted:secret-assignment]\n         )\n+    elif provider_id in {\"opencode-zen\", \"opencode-go\"} and selected_model:\n+        model_cfg = _model_config_dict(config)\n+        model_cfg[\"api_mode\"] = opencode_model_api_mode(provider_id, selected_model)\n+        config[\"model\"] = model_cfg\n \n \n def _sync_model_from_disk(config: Dict[str, Any]) -> None:"]},{"file":"tests/hermes_cli/test_model_validation.py","hunks":["@@ -9,7 +9,9 @@\n     fetch_api_models,\n     github_model_reasoning_efforts,\n     normalize_copilot_model_id,\n+    normalize_opencode_model_id,\n     normalize_provider,\n+    opencode_model_api_mode,\n     parse_model_input,\n     probe_api_models,\n     provider_label,","@@ -339,6 +341,28 @@ def test_copilot_api_mode_with_catalog_only_responses(self):\n         }]\n         assert copilot_model_api_mode(\"gpt-5.4\", catalog=catalog) == \"codex_responses\"\n \n+    def test_normalize_opencode_model_id_strips_provider_prefix(self):\n+        assert normalize_opencode_model_id(\"opencode-go\", \"opencode-go/kimi-k2.5\") == \"kimi-k2.5\"\n+        assert normalize_opencode_model_id(\"opencode-zen\", \"opencode-zen/claude-sonnet-4-6\") == \"claude-sonnet-4-6\"\n+        assert normalize_opencode_model_id(\"opencode-go\", \"glm-5\") == \"glm-5\"\n+\n+    def test_opencode_zen_api_modes_match_docs(self):\n+        assert opencode_model_api_mode(\"opencode-zen\", \"gpt-5.4\") == \"codex_responses\"\n+        assert opencode_model_api_mode(\"opencode-zen\", \"gpt-5.3-codex\") == \"codex_responses\"\n+        assert opencode_model_api_mode(\"opencode-zen\", \"opencode-zen/gpt-5.4\") == \"codex_responses\"\n+        assert opencode_model_api_mode(\"opencode-zen\", \"claude-sonnet-4-6\") == \"anthropic_messages\"\n+        assert opencode_model_api_mode(\"opencode-zen\", \"opencode-zen/claude-sonnet-4-6\") == \"anthropic_messages\"\n+        assert opencode_model_api_mode(\"opencode-zen\", \"gemini-3-flash\") == \"chat_completions\"\n+        assert opencode_model_api_mode(\"opencode-zen\", \"minimax-m2.5\") == \"chat_completions\"\n+\n+    def test_opencode_go_api_modes_match_docs(self):\n+        assert opencode_model_api_mode(\"opencode-go\", \"glm-5\") == \"chat_completions\"\n+        assert opencode_model_api_mode(\"opencode-go\", \"opencode-go/glm-5\") == \"chat_completions\"\n+        assert opencode_model_api_mode(\"opencode-go\", \"kimi-k2.5\") == \"chat_completions\"\n+        assert opencode_model_api_mode(\"opencode-go\", \"opencode-go/kimi-k2.5\") == \"chat_completions\"\n+        assert opencode_model_api_mode(\"opencode-go\", \"minimax-m2.5\") == \"anthropic_messages\"\n+        assert opencode_model_api_mode(\"opencode-go\", \"opencode-go/minimax-m2.5\") == \"anthropic_messages\"\n+\n \n # -- validate — format checks -----------------------------------------------\n "]},{"file":"tests/hermes_cli/test_models.py","hunks":["@@ -101,7 +101,14 @@ def test_openrouter_slug_match(self):\n         assert result[0] == \"openrouter\"\n         assert result[1] == \"anthropic/claude-opus-4.6\"\n \n-    def test_bare_name_gets_openrouter_slug(self):\n+    def test_bare_name_gets_openrouter_slug(self, monkeypatch):\n+        for env_var in (\n+            \"ANTHROPIC_API_KEY\",\n+            \"ANTHROPIC_TOKEN\",\n+            \"CLAUDE_CODE_TOKEN\",\n+            \"CLAUDE_CODE_OAUTH_TOKEN\",\n+        ):\n+            monkeypatch.delenv(env_var, raising=False)\n         \"\"\"Bare model names should get mapped to full OpenRouter slugs.\"\"\"\n         result = detect_provider_for_model(\"claude-opus-4.6\", \"openai-codex\")\n         assert result is not None"]},{"file":"tests/test_codex_models.py","hunks":["@@ -186,6 +186,22 @@ def test_any_provider_prefix_stripped(self):\n         assert changed is True\n         assert cli.model == \"claude-opus-4.6\"\n \n+    def test_opencode_go_prefix_stripped(self):\n+        cli = _make_cli(model=\"opencode-go/kimi-k2.5\")\n+        cli.api_mode = \"chat_completions\"\n+        changed = cli._normalize_model_for_provider(\"opencode-go\")\n+        assert changed is True\n+        assert cli.model == \"kimi-k2.5\"\n+        assert cli.api_mode == \"chat_completions\"\n+\n+    def test_opencode_zen_claude_sets_messages_mode(self):\n+        cli = _make_cli(model=\"opencode-zen/claude-sonnet-4-6\")\n+        cli.api_mode = \"chat_completions\"\n+        changed = cli._normalize_model_for_provider(\"opencode-zen\")\n+        assert changed is True\n+        assert cli.model == \"claude-sonnet-4-6\"\n+        assert cli.api_mode == \"anthropic_messages\"\n+\n     def test_default_model_replaced(self):\n         \"\"\"The untouched default (anthropic/claude-opus-4.6) gets swapped.\"\"\"\n         import cli as _cli_mod"]},{"file":"tests/test_model_provider_persistence.py","hunks":["@@ -210,3 +210,50 @@ def test_copilot_acp_provider_saved_when_selected(self, config_home):\n         assert model.get(\"base_url\") == \"acp://copilot\"\n         assert model.get(\"default\") == \"gpt-5.4\"\n         assert model.get(\"api_mode\") == \"chat_completions\"\n+\n+    def test_opencode_go_models_are_selectable_and_persist_normalized(self, config_home, monkeypatch):\n+        from hermes_cli.main import _model_flow_api_key_provider\n+        from hermes_cli.config import load_config\n+\n+        monkeypatch.setenv(\"OPENCODE_GO_API_KEY\", \"test-key\")\n+\n+        with patch(\"hermes_cli.models.fetch_api_models\", return_value=[\"opencode-go/kimi-k2.5\", \"opencode-go/minimax-m2.7\"]), \\\n+             patch(\"hermes_cli.auth._prompt_model_selection\", return_value=\"kimi-k2.5\"), \\\n+             patch(\"hermes_cli.auth.deactivate_provider\"), \\\n+             patch(\"builtins.input\", return_value=\"\"):\n+            _model_flow_api_key_provider(load_config(), \"opencode-go\", \"opencode-go/kimi-k2.5\")\n+\n+        import yaml\n+        config = yaml.safe_load((config_home / \"config.yaml\").read_text()) or {}\n+        model = config.get(\"model\")\n+        assert isinstance(model, dict)\n+        assert model.get(\"provider\") == \"opencode-go\"\n+        assert model.get(\"default\") == \"kimi-k2.5\"\n+        assert model.get(\"api_mode\") == \"chat_completions\"\n+\n+    def test_opencode_go_same_provider_switch_recomputes_api_mode(self, config_home, monkeypatch):\n+        from hermes_cli.main import _model_flow_api_key_provider\n+        from hermes_cli.config import load_config\n+\n+        monkeypatch.setenv(\"OPENCODE_GO_API_KEY\", \"test-key\")\n+        (config_home / \"config.yaml\").write_text(\n+            \"model:\\n\"\n+            \"  default: kimi-k2.5\\n\"\n+            \"  provider: opencode-go\\n\"\n+            \"  base_url: https://opencode.ai/zen/go/v1\\n\"\n+            \"  api_mode: chat_completions\\n\"\n+        )\n+\n+        with patch(\"hermes_cli.models.fetch_api_models\", return_value=[\"opencode-go/kimi-k2.5\", \"opencode-go/minimax-m2.5\"]), \\\n+             patch(\"hermes_cli.auth._prompt_model_selection\", return_value=\"minimax-m2.5\"), \\\n+             patch(\"hermes_cli.auth.deactivate_provider\"), \\\n+             patch(\"builtins.input\", return_value=\"\"):\n+            _model_flow_api_key_provider(load_config(), \"opencode-go\", \"kimi-k2.5\")\n+\n+        import yaml\n+        config = yaml.safe_load((config_home / \"config.yaml\").read_text()) or {}\n+        model = config.get(\"model\")\n+        assert isinstance(model, dict)\n+        assert model.get(\"provider\") == \"opencode-go\"\n+        assert model.get(\"default\") == \"minimax-m2.5\"\n+        assert model.get(\"api_mode\") == \"anthropic_messages\""]},{"file":"tests/test_runtime_provider_resolution.py","hunks":["@@ -643,6 +643,34 @@ def test_model_config_api_mode(monkeypatch):\n     assert resolved[\"base_url\"] == \"http://127.0.0.1:9208/v1\"\n \n \n+def test_model_config_api_mode_ignored_when_provider_differs(monkeypatch):\n+    monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"zai\")\n+    monkeypatch.setattr(\n+        rp,\n+        \"_get_model_config\",\n+        lambda: {\n+            \"provider\": \"opencode-go\",\n+            \"default\": \"minimax-m2.5\",\n+            \"api_mode\": \"anthropic_messages\",\n+        },\n+    )\n+    monkeypatch.setattr(\n+        rp,\n+        \"resolve_api_key_provider_credentials\",\n+        lambda provider: {\n+            \"provider\": provider,\n+            \"api_key\": \"test-key\",\n+            \"base_url\": \"https://api.z.ai/api/paas/v4\",\n+            \"source\": \"env\",\n+        },\n+    )\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"zai\")\n+\n+    assert resolved[\"provider\"] == \"zai\"\n+    assert resolved[\"api_mode\"] == \"chat_completions\"\n+\n+\n def test_invalid_api_mode_ignored(monkeypatch):\n     \"\"\"Invalid api_mode values should fall back to chat_completions.\"\"\"\n     monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"openrouter\")","@@ -808,6 +836,78 @@ def test_alibaba_anthropic_endpoint_override_uses_anthropic_messages(monkeypatch\n     assert resolved[\"base_url\"] == \"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic\"\n \n \n+def test_opencode_zen_gpt_defaults_to_responses(monkeypatch):\n+    monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"opencode-zen\")\n+    monkeypatch.setattr(rp, \"_get_model_config\", lambda: {\"default\": \"gpt-5.4\"})\n+    monkeypatch.setenv(\"OPENCODE_ZEN_API_KEY\", \"test-opencode-zen-key\")\n+    monkeypatch.delenv(\"OPENCODE_ZEN_BASE_URL\", raising=False)\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"opencode-zen\")\n+\n+    assert resolved[\"provider\"] == \"opencode-zen\"\n+    assert resolved[\"api_mode\"] == \"codex_responses\"\n+    assert resolved[\"base_url\"] == \"https://opencode.ai/zen/v1\"\n+\n+\n+def test_opencode_zen_claude_defaults_to_messages(monkeypatch):\n+    monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"opencode-zen\")\n+    monkeypatch.setattr(rp, \"_get_model_config\", lambda: {\"default\": \"claude-sonnet-4-6\"})\n+    monkeypatch.setenv(\"OPENCODE_ZEN_API_KEY\", \"test-opencode-zen-key\")\n+    monkeypatch.delenv(\"OPENCODE_ZEN_BASE_URL\", raising=False)\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"opencode-zen\")\n+\n+    assert resolved[\"provider\"] == \"opencode-zen\"\n+    assert resolved[\"api_mode\"] == \"anthropic_messages\"\n+    assert resolved[\"base_url\"] == \"https://opencode.ai/zen/v1\"\n+\n+\n+def test_opencode_go_minimax_defaults_to_messages(monkeypatch):\n+    monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"opencode-go\")\n+    monkeypatch.setattr(rp, \"_get_model_config\", lambda: {\"default\": \"minimax-m2.5\"})\n+    monkeypatch.setenv(\"OPENCODE_GO_API_KEY\", \"test-opencode-go-key\")\n+    monkeypatch.delenv(\"OPENCODE_GO_BASE_URL\", raising=False)\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"opencode-go\")\n+\n+    assert resolved[\"provider\"] == \"opencode-go\"\n+    assert resolved[\"api_mode\"] == \"anthropic_messages\"\n+    assert resolved[\"base_url\"] == \"https://opencode.ai/zen/go/v1\"\n+\n+\n+def test_opencode_go_glm_defaults_to_chat_completions(monkeypatch):\n+    monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"opencode-go\")\n+    monkeypatch.setattr(rp, \"_get_model_config\", lambda: {\"default\": \"glm-5\"})\n+    monkeypatch.setenv(\"OPENCODE_GO_API_KEY\", \"test-opencode-go-key\")\n+    monkeypatch.delenv(\"OPENCODE_GO_BASE_URL\", raising=False)\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"opencode-go\")\n+\n+    assert resolved[\"provider\"] == \"opencode-go\"\n+    assert resolved[\"api_mode\"] == \"chat_completions\"\n+    assert resolved[\"base_url\"] == \"https://opencode.ai/zen/go/v1\"\n+\n+\n+def test_opencode_go_configured_api_mode_still_overrides_default(monkeypatch):\n+    monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"opencode-go\")\n+    monkeypatch.setattr(\n+        rp,\n+        \"_get_model_config\",\n+        lambda: {\n+            \"provider\": \"opencode-go\",\n+            \"default\": \"minimax-m2.5\",\n+            \"api_mode\": \"chat_completions\",\n+        },\n+    )\n+    monkeypatch.setenv(\"OPENCODE_GO_API_KEY\", \"test-opencode-go-key\")\n+    monkeypatch.delenv(\"OPENCODE_GO_BASE_URL\", raising=False)\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"opencode-go\")\n+\n+    assert resolved[\"provider\"] == \"opencode-go\"\n+    assert resolved[\"api_mode\"] == \"chat_completions\"\n+\n+\n def test_named_custom_provider_anthropic_api_mode(monkeypatch):\n     \"\"\"Custom providers should accept api_mode: anthropic_messages.\"\"\"\n     monkeypatch.setattr(rp, \"resolve_provider\", lambda *a, **k: \"my-anthropic-proxy\")"]},{"file":"tests/test_setup_model_selection.py","hunks":["@@ -22,6 +22,8 @@ def __init__(self, name, env_vars, base_url_env, inference_url):\n         \"kimi-coding\": FakePConfig(\"Kimi Coding\", [\"KIMI_API_KEY\"], \"KIMI_BASE_URL\", \"https://api.kimi.example\"),\n         \"minimax\": FakePConfig(\"MiniMax\", [\"MINIMAX_API_KEY\"], \"MINIMAX_BASE_URL\", \"https://api.minimax.example\"),\n         \"minimax-cn\": FakePConfig(\"MiniMax CN\", [\"MINIMAX_API_KEY\"], \"MINIMAX_CN_BASE_URL\", \"https://api.minimax-cn.example\"),\n+        \"opencode-zen\": FakePConfig(\"OpenCode Zen\", [\"OPENCODE_ZEN_API_KEY\"], \"OPENCODE_ZEN_BASE_URL\", \"https://opencode.ai/zen/v1\"),\n+        \"opencode-go\": FakePConfig(\"OpenCode Go\", [\"OPENCODE_GO_API_KEY\"], \"OPENCODE_GO_BASE_URL\", \"https://opencode.ai/zen/go/v1\"),\n     }\n \n ","@@ -34,6 +36,8 @@ class TestSetupProviderModelSelection:\n         (\"kimi-coding\", [\"kimi-k2.5\", \"kimi-k2-thinking\", \"kimi-k2-turbo-preview\"]),\n         (\"minimax\", [\"MiniMax-M2.7\", \"MiniMax-M2.7-highspeed\", \"MiniMax-M2.5\", \"MiniMax-M2.5-highspeed\", \"MiniMax-M2.1\"]),\n         (\"minimax-cn\", [\"MiniMax-M2.7\", \"MiniMax-M2.7-highspeed\", \"MiniMax-M2.5\", \"MiniMax-M2.5-highspeed\", \"MiniMax-M2.1\"]),\n+        (\"opencode-zen\", [\"gpt-5.4\", \"gpt-5.3-codex\", \"claude-sonnet-4-6\", \"gemini-3-flash\"]),\n+        (\"opencode-go\", [\"glm-5\", \"kimi-k2.5\", \"minimax-m2.5\", \"minimax-m2.7\"]),\n     ])\n     @patch(\"hermes_cli.models.fetch_api_models\", return_value=[])\n     @patch(\"hermes_cli.config.get_env_value\", return_value=\"fake-key\")","@@ -122,3 +126,30 @@ def fake_prompt_choice(label, choices, default):\n             )\n \n         assert config[\"model\"][\"default\"] == \"my-custom-model\"\n+\n+    @patch(\"hermes_cli.models.fetch_api_models\", return_value=[\"opencode-go/kimi-k2.5\", \"opencode-go/minimax-m2.7\"])\n+    @patch(\"hermes_cli.config.get_env_value\", return_value=\"fake-key\")\n+    def test_opencode_live_models_are_normalized_for_selection(\n+        self, mock_env, mock_fetch, mock_provider_registry\n+    ):\n+        from hermes_cli.setup import _setup_provider_model_selection\n+\n+        captured_choices = {}\n+\n+        def fake_prompt_choice(label, choices, default):\n+            captured_choices[\"choices\"] = choices\n+            return len(choices) - 1\n+\n+        with patch(\"hermes_cli.auth.PROVIDER_REGISTRY\", mock_provider_registry):\n+            _setup_provider_model_selection(\n+                config={\"model\": {}},\n+                provider_id=\"opencode-go\",\n+                current_model=\"opencode-go/kimi-k2.5\",\n+                prompt_choice=fake_prompt_choice,\n+                prompt_fn=lambda _: None,\n+            )\n+\n+        offered = captured_choices[\"choices\"]\n+        assert \"kimi-k2.5\" in offered\n+        assert \"minimax-m2.7\" in offered\n+        assert all(\"opencode-go/\" not in choice for choice in offered)\n"]}]},{"kind":"pr","number":13334,"title":"fix(opencode): route kimi through chat completions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1eb29e645","number":0},{"kind":"pr","value":"#15106","number":15106},{"kind":"pr","value":"#15106","number":15106}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15106,"title":"fix(opencode): derive api_mode from target model, not stale config default","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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.py"],"fix_hunks":[{"file":"hermes_cli/model_switch.py","hunks":["@@ -771,7 +771,10 @@ def switch_model(\n \n     if provider_changed or explicit_provider:\n         try:\n-            runtime = resolve_runtime_provider(requested=target_provider)\n+            runtime = resolve_runtime_provider(\n+                requested=target_provider,\n+                target_model=new_model,\n+            )\n             api_key = [redacted:secret-assignment]\"api_key\", \"\")\n             base_url = runtime.get(\"base_url\", \"\")\n             api_mode = runtime.get(\"api_mode\", \"\")","@@ -788,7 +791,10 @@ def switch_model(\n             )\n     else:\n         try:\n-            runtime = resolve_runtime_provider(requested=current_provider)\n+            runtime = resolve_runtime_provider(\n+                requested=current_provider,\n+                target_model=new_model,\n+            )\n             api_key = [redacted:secret-assignment]\"api_key\", \"\")\n             base_url = runtime.get(\"base_url\", \"\")\n             api_mode = runtime.get(\"api_mode\", \"\")"]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -160,8 +160,16 @@ def _resolve_runtime_from_pool_entry(\n     requested_provider: str,\n     model_cfg: Optional[Dict[str, Any]] = None,\n     pool: Optional[CredentialPool] = None,\n+    target_model: Optional[str] = None,\n ) -> Dict[str, Any]:\n     model_cfg = model_cfg or _get_model_config()\n+    # When the caller is resolving for a specific target model (e.g. a /model\n+    # mid-session switch), prefer that over the persisted model.default. This\n+    # prevents api_mode being computed from a stale config default that no\n+    # longer matches the model actually being used — the bug that caused\n+    # opencode-zen /v1 to be stripped for chat_completions requests when\n+    # config.default was still a Claude model.\n+    effective_model = (target_model or model_cfg.get(\"default\") or \"\")\n     base_url = (getattr(entry, \"runtime_base_url\", None) or getattr(entry, \"base_url\", None) or \"\").rstrip(\"/\")\n     api_key = [redacted:secret-assignment] \"runtime_api_key\", None) or getattr(entry, \"access_token\", \"\")\n     api_mode = \"chat_completions\"","@@ -207,7 +215,7 @@ def _resolve_runtime_from_pool_entry(\n             api_mode = configured_mode\n         elif provider in (\"opencode-zen\", \"opencode-go\"):\n             from hermes_cli.models import opencode_model_api_mode\n-            api_mode = opencode_model_api_mode(provider, model_cfg.get(\"default\", \"\"))\n+            api_mode = opencode_model_api_mode(provider, effective_model)\n         else:\n             # Auto-detect Anthropic-compatible endpoints (/anthropic suffix,\n             # Kimi /coding, api.openai.com → codex_responses, api.x.ai →","@@ -697,8 +705,18 @@ def resolve_runtime_provider(\n     requested: Optional[str] = None,\n     explicit_api_key: [redacted:secret-assignment] = None,\n     explicit_base_url: Optional[str] = None,\n+    target_model: Optional[str] = None,\n ) -> Dict[str, Any]:\n-    \"\"\"Resolve runtime provider credentials for agent execution.\"\"\"\n+    \"\"\"Resolve runtime provider credentials for agent execution.\n+\n+    target_model: Optional override for model_cfg.get(\"default\") when\n+    computing provider-specific api_mode (e.g. OpenCode Zen/Go where different\n+    models route through different API surfaces). Callers performing an\n+    explicit mid-session model switch should pass the new model here so\n+    api_mode is derived from the model they are switching TO, not the stale\n+    persisted default. Other callers can leave it None to preserve existing\n+    behavior (api_mode derived from config).\n+    \"\"\"\n     requested_provider = resolve_requested_provider(requested)\n \n     custom_runtime = _resolve_named_custom_runtime(","@@ -780,6 +798,7 @@ def resolve_runtime_provider(\n                 requested_provider=requested_provider,\n                 model_cfg=model_cfg,\n                 pool=pool,\n+                target_model=target_model,\n             )\n \n     if provider == \"nous\":","@@ -998,7 +1017,11 @@ def resolve_runtime_provider(\n                 api_mode = configured_mode\n             elif provider in (\"opencode-zen\", \"opencode-go\"):\n                 from hermes_cli.models import opencode_model_api_mode\n-                api_mode = opencode_model_api_mode(provider, model_cfg.get(\"default\", \"\"))\n+                # Prefer the target_model from the caller (explicit mid-session\n+                # switch) over the stale model.default; see _resolve_runtime_from_pool_entry\n+                # for the same rationale.\n+                _effective = target_model or model_cfg.get(\"default\", \"\")\n+                api_mode = opencode_model_api_mode(provider, _effective)\n             else:\n                 # Auto-detect Anthropic-compatible endpoints by URL convention\n                 # (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)"]},{"file":"tests/hermes_cli/test_model_switch_custom_providers.py","hunks":["@@ -69,7 +69,7 @@ def test_switch_model_accepts_explicit_named_custom_provider(monkeypatch):\n     \"\"\"Shared /model switch pipeline should accept --provider for custom_providers.\"\"\"\n     monkeypatch.setattr(\n         \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n-        lambda requested: {\n+        lambda **kwargs: {\n             \"api_key\": \"no-key-required\",\n             \"base_url\": \"http://127.0.0.1:4141/v1\",\n             \"api_mode\": \"chat_completions\","]},{"file":"tests/hermes_cli/test_model_switch_opencode_anthropic.py","hunks":["@@ -250,3 +250,126 @@ def _raise_after_capture(api_key, base_url, **kwargs):\n             f\"agent.switch_model did not strip /v1; passed {captured.get('base_url')} \"\n             \"to build_anthropic_client\"\n         )\n+\n+\n+\n+class TestStaleConfigDefaultDoesNotWedgeResolver:\n+    \"\"\"Regression for the real bug Quentin hit.\n+\n+    When ``model.default`` in config.yaml is an OpenCode Anthropic-routed model\n+    (e.g. ``claude-sonnet-4-6`` on opencode-zen) and the user does ``/model\n+    kimi-k2.6 --provider opencode-zen`` session-only, the resolver must derive\n+    api_mode from the model being requested, not the persisted default. The\n+    earlier bug computed api_mode from ``model_cfg.get(\"default\")``, flipped it\n+    to ``anthropic_messages`` based on the stale Claude default, and stripped\n+    ``/v1``. The chat_completions override in switch_model() fixed api_mode but\n+    never re-added ``/v1``, so requests landed on ``https://opencode.ai/zen``\n+    and got OpenCode's website 404 HTML page.\n+\n+    These tests use the REAL ``resolve_runtime_provider`` (not a mock) so a\n+    regression in the target_model plumbing surfaces immediately.\n+    \"\"\"\n+\n+    def test_kimi_switch_keeps_v1_despite_claude_config_default(self, tmp_path, monkeypatch):\n+        import yaml\n+        import importlib\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        monkeypatch.setenv(\"OPENCODE_ZEN_API_KEY\", \"test-key\")\n+        (tmp_path / \"config.yaml\").write_text(yaml.safe_dump({\n+            \"model\": {\"provider\": \"opencode-zen\", \"default\": \"claude-sonnet-4-6\"},\n+        }))\n+\n+        # Re-import with the new HERMES_HOME so config cache is fresh.\n+        import hermes_cli.config as _cfg_mod\n+        importlib.reload(_cfg_mod)\n+        import hermes_cli.runtime_provider as _rp_mod\n+        importlib.reload(_rp_mod)\n+        import hermes_cli.model_switch as _ms_mod\n+        importlib.reload(_ms_mod)\n+\n+        result = _ms_mod.switch_model(\n+            raw_input=\"kimi-k2.6\",\n+            current_provider=\"opencode-zen\",\n+            current_model=\"claude-sonnet-4-6\",\n+            current_base_url=\"https://opencode.ai/zen\",  # stripped from prior claude turn\n+            current_api_key=\"[redacted:secret-assignment]\",\n+            is_global=False,\n+            explicit_provider=\"opencode-zen\",\n+        )\n+\n+        assert result.success, f\"switch failed: {result.error_message}\"\n+        assert result.base_url == \"https://opencode.ai/zen/v1\", (\n+            f\"base_url wedged at {result.base_url!r} - stale Claude config.default \"\n+            \"caused api_mode to be computed as anthropic_messages, stripping /v1, \"\n+            \"and chat_completions override never re-added it.\"\n+        )\n+        assert result.api_mode == \"chat_completions\"\n+\n+    def test_go_glm_switch_keeps_v1_despite_minimax_config_default(self, tmp_path, monkeypatch):\n+        import yaml\n+        import importlib\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        monkeypatch.setenv(\"OPENCODE_GO_API_KEY\", \"test-key\")\n+        monkeypatch.delenv(\"OPENCODE_ZEN_API_KEY\", raising=False)\n+        (tmp_path / \"config.yaml\").write_text(yaml.safe_dump({\n+            \"model\": {\"provider\": \"opencode-go\", \"default\": \"minimax-m2.7\"},\n+        }))\n+\n+        import hermes_cli.config as _cfg_mod\n+        importlib.reload(_cfg_mod)\n+        import hermes_cli.runtime_provider as _rp_mod\n+        importlib.reload(_rp_mod)\n+        import hermes_cli.model_switch as _ms_mod\n+        importlib.reload(_ms_mod)\n+\n+        result = _ms_mod.switch_model(\n+            raw_input=\"glm-5.1\",\n+            current_provider=\"opencode-go\",\n+            current_model=\"minimax-m2.7\",\n+            current_base_url=\"https://opencode.ai/zen/go\",  # stripped from prior minimax turn\n+            current_api_key=\"[redacted:secret-assignment]\",\n+            is_global=False,\n+            explicit_provider=\"opencode-go\",\n+        )\n+\n+        assert result.success, f\"switch failed: {result.error_message}\"\n+        assert result.base_url == \"https://opencode.ai/zen/go/v1\"\n+        assert result.api_mode == \"chat_completions\"\n+\n+    def test_claude_switch_still_strips_v1_with_kimi_config_default(self, tmp_path, monkeypatch):\n+        \"\"\"Inverse case: config default is chat_completions, switch TO anthropic_messages.\n+\n+        Guards that the target_model plumbing does not break the original\n+        strip-for-anthropic behavior.\n+        \"\"\"\n+        import yaml\n+        import importlib\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        monkeypatch.setenv(\"OPENCODE_ZEN_API_KEY\", \"test-key\")\n+        (tmp_path / \"config.yaml\").write_text(yaml.safe_dump({\n+            \"model\": {\"provider\": \"opencode-zen\", \"default\": \"kimi-k2.6\"},\n+        }))\n+\n+        import hermes_cli.config as _cfg_mod\n+        importlib.reload(_cfg_mod)\n+        import hermes_cli.runtime_provider as _rp_mod\n+        importlib.reload(_rp_mod)\n+        import hermes_cli.model_switch as _ms_mod\n+        importlib.reload(_ms_mod)\n+\n+        result = _ms_mod.switch_model(\n+            raw_input=\"claude-sonnet-4-6\",\n+            current_provider=\"opencode-zen\",\n+            current_model=\"kimi-k2.6\",\n+            current_base_url=\"https://opencode.ai/zen/v1\",\n+            current_api_key=\"[redacted:secret-assignment]\",\n+            is_global=False,\n+            explicit_provider=\"opencode-zen\",\n+        )\n+\n+        assert result.success, f\"switch failed: {result.error_message}\"\n+        assert result.base_url == \"https://opencode.ai/zen\"\n+        assert result.api_mode == \"anthropic_messages\""]},{"file":"tests/hermes_cli/test_ollama_cloud_auth.py","hunks":["@@ -518,7 +518,7 @@ def test_switch_model_uses_alias_base_url(self, monkeypatch):\n \n         monkeypatch.setattr(\n             \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n-            lambda requested: {\"api_key\": \"\", \"base_url\": \"\", \"api_mode\": \"openai_compat\", \"provider\": \"custom\"},\n+            lambda **kwargs: {\"api_key\": \"\", \"base_url\": \"\", \"api_mode\": \"openai_compat\", \"provider\": \"custom\"},\n         )\n \n         monkeypatch.setattr(\"hermes_cli.models.validate_requested_model\",","@@ -544,7 +544,7 @@ def test_switch_model_alias_no_api_key_gets_default(self, monkeypatch):\n             lambda raw, prov: (\"custom\", \"local-model\", \"local\"))\n         monkeypatch.setattr(\n             \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n-            lambda requested: {\"api_key\": \"\", \"base_url\": \"\", \"api_mode\": \"openai_compat\", \"provider\": \"custom\"},\n+            lambda **kwargs: {\"api_key\": \"\", \"base_url\": \"\", \"api_mode\": \"openai_compat\", \"provider\": \"custom\"},\n         )\n         monkeypatch.setattr(\"hermes_cli.models.validate_requested_model\",\n             lambda *a, **kw: {\"accepted\": True, \"persist\": True, \"recognized\": True, \"message\": None})\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}