/ tests / hermes_cli / test_model_switch_opencode_anthropic.py
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"