mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
372f8195c7
commit
6eb39c2bbe
5 changed files with 199 additions and 27 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue