fix(opencode): derive api_mode from target model, not stale config default (#15106)

/model kimi-k2.6 on opencode-zen (or glm-5.1 on opencode-go) returned OpenCode's
website 404 HTML page when the user's persisted model.default was a Claude or
MiniMax model. The switched-to chat_completions request hit
https://opencode.ai/zen (or /zen/go) with no /v1 suffix.

Root cause: resolve_runtime_provider() computed api_mode from
model_cfg.get('default') instead of the model being requested. With a Claude
default, it resolved api_mode=anthropic_messages, stripped /v1 from base_url
(required for the Anthropic SDK), then switch_model()'s opencode_model_api_mode
override flipped api_mode back to chat_completions without restoring /v1.

Fix: thread an optional target_model kwarg through resolve_runtime_provider
and _resolve_runtime_from_pool_entry. When the caller is performing an explicit
mid-session model switch (i.e. switch_model()), the target model drives both
api_mode selection and the conditional /v1 strip. Other callers (CLI init,
gateway init, cron, ACP, aux client, delegate, account_usage, tui_gateway) pass
nothing and preserve the existing config-default behavior.

Regression tests added in test_model_switch_opencode_anthropic.py use the REAL
resolver (not a mock) to guard the exact Quentin-repro scenario. Existing tests
that mocked resolve_runtime_provider with 'lambda requested:' had their mock
signatures widened to '**kwargs' to accept the new kwarg.
This commit is contained in:
Teknium 2026-04-24 04:58:46 -07:00 committed by GitHub
parent 7634c1386f
commit 1eb29e6452
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 160 additions and 8 deletions

View file

@ -250,3 +250,126 @@ class TestAgentSwitchModelDefenseInDepth:
f"agent.switch_model did not strip /v1; passed {captured.get('base_url')} "
"to build_anthropic_client"
)
class TestStaleConfigDefaultDoesNotWedgeResolver:
"""Regression for the real bug Quentin hit.
When ``model.default`` in config.yaml is an OpenCode Anthropic-routed model
(e.g. ``claude-sonnet-4-6`` on opencode-zen) and the user does ``/model
kimi-k2.6 --provider opencode-zen`` session-only, the resolver must derive
api_mode from the model being requested, not the persisted default. The
earlier bug computed api_mode from ``model_cfg.get("default")``, flipped it
to ``anthropic_messages`` based on the stale Claude default, and stripped
``/v1``. The chat_completions override in switch_model() fixed api_mode but
never re-added ``/v1``, so requests landed on ``https://opencode.ai/zen``
and got OpenCode's website 404 HTML page.
These tests use the REAL ``resolve_runtime_provider`` (not a mock) so a
regression in the target_model plumbing surfaces immediately.
"""
def test_kimi_switch_keeps_v1_despite_claude_config_default(self, tmp_path, monkeypatch):
import yaml
import importlib
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "test-key")
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
"model": {"provider": "opencode-zen", "default": "claude-sonnet-4-6"},
}))
# Re-import with the new HERMES_HOME so config cache is fresh.
import hermes_cli.config as _cfg_mod
importlib.reload(_cfg_mod)
import hermes_cli.runtime_provider as _rp_mod
importlib.reload(_rp_mod)
import hermes_cli.model_switch as _ms_mod
importlib.reload(_ms_mod)
result = _ms_mod.switch_model(
raw_input="kimi-k2.6",
current_provider="opencode-zen",
current_model="claude-sonnet-4-6",
current_base_url="https://opencode.ai/zen", # stripped from prior claude turn
current_api_key="test-key",
is_global=False,
explicit_provider="opencode-zen",
)
assert result.success, f"switch failed: {result.error_message}"
assert result.base_url == "https://opencode.ai/zen/v1", (
f"base_url wedged at {result.base_url!r} - stale Claude config.default "
"caused api_mode to be computed as anthropic_messages, stripping /v1, "
"and chat_completions override never re-added it."
)
assert result.api_mode == "chat_completions"
def test_go_glm_switch_keeps_v1_despite_minimax_config_default(self, tmp_path, monkeypatch):
import yaml
import importlib
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-key")
monkeypatch.delenv("OPENCODE_ZEN_API_KEY", raising=False)
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
"model": {"provider": "opencode-go", "default": "minimax-m2.7"},
}))
import hermes_cli.config as _cfg_mod
importlib.reload(_cfg_mod)
import hermes_cli.runtime_provider as _rp_mod
importlib.reload(_rp_mod)
import hermes_cli.model_switch as _ms_mod
importlib.reload(_ms_mod)
result = _ms_mod.switch_model(
raw_input="glm-5.1",
current_provider="opencode-go",
current_model="minimax-m2.7",
current_base_url="https://opencode.ai/zen/go", # stripped from prior minimax turn
current_api_key="test-key",
is_global=False,
explicit_provider="opencode-go",
)
assert result.success, f"switch failed: {result.error_message}"
assert result.base_url == "https://opencode.ai/zen/go/v1"
assert result.api_mode == "chat_completions"
def test_claude_switch_still_strips_v1_with_kimi_config_default(self, tmp_path, monkeypatch):
"""Inverse case: config default is chat_completions, switch TO anthropic_messages.
Guards that the target_model plumbing does not break the original
strip-for-anthropic behavior.
"""
import yaml
import importlib
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "test-key")
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
"model": {"provider": "opencode-zen", "default": "kimi-k2.6"},
}))
import hermes_cli.config as _cfg_mod
importlib.reload(_cfg_mod)
import hermes_cli.runtime_provider as _rp_mod
importlib.reload(_rp_mod)
import hermes_cli.model_switch as _ms_mod
importlib.reload(_ms_mod)
result = _ms_mod.switch_model(
raw_input="claude-sonnet-4-6",
current_provider="opencode-zen",
current_model="kimi-k2.6",
current_base_url="https://opencode.ai/zen/v1",
current_api_key="test-key",
is_global=False,
explicit_provider="opencode-zen",
)
assert result.success, f"switch failed: {result.error_message}"
assert result.base_url == "https://opencode.ai/zen"
assert result.api_mode == "anthropic_messages"