fix(opencode-go): heal stripped /v1 base_url so non-minimax models stop 404ing (#57585)

OpenCode Go serves minimax/qwen via Anthropic Messages (base URL without
/v1 — the SDK appends /v1/messages) and glm/kimi/deepseek/mimo via OpenAI
chat completions (base URL WITH /v1). The runtime stripped /v1 for
anthropic-routed models, and the TUI/desktop + gateway persisted that
stripped URL to model.base_url. Every later chat_completions model then
POSTed to https://opencode.ai/zen/go/chat/completions — a 404 (the
marketing site). Result: only minimax worked; glm/deepseek/kimi all 404ed.

- New normalize_opencode_base_url(): symmetric /v1 normalization —
  strip for anthropic_messages, re-append for chat_completions /
  codex_responses on opencode.ai hosts (heals persisted stripped URLs;
  custom proxy overrides untouched)
- Applied at all three former one-way strip sites (resolve_runtime_provider
  x2, switch_model)
- opencode_model_api_mode: all Qwen models on Go AND Zen now route via
  /v1/messages per current published endpoint tables (previously only
  qwen3.7-max on Go — qwen3.6-plus etc. would 404 the same way)
- Catalog refresh: Go gains deepseek-v4-pro/flash, glm-5.2,
  kimi-k2.7-code, minimax-m3, qwen3.7-plus; Zen gains glm-5.2,
  kimi-k2.7-code, minimax-m3, qwen3.7-plus

Reported by IndieSuperhuman on X: opencode-go 404s for any model other
than minimax.
This commit is contained in:
Teknium 2026-07-03 00:46:45 -07:00 committed by GitHub
parent 372f8195c7
commit 6eb39c2bbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 199 additions and 27 deletions

View file

@ -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)

View file

@ -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],
*,

View file

@ -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 {

View file

@ -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:

View file

@ -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")