Files
hermes-agent/tests/hermes_cli/test_model_switch_opencode_anthropic.py
ethernet 284dbaf537 fix(pm): isolate bootstrap dependencies and unify YAML on ruamel
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.

Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.

Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.

Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.

Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.

Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
2026-09-11 12:23:51 -04:00

229 lines
9.0 KiB
Python

"""Regression tests for OpenCode /v1 stripping during /model switch.
When switching to an Anthropic-routed OpenCode model mid-session (e.g.
``/model minimax-m2.7`` on opencode-go, or ``/model claude-sonnet-4-6``
on opencode-zen), the resolved base_url must have its trailing ``/v1``
stripped before being handed to the Anthropic SDK.
Without the strip, the SDK prepends its own ``/v1/messages`` path and
requests hit ``https://opencode.ai/zen/go/v1/v1/messages`` — a double
``/v1`` that returns OpenCode's website 404 page with HTML body.
``hermes_cli.runtime_provider.resolve_runtime_provider`` already strips
``/v1`` at fresh agent init (PR #4918), but the ``/model`` mid-session
switch path in ``hermes_cli.model_switch.switch_model`` was missing the
same logic — these tests guard against that regression.
"""
from unittest.mock import patch
import pytest
from hermes_cli.model_switch import switch_model
_MOCK_VALIDATION = {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
def _run_opencode_switch(
raw_input: str,
current_provider: str,
current_model: str,
current_base_url: str,
explicit_provider: str = "",
runtime_base_url: str = "",
):
"""Run switch_model with OpenCode mocks and return the result.
runtime_base_url defaults to current_base_url; tests can override it
to simulate the credential resolver returning a base_url different
from the session's current one.
"""
effective_runtime_base = runtime_base_url or current_base_url
with (
patch("hermes_cli.model_switch.resolve_alias", return_value=None),
patch("hermes_cli.model_switch.list_provider_models", return_value=[]),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
"api_key": "sk-opencode-fake",
"base_url": effective_runtime_base,
"api_mode": "chat_completions",
},
),
patch(
"hermes_cli.models_validate.validate_requested_model",
return_value=_MOCK_VALIDATION,
),
patch("hermes_cli.model_switch.get_model_info", return_value=None),
patch("hermes_cli.model_switch.get_model_capabilities", return_value=None),
patch("hermes_cli.models.detect_provider_for_model", return_value=None),
):
return switch_model(
raw_input=raw_input,
current_provider=current_provider,
current_model=current_model,
current_base_url=current_base_url,
current_api_key="sk-opencode-fake",
explicit_provider=explicit_provider,
)
class TestOpenCodeGoV1Strip:
"""OpenCode Go: ``/model minimax-*`` must strip /v1."""
def test_switch_to_minimax_m27_strips_v1(self):
"""GLM-5 → MiniMax-M2.7: base_url loses trailing /v1."""
result = _run_opencode_switch(
raw_input="minimax-m2.7",
current_provider="opencode-go",
current_model="glm-5",
current_base_url="https://opencode.ai/zen/go/v1",
)
assert result.success, f"switch_model failed: {result.error_message}"
assert result.api_mode == "anthropic_messages"
assert result.base_url == "https://opencode.ai/zen/go", (
f"Expected /v1 stripped for anthropic_messages; got {result.base_url}"
)
class TestOpenCodeZenV1Strip:
"""OpenCode Zen: ``/model claude-*`` must strip /v1."""
def test_switch_to_gpt_uses_codex_responses_keeps_v1(self):
"""GPT on opencode-zen uses codex_responses api_mode — /v1 kept."""
result = _run_opencode_switch(
raw_input="gpt-5.4",
current_provider="opencode-zen",
current_model="claude-sonnet-4-6",
current_base_url="https://opencode.ai/zen",
runtime_base_url="https://opencode.ai/zen/v1",
)
assert result.success
assert result.api_mode == "codex_responses"
assert result.base_url == "https://opencode.ai/zen/v1"
class TestAgentSwitchModelDefenseInDepth:
"""run_agent.AIAgent.switch_model() also strips /v1 as defense-in-depth."""
def test_agent_switch_model_strips_v1_for_anthropic_messages(self):
"""Even if a caller hands in a /v1 URL, the agent strips it."""
from run_agent import AIAgent
# Build a bare agent instance without running __init__; we only want
# to exercise switch_model's base_url normalization logic.
agent = AIAgent.__new__(AIAgent)
agent.model = "glm-5"
agent.provider = "opencode-go"
agent.base_url = "https://opencode.ai/zen/go/v1"
agent.api_key = "sk-opencode-fake"
agent.api_mode = "chat_completions"
agent._client_kwargs = {}
# Intercept the expensive client rebuild — we only need to verify
# that base_url was normalized before it reached the Anthropic
# client factory.
captured = {}
def _fake_build_anthropic_client(api_key, base_url, **kwargs):
captured["api_key"] = api_key
captured["base_url"] = base_url
return object() # placeholder client — no real calls expected
# The downstream cache/plumbing touches a bunch of private state
# that wasn't initialized above; we don't want to rebuild the full
# runtime for this single assertion, so short-circuit after the
# strip by raising inside the stubbed factory.
class _Sentinel(Exception):
pass
def _raise_after_capture(api_key, base_url, **kwargs):
captured["api_key"] = api_key
captured["base_url"] = base_url
raise _Sentinel("strip verified")
with patch(
"agent.anthropic_adapter.build_anthropic_client",
side_effect=_raise_after_capture,
), patch("agent.anthropic_credentials.resolve_anthropic_token", return_value=""), patch(
"agent.anthropic_credentials._is_oauth_token", return_value=False
):
with pytest.raises(_Sentinel):
agent.switch_model(
new_model="minimax-m2.7",
new_provider="opencode-go",
api_key="sk-opencode-fake",
base_url="https://opencode.ai/zen/go/v1",
api_mode="anthropic_messages",
)
assert captured.get("base_url") == "https://opencode.ai/zen/go", (
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 hermes_yaml as 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"