diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index c914caa84dd..b9a50f6df63 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1303,20 +1303,19 @@ def switch_model( api_mode = determine_api_mode(target_provider, base_url) # OpenCode base URLs end with /v1 for OpenAI-compatible models, but the - # Anthropic SDK prepends its own /v1/messages to the base_url. Strip the - # trailing /v1 so the SDK constructs the correct path (e.g. - # https://opencode.ai/zen/go/v1/messages instead of .../v1/v1/messages). - # Mirrors the same logic in hermes_cli.runtime_provider.resolve_runtime_provider; - # without it, /model switches into an anthropic_messages-routed OpenCode - # model (e.g. `/model minimax-m2.7` on opencode-go, `/model claude-sonnet-4-6` - # on opencode-zen) hit a double /v1 and returned OpenCode's website 404 page. - if ( - api_mode == "anthropic_messages" - and target_provider in {"opencode-zen", "opencode-go"} - and isinstance(base_url, str) - and base_url - ): - base_url = re.sub(r"/v1/?$", "", base_url) + # Anthropic SDK prepends its own /v1/messages to the base_url. Normalize + # symmetrically (strip /v1 for anthropic_messages, re-append it for + # chat_completions / codex_responses). Mirrors the same logic in + # hermes_cli.runtime_provider.resolve_runtime_provider; without the strip, + # /model switches into an anthropic_messages-routed OpenCode model + # (e.g. `/model minimax-m2.7` on opencode-go, `/model claude-sonnet-4-6` + # on opencode-zen) hit a double /v1 and returned OpenCode's website 404 + # page — and without the re-append, a stripped URL persisted to + # model.base_url broke every later chat_completions model (glm, deepseek, + # kimi) the same way. + if target_provider in {"opencode-zen", "opencode-go"} and isinstance(base_url, str): + from hermes_cli.models import normalize_opencode_base_url + base_url = normalize_opencode_base_url(target_provider, api_mode, base_url) # --- Get capabilities (legacy) --- capabilities = get_model_capabilities(target_provider, new_model) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index b7bac169bfe..b9b5fc69967 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -415,14 +415,18 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "gemini-3.5-flash", "gemini-3.1-pro", "gemini-3-flash", + "minimax-m3", "minimax-m2.7", "minimax-m2.5", "minimax-m3-free", + "glm-5.2", "glm-5.1", "glm-5", + "kimi-k2.7-code", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-free", + "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-plus-free", "qwen3.5-plus", @@ -433,17 +437,23 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "nemotron-3-ultra-free", ], "opencode-go": [ + "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", + "glm-5.2", "glm-5.1", "glm-5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", + "minimax-m3", "minimax-m2.7", "minimax-m2.5", + "deepseek-v4-pro", + "deepseek-v4-flash", "qwen3.7-max", + "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", ], @@ -3373,12 +3383,14 @@ def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) - 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 + - MiniMax and Qwen models on Go use ``/v1/messages`` + - GLM / Kimi / DeepSeek / MiMo on Go use ``/v1/chat/completions`` + - Qwen models on Zen use ``/v1/messages`` + - Other Zen models (Gemini, GLM, Kimi, MiniMax, DeepSeek, etc.) use ``/v1/chat/completions`` - This follows the published OpenCode docs for Zen and Go endpoints. + This follows the published OpenCode docs for Zen and Go endpoints + (https://opencode.ai/docs/zen/ and https://opencode.ai/docs/go/). """ provider = normalize_provider(provider_id) normalized = normalize_opencode_model_id(provider_id, model_id).lower() @@ -3388,7 +3400,9 @@ def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) if provider == "opencode-go": if normalized.startswith("minimax-"): return "anthropic_messages" - if normalized.startswith("qwen3.7-max"): + if normalized.startswith("qwen"): + # All Qwen models on Go (qwen3.7-max, qwen3.7-plus, qwen3.6-plus) + # are served via /v1/messages per the published Go endpoint table. return "anthropic_messages" return "chat_completions" @@ -3397,11 +3411,61 @@ def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) return "anthropic_messages" if normalized.startswith("gpt-"): return "codex_responses" + if normalized.startswith("qwen"): + # Qwen models on Zen moved to /v1/messages per the published + # Zen endpoint table. + return "anthropic_messages" return "chat_completions" return "chat_completions" +def normalize_opencode_base_url( + provider_id: Optional[str], api_mode: Optional[str], base_url: Optional[str] +) -> str: + """Normalize an OpenCode Zen / Go base URL for the target API mode. + + OpenCode's OpenAI-compatible endpoints live under ``/v1`` (the OpenAI SDK + appends ``/chat/completions`` or ``/responses``), while the Anthropic SDK + appends its own ``/v1/messages`` — so anthropic_messages needs the ``/v1`` + suffix stripped. + + Crucially this must be SYMMETRIC. The stripped URL gets persisted to + config (``model.base_url``) by the TUI/desktop and gateway after switching + into an anthropic-routed model (e.g. minimax-m2.7 on Go). A later switch + to a chat_completions model (glm, deepseek, kimi) then inherited the + stripped URL and POSTed to ``https://opencode.ai/zen/go/chat/completions`` + — a 404 (the marketing site). Re-append ``/v1`` for non-anthropic modes + so previously-stripped URLs heal themselves. + + Only opencode.ai-hosted URLs are re-suffixed; custom proxy overrides via + ``OPENCODE_*_BASE_URL`` are left alone unless they already carry ``/v1``. + """ + url = str(base_url or "").strip().rstrip("/") + if not url: + return url + provider = normalize_provider(provider_id) + if provider not in {"opencode-zen", "opencode-go"}: + return url + + import re as _re + + if api_mode == "anthropic_messages": + return _re.sub(r"/v1$", "", url) + + # chat_completions / codex_responses: ensure the /v1 suffix is present on + # official opencode.ai hosts (heals a persisted anthropic-stripped URL). + if url.endswith("/v1"): + return url + try: + host = urllib.parse.urlparse(url).netloc.lower() + except Exception: + host = "" + if host == "opencode.ai" or host.endswith(".opencode.ai"): + return url + "/v1" + return url + + def github_model_reasoning_efforts( model_id: Optional[str], *, diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index e93fa5317fa..ddc7ccecd50 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -503,11 +503,14 @@ def _resolve_runtime_from_pool_entry( api_mode = detected # OpenCode base URLs end with /v1 for OpenAI-compatible models, but the - # Anthropic SDK prepends its own /v1/messages to the base_url. Strip the - # trailing /v1 so the SDK constructs the correct path (e.g. - # https://opencode.ai/zen/go/v1/messages instead of .../v1/v1/messages). - if api_mode == "anthropic_messages" and provider in {"opencode-zen", "opencode-go"}: - base_url = re.sub(r"/v1/?$", "", base_url) + # Anthropic SDK prepends its own /v1/messages to the base_url. Normalize + # symmetrically: strip /v1 for anthropic_messages, re-append it for + # chat_completions / codex_responses (heals a stripped URL persisted to + # model.base_url by an earlier switch into an anthropic-routed model). + if provider in {"opencode-zen", "opencode-go"}: + from hermes_cli.models import normalize_opencode_base_url + + base_url = normalize_opencode_base_url(provider, api_mode, base_url) # Optional opt-in: route OpenAI/Codex turns through `codex app-server`. # Inert when `model.openai_runtime` is unset or "auto". @@ -2025,9 +2028,10 @@ def resolve_runtime_provider( detected = _detect_api_mode_for_url(base_url) if detected: api_mode = detected - # Strip trailing /v1 for OpenCode Anthropic models (see comment above). - if api_mode == "anthropic_messages" and provider in {"opencode-zen", "opencode-go"}: - base_url = re.sub(r"/v1/?$", "", base_url) + # Normalize the /v1 suffix for OpenCode by API mode (see comment above). + if provider in {"opencode-zen", "opencode-go"}: + from hermes_cli.models import normalize_opencode_base_url + base_url = normalize_opencode_base_url(provider, api_mode, base_url) if provider == "lmstudio": base_url = auth_mod._normalize_lmstudio_runtime_base_url(base_url) return { diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index ca621f915d0..113a4f05a68 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -424,6 +424,9 @@ class TestCopilotNormalization: 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" + # Qwen on Zen is served via /v1/messages per the Zen endpoint table. + assert opencode_model_api_mode("opencode-zen", "qwen3.7-max") == "anthropic_messages" + assert opencode_model_api_mode("opencode-zen", "qwen3.6-plus") == "anthropic_messages" def test_opencode_go_api_modes_match_docs(self): assert opencode_model_api_mode("opencode-go", "glm-5.1") == "chat_completions" @@ -436,6 +439,80 @@ class TestCopilotNormalization: assert opencode_model_api_mode("opencode-go", "opencode-go/minimax-m2.5") == "anthropic_messages" assert opencode_model_api_mode("opencode-go", "qwen3.7-max") == "anthropic_messages" assert opencode_model_api_mode("opencode-go", "opencode-go/qwen3.7-max") == "anthropic_messages" + # All Qwen models on Go route via /v1/messages (Go endpoint table). + assert opencode_model_api_mode("opencode-go", "qwen3.7-plus") == "anthropic_messages" + assert opencode_model_api_mode("opencode-go", "qwen3.6-plus") == "anthropic_messages" + # DeepSeek / MiMo on Go are OpenAI-compatible chat completions. + assert opencode_model_api_mode("opencode-go", "deepseek-v4-pro") == "chat_completions" + assert opencode_model_api_mode("opencode-go", "deepseek-v4-flash") == "chat_completions" + assert opencode_model_api_mode("opencode-go", "mimo-v2.5") == "chat_completions" + assert opencode_model_api_mode("opencode-go", "kimi-k2.7-code") == "chat_completions" + assert opencode_model_api_mode("opencode-go", "glm-5.2") == "chat_completions" + assert opencode_model_api_mode("opencode-go", "minimax-m3") == "anthropic_messages" + + +class TestNormalizeOpencodeBaseUrl: + """Symmetric /v1 normalization for OpenCode Zen / Go base URLs. + + Regression for the 'only minimax works on opencode-go' bug: switching into + an anthropic-routed model strips /v1 from the base URL and that stripped + URL gets persisted to model.base_url; every later chat_completions model + (glm, deepseek, kimi) then POSTed to https://opencode.ai/zen/go/chat/completions + — a 404 (the marketing site). The normalizer must heal a stripped URL. + """ + + def test_strips_v1_for_anthropic_messages(self): + from hermes_cli.models import normalize_opencode_base_url + assert normalize_opencode_base_url( + "opencode-go", "anthropic_messages", "https://opencode.ai/zen/go/v1" + ) == "https://opencode.ai/zen/go" + assert normalize_opencode_base_url( + "opencode-zen", "anthropic_messages", "https://opencode.ai/zen/v1/" + ) == "https://opencode.ai/zen" + + def test_strip_is_idempotent(self): + from hermes_cli.models import normalize_opencode_base_url + assert normalize_opencode_base_url( + "opencode-go", "anthropic_messages", "https://opencode.ai/zen/go" + ) == "https://opencode.ai/zen/go" + + def test_reappends_v1_for_chat_completions(self): + from hermes_cli.models import normalize_opencode_base_url + # The healing case: a stripped URL persisted by a prior anthropic switch. + assert normalize_opencode_base_url( + "opencode-go", "chat_completions", "https://opencode.ai/zen/go" + ) == "https://opencode.ai/zen/go/v1" + assert normalize_opencode_base_url( + "opencode-zen", "codex_responses", "https://opencode.ai/zen" + ) == "https://opencode.ai/zen/v1" + + def test_reappend_is_idempotent(self): + from hermes_cli.models import normalize_opencode_base_url + assert normalize_opencode_base_url( + "opencode-go", "chat_completions", "https://opencode.ai/zen/go/v1" + ) == "https://opencode.ai/zen/go/v1" + + def test_custom_host_not_suffixed(self): + from hermes_cli.models import normalize_opencode_base_url + # A user's proxy override without /v1 is left alone (we can't know its + # path layout), but the anthropic strip still applies when it has /v1. + assert normalize_opencode_base_url( + "opencode-go", "chat_completions", "https://myproxy.example.com/opencode" + ) == "https://myproxy.example.com/opencode" + assert normalize_opencode_base_url( + "opencode-go", "anthropic_messages", "https://myproxy.example.com/opencode/v1" + ) == "https://myproxy.example.com/opencode" + + def test_non_opencode_provider_untouched(self): + from hermes_cli.models import normalize_opencode_base_url + assert normalize_opencode_base_url( + "openrouter", "chat_completions", "https://openrouter.ai/api" + ) == "https://openrouter.ai/api" + + def test_empty_url_passthrough(self): + from hermes_cli.models import normalize_opencode_base_url + assert normalize_opencode_base_url("opencode-go", "chat_completions", "") == "" + assert normalize_opencode_base_url("opencode-go", "chat_completions", None) == "" class TestAzureFoundryModelApiMode: diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 4923cd86b23..54f58b85808 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -1683,6 +1683,34 @@ def test_opencode_go_model_derivation_beats_stale_persisted_api_mode(monkeypatch assert resolved["api_mode"] == "anthropic_messages" +def test_opencode_go_heals_persisted_stripped_base_url(monkeypatch): + """A stripped base_url persisted after switching into an anthropic-routed + model (e.g. minimax-m2.7 on Go) must be healed back to .../v1 when the + target model routes via chat_completions. Without the heal, glm/deepseek/ + kimi POST to https://opencode.ai/zen/go/chat/completions — a 404 (the + marketing site). This was the 'only minimax works on opencode-go' bug. + """ + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-go") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "opencode-go", + "default": "glm-5.1", + # Stripped URL persisted by a previous anthropic_messages switch. + "base_url": "https://opencode.ai/zen/go", + }, + ) + 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_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")