test_model_switch_opencode_anthropic.py
1 """Regression tests for OpenCode /v1 stripping during /model switch. 2 3 When switching to an Anthropic-routed OpenCode model mid-session (e.g. 4 ``/model minimax-m2.7`` on opencode-go, or ``/model claude-sonnet-4-6`` 5 on opencode-zen), the resolved base_url must have its trailing ``/v1`` 6 stripped before being handed to the Anthropic SDK. 7 8 Without the strip, the SDK prepends its own ``/v1/messages`` path and 9 requests hit ``https://opencode.ai/zen/go/v1/v1/messages`` — a double 10 ``/v1`` that returns OpenCode's website 404 page with HTML body. 11 12 ``hermes_cli.runtime_provider.resolve_runtime_provider`` already strips 13 ``/v1`` at fresh agent init (PR #4918), but the ``/model`` mid-session 14 switch path in ``hermes_cli.model_switch.switch_model`` was missing the 15 same logic — these tests guard against that regression. 16 """ 17 18 from unittest.mock import patch 19 20 import pytest 21 22 from hermes_cli.model_switch import switch_model 23 24 25 _MOCK_VALIDATION = { 26 "accepted": True, 27 "persist": True, 28 "recognized": True, 29 "message": None, 30 } 31 32 33 def _run_opencode_switch( 34 raw_input: str, 35 current_provider: str, 36 current_model: str, 37 current_base_url: str, 38 explicit_provider: str = "", 39 runtime_base_url: str = "", 40 ): 41 """Run switch_model with OpenCode mocks and return the result. 42 43 runtime_base_url defaults to current_base_url; tests can override it 44 to simulate the credential resolver returning a base_url different 45 from the session's current one. 46 """ 47 effective_runtime_base = runtime_base_url or current_base_url 48 with ( 49 patch("hermes_cli.model_switch.resolve_alias", return_value=None), 50 patch("hermes_cli.model_switch.list_provider_models", return_value=[]), 51 patch( 52 "hermes_cli.runtime_provider.resolve_runtime_provider", 53 return_value={ 54 "api_key": "sk-opencode-fake", 55 "base_url": effective_runtime_base, 56 "api_mode": "chat_completions", 57 }, 58 ), 59 patch( 60 "hermes_cli.models.validate_requested_model", 61 return_value=_MOCK_VALIDATION, 62 ), 63 patch("hermes_cli.model_switch.get_model_info", return_value=None), 64 patch("hermes_cli.model_switch.get_model_capabilities", return_value=None), 65 patch("hermes_cli.models.detect_provider_for_model", return_value=None), 66 ): 67 return switch_model( 68 raw_input=raw_input, 69 current_provider=current_provider, 70 current_model=current_model, 71 current_base_url=current_base_url, 72 current_api_key="sk-opencode-fake", 73 explicit_provider=explicit_provider, 74 ) 75 76 77 class TestOpenCodeGoV1Strip: 78 """OpenCode Go: ``/model minimax-*`` must strip /v1.""" 79 80 def test_switch_to_minimax_m27_strips_v1(self): 81 """GLM-5 → MiniMax-M2.7: base_url loses trailing /v1.""" 82 result = _run_opencode_switch( 83 raw_input="minimax-m2.7", 84 current_provider="opencode-go", 85 current_model="glm-5", 86 current_base_url="https://opencode.ai/zen/go/v1", 87 ) 88 89 assert result.success, f"switch_model failed: {result.error_message}" 90 assert result.api_mode == "anthropic_messages" 91 assert result.base_url == "https://opencode.ai/zen/go", ( 92 f"Expected /v1 stripped for anthropic_messages; got {result.base_url}" 93 ) 94 95 def test_switch_to_minimax_m25_strips_v1(self): 96 """Same behavior for M2.5.""" 97 result = _run_opencode_switch( 98 raw_input="minimax-m2.5", 99 current_provider="opencode-go", 100 current_model="kimi-k2.5", 101 current_base_url="https://opencode.ai/zen/go/v1", 102 ) 103 104 assert result.success 105 assert result.api_mode == "anthropic_messages" 106 assert result.base_url == "https://opencode.ai/zen/go" 107 108 def test_switch_to_glm_leaves_v1_intact(self): 109 """OpenAI-compatible models (GLM, Kimi, MiMo) keep /v1.""" 110 result = _run_opencode_switch( 111 raw_input="glm-5.1", 112 current_provider="opencode-go", 113 current_model="minimax-m2.7", 114 current_base_url="https://opencode.ai/zen/go", # stripped from previous Anthropic model 115 runtime_base_url="https://opencode.ai/zen/go/v1", 116 ) 117 118 assert result.success 119 assert result.api_mode == "chat_completions" 120 assert result.base_url == "https://opencode.ai/zen/go/v1", ( 121 f"chat_completions must keep /v1; got {result.base_url}" 122 ) 123 124 def test_switch_to_kimi_leaves_v1_intact(self): 125 result = _run_opencode_switch( 126 raw_input="kimi-k2.5", 127 current_provider="opencode-go", 128 current_model="glm-5", 129 current_base_url="https://opencode.ai/zen/go/v1", 130 ) 131 132 assert result.success 133 assert result.api_mode == "chat_completions" 134 assert result.base_url == "https://opencode.ai/zen/go/v1" 135 136 def test_trailing_slash_also_stripped(self): 137 """``/v1/`` with trailing slash is also stripped cleanly.""" 138 result = _run_opencode_switch( 139 raw_input="minimax-m2.7", 140 current_provider="opencode-go", 141 current_model="glm-5", 142 current_base_url="https://opencode.ai/zen/go/v1/", 143 ) 144 145 assert result.success 146 assert result.api_mode == "anthropic_messages" 147 assert result.base_url == "https://opencode.ai/zen/go" 148 149 150 class TestOpenCodeZenV1Strip: 151 """OpenCode Zen: ``/model claude-*`` must strip /v1.""" 152 153 def test_switch_to_claude_sonnet_strips_v1(self): 154 """Gemini → Claude on opencode-zen: /v1 stripped.""" 155 result = _run_opencode_switch( 156 raw_input="claude-sonnet-4-6", 157 current_provider="opencode-zen", 158 current_model="gemini-3-flash", 159 current_base_url="https://opencode.ai/zen/v1", 160 ) 161 162 assert result.success 163 assert result.api_mode == "anthropic_messages" 164 assert result.base_url == "https://opencode.ai/zen" 165 166 def test_switch_to_gemini_leaves_v1_intact(self): 167 """Gemini on opencode-zen stays on chat_completions with /v1.""" 168 result = _run_opencode_switch( 169 raw_input="gemini-3-flash", 170 current_provider="opencode-zen", 171 current_model="claude-sonnet-4-6", 172 current_base_url="https://opencode.ai/zen", # stripped from previous Claude 173 runtime_base_url="https://opencode.ai/zen/v1", 174 ) 175 176 assert result.success 177 assert result.api_mode == "chat_completions" 178 assert result.base_url == "https://opencode.ai/zen/v1" 179 180 def test_switch_to_gpt_uses_codex_responses_keeps_v1(self): 181 """GPT on opencode-zen uses codex_responses api_mode — /v1 kept.""" 182 result = _run_opencode_switch( 183 raw_input="gpt-5.4", 184 current_provider="opencode-zen", 185 current_model="claude-sonnet-4-6", 186 current_base_url="https://opencode.ai/zen", 187 runtime_base_url="https://opencode.ai/zen/v1", 188 ) 189 190 assert result.success 191 assert result.api_mode == "codex_responses" 192 assert result.base_url == "https://opencode.ai/zen/v1" 193 194 195 class TestAgentSwitchModelDefenseInDepth: 196 """run_agent.AIAgent.switch_model() also strips /v1 as defense-in-depth.""" 197 198 def test_agent_switch_model_strips_v1_for_anthropic_messages(self): 199 """Even if a caller hands in a /v1 URL, the agent strips it.""" 200 from run_agent import AIAgent 201 202 # Build a bare agent instance without running __init__; we only want 203 # to exercise switch_model's base_url normalization logic. 204 agent = AIAgent.__new__(AIAgent) 205 agent.model = "glm-5" 206 agent.provider = "opencode-go" 207 agent.base_url = "https://opencode.ai/zen/go/v1" 208 agent.api_key = "sk-opencode-fake" 209 agent.api_mode = "chat_completions" 210 agent._client_kwargs = {} 211 212 # Intercept the expensive client rebuild — we only need to verify 213 # that base_url was normalized before it reached the Anthropic 214 # client factory. 215 captured = {} 216 217 def _fake_build_anthropic_client(api_key, base_url, **kwargs): 218 captured["api_key"] = api_key 219 captured["base_url"] = base_url 220 return object() # placeholder client — no real calls expected 221 222 # The downstream cache/plumbing touches a bunch of private state 223 # that wasn't initialized above; we don't want to rebuild the full 224 # runtime for this single assertion, so short-circuit after the 225 # strip by raising inside the stubbed factory. 226 class _Sentinel(Exception): 227 pass 228 229 def _raise_after_capture(api_key, base_url, **kwargs): 230 captured["api_key"] = api_key 231 captured["base_url"] = base_url 232 raise _Sentinel("strip verified") 233 234 with patch( 235 "agent.anthropic_adapter.build_anthropic_client", 236 side_effect=_raise_after_capture, 237 ), patch("agent.anthropic_adapter.resolve_anthropic_token", return_value=""), patch( 238 "agent.anthropic_adapter._is_oauth_token", return_value=False 239 ): 240 with pytest.raises(_Sentinel): 241 agent.switch_model( 242 new_model="minimax-m2.7", 243 new_provider="opencode-go", 244 api_key="sk-opencode-fake", 245 base_url="https://opencode.ai/zen/go/v1", 246 api_mode="anthropic_messages", 247 ) 248 249 assert captured.get("base_url") == "https://opencode.ai/zen/go", ( 250 f"agent.switch_model did not strip /v1; passed {captured.get('base_url')} " 251 "to build_anthropic_client" 252 ) 253 254 255 256 class TestStaleConfigDefaultDoesNotWedgeResolver: 257 """Regression for the real bug Quentin hit. 258 259 When ``model.default`` in config.yaml is an OpenCode Anthropic-routed model 260 (e.g. ``claude-sonnet-4-6`` on opencode-zen) and the user does ``/model 261 kimi-k2.6 --provider opencode-zen`` session-only, the resolver must derive 262 api_mode from the model being requested, not the persisted default. The 263 earlier bug computed api_mode from ``model_cfg.get("default")``, flipped it 264 to ``anthropic_messages`` based on the stale Claude default, and stripped 265 ``/v1``. The chat_completions override in switch_model() fixed api_mode but 266 never re-added ``/v1``, so requests landed on ``https://opencode.ai/zen`` 267 and got OpenCode's website 404 HTML page. 268 269 These tests use the REAL ``resolve_runtime_provider`` (not a mock) so a 270 regression in the target_model plumbing surfaces immediately. 271 """ 272 273 def test_kimi_switch_keeps_v1_despite_claude_config_default(self, tmp_path, monkeypatch): 274 import yaml 275 import importlib 276 277 monkeypatch.setenv("HERMES_HOME", str(tmp_path)) 278 monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "test-key") 279 (tmp_path / "config.yaml").write_text(yaml.safe_dump({ 280 "model": {"provider": "opencode-zen", "default": "claude-sonnet-4-6"}, 281 })) 282 283 # Re-import with the new HERMES_HOME so config cache is fresh. 284 import hermes_cli.config as _cfg_mod 285 importlib.reload(_cfg_mod) 286 import hermes_cli.runtime_provider as _rp_mod 287 importlib.reload(_rp_mod) 288 import hermes_cli.model_switch as _ms_mod 289 importlib.reload(_ms_mod) 290 291 result = _ms_mod.switch_model( 292 raw_input="kimi-k2.6", 293 current_provider="opencode-zen", 294 current_model="claude-sonnet-4-6", 295 current_base_url="https://opencode.ai/zen", # stripped from prior claude turn 296 current_api_key="test-key", 297 is_global=False, 298 explicit_provider="opencode-zen", 299 ) 300 301 assert result.success, f"switch failed: {result.error_message}" 302 assert result.base_url == "https://opencode.ai/zen/v1", ( 303 f"base_url wedged at {result.base_url!r} - stale Claude config.default " 304 "caused api_mode to be computed as anthropic_messages, stripping /v1, " 305 "and chat_completions override never re-added it." 306 ) 307 assert result.api_mode == "chat_completions" 308 309 def test_go_glm_switch_keeps_v1_despite_minimax_config_default(self, tmp_path, monkeypatch): 310 import yaml 311 import importlib 312 313 monkeypatch.setenv("HERMES_HOME", str(tmp_path)) 314 monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-key") 315 monkeypatch.delenv("OPENCODE_ZEN_API_KEY", raising=False) 316 (tmp_path / "config.yaml").write_text(yaml.safe_dump({ 317 "model": {"provider": "opencode-go", "default": "minimax-m2.7"}, 318 })) 319 320 import hermes_cli.config as _cfg_mod 321 importlib.reload(_cfg_mod) 322 import hermes_cli.runtime_provider as _rp_mod 323 importlib.reload(_rp_mod) 324 import hermes_cli.model_switch as _ms_mod 325 importlib.reload(_ms_mod) 326 327 result = _ms_mod.switch_model( 328 raw_input="glm-5.1", 329 current_provider="opencode-go", 330 current_model="minimax-m2.7", 331 current_base_url="https://opencode.ai/zen/go", # stripped from prior minimax turn 332 current_api_key="test-key", 333 is_global=False, 334 explicit_provider="opencode-go", 335 ) 336 337 assert result.success, f"switch failed: {result.error_message}" 338 assert result.base_url == "https://opencode.ai/zen/go/v1" 339 assert result.api_mode == "chat_completions" 340 341 def test_claude_switch_still_strips_v1_with_kimi_config_default(self, tmp_path, monkeypatch): 342 """Inverse case: config default is chat_completions, switch TO anthropic_messages. 343 344 Guards that the target_model plumbing does not break the original 345 strip-for-anthropic behavior. 346 """ 347 import yaml 348 import importlib 349 350 monkeypatch.setenv("HERMES_HOME", str(tmp_path)) 351 monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "test-key") 352 (tmp_path / "config.yaml").write_text(yaml.safe_dump({ 353 "model": {"provider": "opencode-zen", "default": "kimi-k2.6"}, 354 })) 355 356 import hermes_cli.config as _cfg_mod 357 importlib.reload(_cfg_mod) 358 import hermes_cli.runtime_provider as _rp_mod 359 importlib.reload(_rp_mod) 360 import hermes_cli.model_switch as _ms_mod 361 importlib.reload(_ms_mod) 362 363 result = _ms_mod.switch_model( 364 raw_input="claude-sonnet-4-6", 365 current_provider="opencode-zen", 366 current_model="kimi-k2.6", 367 current_base_url="https://opencode.ai/zen/v1", 368 current_api_key="test-key", 369 is_global=False, 370 explicit_provider="opencode-zen", 371 ) 372 373 assert result.success, f"switch failed: {result.error_message}" 374 assert result.base_url == "https://opencode.ai/zen" 375 assert result.api_mode == "anthropic_messages"