{"repo":"NousResearch/hermes-agent","public_id":"25106","display_label":"CLI global model switch state persistence fixes","summary":"A bug and several iterative fixes addressing incomplete persistence of model runtime state (base_url, api_mode) when switching models globally via the CLI. Changes span the CLI, gateway, and agent layers.","subsystem":"agent","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":66563},"resolution":{"state":"resolved","by":"pr-66563"},"freshness":{"state":"verified","last_verified_at":"2026-07-23T04:18:51+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":25106,"pr_number":66563,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":25106,"pr_number":69818,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":25107,"pr_number":66563,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":25107,"pr_number":69818,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":25106,"title":"[Bug]: CLI --global model switch does not persist/clear model.base_url and model.api_mode","url":null,"state":"closed"},{"kind":"pr","number":25131,"title":"fix(agent): persist complete model runtime state on /model switch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"398634eed","number":0},{"kind":"pr","value":"#25106","number":25106},{"kind":"pr","value":"#69818","number":69818}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":25137,"title":"fix: persist CLI model runtime state","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":60970,"title":"fix(cli,gateway): sync base_url/api_mode on global model switch persist","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"398634eed","number":0},{"kind":"commit","value":"e7f208fd7","number":0},{"kind":"pr","value":"#66563","number":66563},{"kind":"pr","value":"#25106","number":25106},{"kind":"pr","value":"#25107","number":25107}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":66563,"title":"fix(model-switch): route reasoning models to Responses API on switch + persist api_mode globally","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/slash_commands.py","hermes_cli/model_switch.py","hermes_cli/providers.py","tests/gateway/test_25107_stale_base_url_api_mode.py","tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py","tests/hermes_cli/test_model_switch_openai_api_mode.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -7979,6 +7979,14 @@ def _apply_model_switch_result(self, result, persist_global: bool) -> None:\n             save_config_value(\"model.default\", result.new_model)\n             if result.provider_changed:\n                 save_config_value(\"model.provider\", result.target_provider)\n+            # base_url/api_mode were previously never persisted here, so a\n+            # global switch left the OLD provider's endpoint/wire-protocol in\n+            # config.yaml. result.base_url/api_mode are always freshly\n+            # resolved for the target provider (see model_switch.py), so sync\n+            # them every time; None clears a value the new provider doesn't\n+            # need (#25106).\n+            save_config_value(\"model.base_url\", result.base_url or None)\n+            save_config_value(\"model.api_mode\", result.api_mode or None)\n             _cprint(\"    Saved to config.yaml (--global)\")\n         else:\n             _cprint(\"    (session only — add --global to persist)\")","@@ -8291,6 +8299,10 @@ def _handle_model_switch(self, cmd_original: str):\n             save_config_value(\"model.default\", result.new_model)\n             if result.provider_changed:\n                 save_config_value(\"model.provider\", result.target_provider)\n+            # See _apply_model_switch_result above for why base_url/api_mode\n+            # must be synced on every global switch (#25106).\n+            save_config_value(\"model.base_url\", result.base_url or None)\n+            save_config_value(\"model.api_mode\", result.api_mode or None)\n             _cprint(\"    Saved to config.yaml\")\n         else:\n             _cprint(\"    (session only — add --global to persist)\")"]},{"file":"gateway/slash_commands.py","hunks":["@@ -1723,9 +1723,24 @@ async def _on_model_selected_scoped(\n                                     _persist_cfg[\"model\"] = _persist_model_cfg\n                                 _persist_model_cfg[\"default\"] = result.new_model\n                                 _persist_model_cfg[\"provider\"] = result.target_provider\n+                                # Named providers always resolve base_url/api_mode fresh,\n+                                # so any leftover is cleared unconditionally below. Custom\n+                                # providers have no registry entry to re-derive from, so\n+                                # they need an explicit set-or-clear here — the previous\n+                                # lone `if result.base_url:` left a stale base_url behind\n+                                # when switching to a custom provider whose resolver\n+                                # returned an empty base_url (#25107).\n+                                _is_custom_target = str(result.target_provider or \"\").strip().lower() == \"custom\"\n                                 if result.base_url:\n                                     _persist_model_cfg[\"base_url\"] = result.base_url\n-                                if str(result.target_provider or \"\").strip().lower() != \"custom\":\n+                                elif _is_custom_target:\n+                                    _persist_model_cfg.pop(\"base_url\", None)\n+                                if _is_custom_target:\n+                                    if result.api_mode:\n+                                        _persist_model_cfg[\"api_mode\"] = result.api_mode\n+                                    else:\n+                                        _persist_model_cfg.pop(\"api_mode\", None)\n+                                else:\n                                     clear_model_endpoint_credentials(_persist_model_cfg, clear_base_url=True)\n                                 from hermes_cli.config import save_config\n                                 save_config(_persist_cfg)","@@ -1988,9 +2003,19 @@ async def _finish_switch() -> str:\n                         cfg[\"model\"] = model_cfg\n                     model_cfg[\"default\"] = result.new_model\n                     model_cfg[\"provider\"] = result.target_provider\n+                    # See the picker handler above for why custom providers need an\n+                    # explicit set-or-clear instead of the old lone truthy check (#25107).\n+                    _is_custom_target = str(result.target_provider or \"\").strip().lower() == \"custom\"\n                     if result.base_url:\n                         model_cfg[\"base_url\"] = result.base_url\n-                    if str(result.target_provider or \"\").strip().lower() != \"custom\":\n+                    elif _is_custom_target:\n+                        model_cfg.pop(\"base_url\", None)\n+                    if _is_custom_target:\n+                        if result.api_mode:\n+                            model_cfg[\"api_mode\"] = result.api_mode\n+                        else:\n+                            model_cfg.pop(\"api_mode\", None)\n+                    else:\n                         clear_model_endpoint_credentials(model_cfg, clear_base_url=True)\n                     from hermes_cli.config import save_config\n                     save_config(cfg)"]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -30,6 +30,7 @@\n     custom_provider_slug,\n     determine_api_mode,\n     get_label,\n+    host_mandated_api_mode,\n     is_aggregator,\n     resolve_provider_full,\n )","@@ -1255,6 +1256,21 @@ def switch_model(\n             if not api_key:\n                 [redacted:secret-assignment] = \"no-key-required\"\n \n+    # --- Resolve api_mode from the final (provider, base_url) before validation ---\n+    # Two cases this closes, both surfaced when the switched model's reasoning\n+    # is actually applied (post the reasoning-unification refactor):\n+    #   1. api_mode empty (e.g. alias cleared it above) → fill from the endpoint.\n+    #   2. api_mode carried a STALE value from the previous session state\n+    #      (e.g. a same-provider /model switch to gpt-5.x on api.openai.com that\n+    #      kept the prior openrouter/chat_completions mode). A host that mandates\n+    #      one wire protocol must override the stale value — otherwise the request\n+    #      goes out on chat_completions and OpenAI 400s on tools+reasoning_effort.\n+    _mandated_mode = host_mandated_api_mode(base_url)\n+    if _mandated_mode is not None:\n+        api_mode = _mandated_mode\n+    elif not api_mode:\n+        api_mode = determine_api_mode(target_provider, base_url)\n+\n     # --- Normalize model name for target provider ---\n     new_model = normalize_model_for_provider(new_model, target_provider)\n "]},{"file":"hermes_cli/providers.py","hunks":["@@ -549,45 +549,61 @@ def is_routing_aggregator(provider: str) -> bool:\n     return is_aggregator(provider_norm)\n \n \n+def host_mandated_api_mode(base_url: str = \"\") -> Optional[str]:\n+    \"\"\"Return the wire protocol a specific endpoint *requires*, or None.\n+\n+    Some hosts only accept one API mode and reject the others outright:\n+      - api.openai.com only accepts the Responses API for its (reasoning)\n+        models when tools + reasoning are in play (chat/completions 400s).\n+      - api.anthropic.com / ``…/anthropic`` suffixes speak native Messages.\n+      - Kimi's ``/coding`` endpoint speaks native Messages.\n+      - AWS Bedrock runtime hosts speak Converse.\n+\n+    These are *mandatory* — a session carrying a stale api_mode (e.g. a\n+    /model switch that kept the previous provider's ``chat_completions``)\n+    must be overridden to the host's required mode, not merely filled in\n+    when empty. Generic / unknown endpoints return None so an explicitly\n+    configured api_mode on them is never clobbered.\n+    \"\"\"\n+    if not base_url:\n+        return None\n+    url_lower = base_url.rstrip(\"/\").lower()\n+    hostname = base_url_hostname(base_url)\n+    # Exact-hostname matching only — never bare substring — so lookalike hosts\n+    # (api.openai.com.attacker.test) and path-segment spoofs\n+    # (proxy.test/api.openai.com/v1) are NOT treated as the real endpoint. (#32243)\n+    if hostname == \"api.kimi.com\" and \"/coding\" in url_lower:\n+        return \"anthropic_messages\"\n+    if hostname == \"api.anthropic.com\" or url_lower.endswith(\"/anthropic\"):\n+        return \"anthropic_messages\"\n+    if hostname == \"api.openai.com\":\n+        return \"codex_responses\"\n+    if hostname.startswith(\"bedrock-runtime.\") and base_url_host_matches(base_url, \"amazonaws.com\"):\n+        return \"bedrock_converse\"\n+    return None\n+\n+\n def determine_api_mode(provider: str, base_url: str = \"\") -> str:\n     \"\"\"Determine the API mode (wire protocol) for a provider/endpoint.\n \n     Resolution order:\n-      1. Known provider → transport → TRANSPORT_TO_API_MODE.\n-      2. URL heuristics for unknown / custom providers.\n-      3. Default: 'chat_completions'.\n+      1. Host-mandated mode (special endpoints that only accept one protocol).\n+      2. Known provider → transport → TRANSPORT_TO_API_MODE.\n+      3. Direct provider checks (bedrock).\n+      4. Default: 'chat_completions'.\n     \"\"\"\n+    mandated = host_mandated_api_mode(base_url)\n+    if mandated is not None:\n+        return mandated\n+\n     pdef = get_provider(provider)\n     if pdef is not None:\n-        # Even for known providers, check URL heuristics for special endpoints\n-        # (e.g. kimi /coding endpoint needs anthropic_messages even on 'custom')\n-        if base_url:\n-            url_lower = base_url.rstrip(\"/\").lower()\n-            if \"api.kimi.com/coding\" in url_lower:\n-                return \"anthropic_messages\"\n-            if url_lower.endswith(\"/anthropic\") or \"api.anthropic.com\" in url_lower:\n-                return \"anthropic_messages\"\n-            if \"api.openai.com\" in url_lower:\n-                return \"codex_responses\"\n         return TRANSPORT_TO_API_MODE.get(pdef.transport, \"chat_completions\")\n \n     # Direct provider checks for providers not in HERMES_OVERLAYS\n     if provider == \"bedrock\":\n         return \"bedrock_converse\"\n \n-    # URL-based heuristics for custom / unknown providers\n-    if base_url:\n-        url_lower = base_url.rstrip(\"/\").lower()\n-        hostname = base_url_hostname(base_url)\n-        if url_lower.endswith(\"/anthropic\") or hostname == \"api.anthropic.com\":\n-            return \"anthropic_messages\"\n-        if hostname == \"api.kimi.com\" and \"/coding\" in url_lower:\n-            return \"anthropic_messages\"\n-        if hostname == \"api.openai.com\":\n-            return \"codex_responses\"\n-        if hostname.startswith(\"bedrock-runtime.\") and base_url_host_matches(base_url, \"amazonaws.com\"):\n-            return \"bedrock_converse\"\n-\n     return \"chat_completions\"\n \n "]},{"file":"tests/gateway/test_25107_stale_base_url_api_mode.py","hunks":["@@ -0,0 +1,194 @@\n+\"\"\"Regression tests for #25107: gateway /model switch left a stale\n+``base_url``/never persisted ``api_mode`` in config.yaml when switching to a\n+custom provider whose resolver returned an empty ``base_url``.\n+\n+Root cause: both the picker-tap path (``_on_model_selected`` in\n+``gateway/slash_commands.py``) and the typed ``/model X --global`` path\n+(``_finish_switch``) guarded the persist block with two *independent*\n+``if``s:\n+\n+    if result.base_url:\n+        model_cfg[\"base_url\"] = result.base_url\n+    if target_provider != \"custom\":\n+        clear_model_endpoint_credentials(model_cfg, clear_base_url=True)\n+\n+For a NAMED provider the second ``if`` always clears any stale value, so the\n+bug was invisible there. But for a ``custom`` provider with an empty\n+resolved ``base_url``, NEITHER branch fires: the old base_url survives\n+untouched, and ``api_mode`` was never written to ``model_cfg`` at all in\n+either branch (only present in the in-memory session override).\n+\n+Fix: an explicit set-or-clear for both fields when the target provider is\n+custom, so a genuine switch always leaves config.yaml coherent with the\n+resolved result.\n+\"\"\"\n+\n+import types\n+\n+import yaml\n+import pytest\n+\n+from gateway.config import Platform\n+from gateway.platforms.base import MessageEvent, MessageType\n+from gateway.run import GatewayRunner\n+from gateway.session import SessionSource\n+\n+\n+class _FakePickerAdapter:\n+    def __init__(self):\n+        self.captured_callback = None\n+\n+    async def send_model_picker(self, *, on_model_selected, **kwargs):\n+        self.captured_callback = on_model_selected\n+        return types.SimpleNamespace(success=True)\n+\n+\n+def _make_runner(adapter=None):\n+    runner = object.__new__(GatewayRunner)\n+    runner.adapters = {Platform.TELEGRAM: adapter} if adapter else {}\n+    runner._voice_mode = {}\n+    runner._session_model_overrides = {}\n+    runner._running_agents = {}\n+    return runner\n+\n+\n+def _make_event(text):\n+    return MessageEvent(\n+        text=text,\n+        message_type=MessageType.TEXT,\n+        source=SessionSource(platform=Platform.TELEGRAM, chat_id=\"12345\", chat_type=\"dm\"),\n+    )\n+\n+\n+def _fake_switch_result(*, base_url=\"\", api_mode=\"\"):\n+    from hermes_cli.model_switch import ModelSwitchResult\n+\n+    return ModelSwitchResult(\n+        success=True,\n+        new_model=\"local-llama\",\n+        target_provider=\"custom\",\n+        provider_changed=True,\n+        api_key=\"[redacted:secret-assignment]\",\n+        base_url=base_url,\n+        api_mode=api_mode,\n+        provider_label=\"Custom\",\n+        is_global=True,\n+    )\n+\n+\n+def _setup_isolated_home(tmp_path, monkeypatch, model_yaml_value, *, base_url=\"\", api_mode=\"\"):\n+    import gateway.run as gateway_run\n+\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    cfg_path = hermes_home / \"config.yaml\"\n+    cfg_path.write_text(\n+        yaml.safe_dump({\"model\": model_yaml_value, \"providers\": {}}),\n+        encoding=\"utf-8\",\n+    )\n+\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", hermes_home)\n+    monkeypatch.setattr(\"agent.models_dev.fetch_models_dev\", lambda: {})\n+    monkeypatch.setattr(\n+        \"hermes_cli.model_switch.list_picker_providers\",\n+        lambda **kw: [{\"slug\": \"custom\", \"name\": \"Custom\", \"models\": [\"local-llama\"]}],\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.model_switch.switch_model\",\n+        lambda **kw: _fake_switch_result(base_url=base_url, api_mode=api_mode),\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.model_switch.resolve_display_context_length\",\n+        lambda *a, **k: 8192,\n+    )\n+    monkeypatch.setattr(\"hermes_constants.get_hermes_home\", lambda: hermes_home)\n+    monkeypatch.setattr(\"hermes_cli.config.get_hermes_home\", lambda: hermes_home)\n+    return cfg_path\n+\n+\n+_STALE_MODEL_CFG = {\n+    \"default\": \"old-custom-model\",\n+    \"provider\": \"custom\",\n+    \"base_url\": \"https://old-stale-endpoint.example/v1\",\n+    \"api_key\": \"sk-stale\",\n+    \"api_mode\": \"anthropic_messages\",\n+}\n+\n+\n+# ---------------------------------------------------------------------------\n+# Typed `/model X --global` path (_finish_switch)\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_typed_switch_to_custom_clears_stale_base_url_and_api_mode(tmp_path, monkeypatch):\n+    \"\"\"Switching to a custom provider whose resolver returns no base_url/\n+    api_mode must clear the previous custom endpoint's leftovers, not keep\n+    routing at the old host/protocol (#25107).\"\"\"\n+    cfg_path = _setup_isolated_home(tmp_path, monkeypatch, dict(_STALE_MODEL_CFG))\n+\n+    result = await _make_runner()._handle_model_command(\n+        _make_event(\"/model local-llama --provider custom --global\")\n+    )\n+\n+    assert result is not None\n+    written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n+    assert written[\"model\"][\"default\"] == \"local-llama\"\n+    assert \"base_url\" not in written[\"model\"], (\n+        \"stale base_url from the old custom endpoint must be cleared\"\n+    )\n+    assert \"api_mode\" not in written[\"model\"], (\n+        \"stale api_mode from the old custom endpoint must be cleared\"\n+    )\n+\n+\n[redacted:email]\n+async def test_typed_switch_to_custom_persists_resolved_base_url_and_api_mode(tmp_path, monkeypatch):\n+    \"\"\"The normal case: a custom-provider switch that DOES resolve a fresh\n+    base_url/api_mode must persist both (api_mode was never written here\n+    before the fix).\"\"\"\n+    cfg_path = _setup_isolated_home(\n+        tmp_path,\n+        monkeypatch,\n+        dict(_STALE_MODEL_CFG),\n+        base_url=\"https://new-endpoint.example/v1\",\n+        api_mode=\"anthropic_messages\",\n+    )\n+\n+    result = await _make_runner()._handle_model_command(\n+        _make_event(\"/model local-llama --provider custom --global\")\n+    )\n+\n+    assert result is not None\n+    written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n+    assert written[\"model\"][\"base_url\"] == \"https://new-endpoint.example/v1\"\n+    assert written[\"model\"][\"api_mode\"] == \"anthropic_messages\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Picker-tap path (_on_model_selected)\n+# ---------------------------------------------------------------------------\n+\n+\n+async def _drive_picker(runner, event):\n+    sent = await runner._handle_model_command(event)\n+    assert sent is None\n+    adapter = runner.adapters[Platform.TELEGRAM]\n+    assert adapter.captured_callback is not None, \"picker callback was not wired\"\n+    return await adapter.captured_callback(\"12345\", \"local-llama\", \"custom\")\n+\n+\n[redacted:email]\n+async def test_picker_tap_to_custom_clears_stale_base_url_and_api_mode(tmp_path, monkeypatch):\n+    \"\"\"Same bug, picker-tap path: tapping a custom-provider model with an\n+    empty resolved base_url must clear the previous custom endpoint (#25107).\n+    \"\"\"\n+    adapter = _FakePickerAdapter()\n+    cfg_path = _setup_isolated_home(tmp_path, monkeypatch, dict(_STALE_MODEL_CFG))\n+\n+    confirmation = await _drive_picker(_make_runner(adapter), _make_event(\"/model\"))\n+\n+    assert confirmation is not None\n+    written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n+    assert \"base_url\" not in written[\"model\"]\n+    assert \"api_mode\" not in written[\"model\"]"]},{"file":"tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py","hunks":["@@ -0,0 +1,160 @@\n+\"\"\"Regression tests for #25106: CLI `/model <name> --global` never persisted\n+``model.base_url``/``model.api_mode`` to config.yaml, so a global provider\n+switch left the PREVIOUS provider's endpoint/wire-protocol on disk. The next\n+`hermes` launch re-read the stale base_url and routed the new model at the\n+old host.\n+\n+Both ``_handle_model_switch`` (typed ``/model <name>``) and\n+``_apply_model_switch_result`` (interactive picker) shared the same gap: the\n+persistence block wrote ``model.default``/``model.provider`` but never\n+touched ``base_url``/``api_mode`` at all. Fix: sync both on every global\n+switch, clearing to ``None`` when the resolved result doesn't need them —\n+mirroring the already-correct ``tui_gateway/server.py:_persist_model_switch``\n+pattern (fixed for #48305).\n+\"\"\"\n+\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+from hermes_cli.model_switch import ModelSwitchResult\n+\n+\n+def _make_result(*, base_url=\"https://api.minimax.io/v1\", api_mode=\"chat_completions\", provider_changed=True):\n+    return ModelSwitchResult(\n+        success=True,\n+        new_model=\"MiniMax-M3\",\n+        target_provider=\"custom:minimax\",\n+        provider_changed=provider_changed,\n+        api_key=\"[redacted:secret-assignment]\",\n+        base_url=base_url,\n+        api_mode=api_mode,\n+        warning_message=\"\",\n+        provider_label=\"MiniMax (custom)\",\n+        resolved_via_alias=False,\n+        capabilities=None,\n+        model_info=None,\n+        is_global=True,\n+    )\n+\n+\n+class _StubCLI:\n+    \"\"\"Minimum attrs/methods `_handle_model_switch` reads or calls on self.\"\"\"\n+\n+    agent = None\n+    model = \"old-model\"\n+    provider = \"copilot\"\n+    requested_provider = \"copilot\"\n+    api_key = \"[redacted:secret-assignment]\"\n+    base_url = \"https://api.githubcopilot.com\"\n+    api_mode = \"chat_completions\"\n+    _explicit_api_key = \"\"\n+    _explicit_base_url = \"\"\n+    conversation_history = []\n+    _pending_model_switch_note = \"\"\n+\n+    def _confirm_expensive_model_switch(self, result) -> bool:\n+        return True\n+\n+    def _open_model_picker(self, *a, **k):\n+        raise AssertionError(\"picker should not open when a model name is given\")\n+\n+\n+def _run_switch(monkeypatch, result, cmd=\"/model MiniMax-M3 --global\"):\n+    import cli as cli_mod\n+\n+    monkeypatch.setattr(cli_mod, \"_cprint\", lambda *a, **k: None)\n+    saved: dict[str, object] = {}\n+\n+    def _fake_save(key, value):\n+        saved[key] = value\n+\n+    monkeypatch.setattr(cli_mod, \"save_config_value\", _fake_save)\n+    monkeypatch.setattr(\"hermes_cli.model_switch.switch_model\", lambda **kw: result)\n+    monkeypatch.setattr(\n+        \"hermes_cli.inventory.load_picker_context\",\n+        lambda: (_ for _ in ()).throw(RuntimeError(\"no picker context in test\")),\n+    )\n+    cli_mod.HermesCLI._handle_model_switch(_StubCLI(), cmd)\n+    return saved\n+\n+\n+def test_global_switch_persists_base_url_and_api_mode(monkeypatch):\n+    \"\"\"The core #25106 fix: a --global switch to a new provider/endpoint must\n+    write the newly resolved base_url and api_mode, not just default/provider.\"\"\"\n+    saved = _run_switch(monkeypatch, _make_result())\n+\n+    assert saved[\"model.default\"] == \"MiniMax-M3\"\n+    assert saved[\"model.provider\"] == \"custom:minimax\"\n+    assert saved[\"model.base_url\"] == \"https://api.minimax.io/v1\"\n+    assert saved[\"model.api_mode\"] == \"chat_completions\"\n+\n+\n+def test_global_switch_clears_base_url_and_api_mode_when_unresolved(monkeypatch):\n+    \"\"\"When the resolver returns no base_url/api_mode for the new provider\n+    (e.g. a named provider needing neither), any previous value must be\n+    cleared (None) rather than silently left in config.yaml.\"\"\"\n+    result = _make_result(base_url=\"\", api_mode=\"\")\n+    saved = _run_switch(monkeypatch, result)\n+\n+    assert saved[\"model.base_url\"] is None\n+    assert saved[\"model.api_mode\"] is None\n+\n+\n+def test_session_only_switch_does_not_touch_config(monkeypatch):\n+    \"\"\"--session must not call save_config_value at all — persistence stays\n+    entirely in-memory.\"\"\"\n+    import cli as cli_mod\n+\n+    monkeypatch.setattr(cli_mod, \"_cprint\", lambda *a, **k: None)\n+    save_calls = []\n+    monkeypatch.setattr(cli_mod, \"save_config_value\", lambda *a, **k: save_calls.append(a))\n+    monkeypatch.setattr(\"hermes_cli.model_switch.switch_model\", lambda **kw: _make_result())\n+    monkeypatch.setattr(\n+        \"hermes_cli.inventory.load_picker_context\",\n+        lambda: (_ for _ in ()).throw(RuntimeError(\"no picker context in test\")),\n+    )\n+\n+    cli_mod.HermesCLI._handle_model_switch(_StubCLI(), \"/model MiniMax-M3 --session\")\n+\n+    assert save_calls == []\n+\n+\n+def _run_apply(monkeypatch, result, persist_global=True):\n+    \"\"\"Drives `_apply_model_switch_result` directly — the interactive-picker\n+    sibling of `_handle_model_switch`. Unlike the tests above, and unlike\n+    `test_apply_model_switch_result_context.py` (which only ever calls this\n+    method with `persist_global=False`), this exercises the `persist_global=True`\n+    branch that actually writes to config.yaml.\"\"\"\n+    import cli as cli_mod\n+\n+    monkeypatch.setattr(cli_mod, \"_cprint\", lambda *a, **k: None)\n+    saved: dict[str, object] = {}\n+\n+    def _fake_save(key, value):\n+        saved[key] = value\n+\n+    monkeypatch.setattr(cli_mod, \"save_config_value\", _fake_save)\n+    cli_mod.HermesCLI._apply_model_switch_result(_StubCLI(), result, persist_global)\n+    return saved\n+\n+\n+def test_picker_global_switch_persists_base_url_and_api_mode(monkeypatch):\n+    \"\"\"Picker-path counterpart of `test_global_switch_persists_base_url_and_api_mode`:\n+    `_apply_model_switch_result(..., persist_global=True)` must sync base_url/api_mode\n+    too, not just default/provider.\"\"\"\n+    saved = _run_apply(monkeypatch, _make_result())\n+\n+    assert saved[\"model.default\"] == \"MiniMax-M3\"\n+    assert saved[\"model.provider\"] == \"custom:minimax\"\n+    assert saved[\"model.base_url\"] == \"https://api.minimax.io/v1\"\n+    assert saved[\"model.api_mode\"] == \"chat_completions\"\n+\n+\n+def test_picker_global_switch_clears_base_url_and_api_mode_when_unresolved(monkeypatch):\n+    \"\"\"Picker-path counterpart of `test_global_switch_clears_base_url_and_api_mode_when_unresolved`.\"\"\"\n+    result = _make_result(base_url=\"\", api_mode=\"\")\n+    saved = _run_apply(monkeypatch, result)\n+\n+    assert saved[\"model.base_url\"] is None\n+    assert saved[\"model.api_mode\"] is None"]},{"file":"tests/hermes_cli/test_model_switch_openai_api_mode.py","hunks":["@@ -0,0 +1,113 @@\n+\"\"\"Regression tests for OpenAI-direct api_mode recomputation during /model switch.\n+\n+api.openai.com only accepts the Responses API (codex_responses) for its\n+reasoning models when tools + reasoning are in play — /v1/chat/completions\n+returns HTTP 400 (\"Function tools with reasoning_effort are not supported\").\n+\n+When a session switches to a GPT-5.x model on api.openai.com while carrying a\n+stale ``chat_completions`` api_mode from the previous provider (e.g. an\n+openrouter default), the switch must override the stale value with the\n+host-mandated ``codex_responses``. Filling only when empty (the earlier fix)\n+was insufficient: the carried value was a *non-empty but wrong* mode.\n+\n+This surfaced after the reasoning-unification refactor made the switched\n+model's reasoning effort actually apply, turning a silently-dropped\n+reasoning_effort into a live one that OpenAI 400s on the chat_completions path.\n+\"\"\"\n+\n+from unittest.mock import patch\n+\n+from hermes_cli.model_switch import switch_model\n+\n+_MOCK_VALIDATION = {\n+    \"accepted\": True,\n+    \"persist\": True,\n+    \"recognized\": True,\n+    \"message\": None,\n+}\n+\n+\n+def _run_openai_switch(\n+    raw_input: str,\n+    current_provider: str = \"openrouter\",\n+    current_model: str = \"anthropic/claude-opus-4.8\",\n+    explicit_provider: str = \"openai-api\",\n+    runtime_api_mode: str = \"chat_completions\",\n+    runtime_base_url: str = \"https://api.openai.com/v1\",\n+):\n+    \"\"\"Run switch_model with OpenAI-direct mocks and return the result.\"\"\"\n+    with (\n+        patch(\"hermes_cli.model_switch.resolve_alias\", return_value=None),\n+        patch(\"hermes_cli.model_switch.list_provider_models\", return_value=[]),\n+        patch(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            return_value={\n+                \"api_key\": \"sk-test\",\n+                \"base_url\": runtime_base_url,\n+                \"api_mode\": runtime_api_mode,\n+            },\n+        ),\n+        patch(\n+            \"hermes_cli.models.validate_requested_model\",\n+            return_value=_MOCK_VALIDATION,\n+        ),\n+        patch(\"hermes_cli.model_switch.get_model_info\", return_value=None),\n+        patch(\"hermes_cli.model_switch.get_model_capabilities\", return_value=None),\n+        patch(\"hermes_cli.models.detect_provider_for_model\", return_value=None),\n+    ):\n+        return switch_model(\n+            raw_input=raw_input,\n+            current_provider=current_provider,\n+            current_model=current_model,\n+            explicit_provider=explicit_provider,\n+        )\n+\n+\n+def test_stale_chat_completions_overridden_on_openai_direct():\n+    \"\"\"The incident: stale chat_completions on api.openai.com → codex_responses.\n+\n+    Switching from an openrouter/chat_completions session to gpt-5.6-sol on\n+    api.openai.com must flip the wire protocol to the Responses API so tools +\n+    reasoning are preserved, instead of 400ing on chat/completions.\n+    \"\"\"\n+    result = _run_openai_switch(\n+        raw_input=\"gpt-5.6-sol\",\n+        current_provider=\"openrouter\",\n+        current_model=\"anthropic/claude-opus-4.8\",\n+        explicit_provider=\"openai-api\",\n+        runtime_api_mode=\"chat_completions\",  # stale value carried over\n+    )\n+\n+    assert result.success, f\"switch_model failed: {result.error_message}\"\n+    assert result.target_provider == \"openai-api\"\n+    assert result.new_model == \"gpt-5.6-sol\"\n+    assert result.api_mode == \"codex_responses\"\n+\n+\n+def test_empty_api_mode_filled_on_openai_direct():\n+    \"\"\"An empty runtime api_mode on api.openai.com resolves to codex_responses.\"\"\"\n+    result = _run_openai_switch(\n+        raw_input=\"gpt-5.6-sol\",\n+        runtime_api_mode=\"\",  # empty — the earlier fill-if-empty subcase\n+    )\n+\n+    assert result.success, f\"switch_model failed: {result.error_message}\"\n+    assert result.api_mode == \"codex_responses\"\n+\n+\n+def test_generic_endpoint_keeps_explicit_api_mode():\n+    \"\"\"A generic (non-host-mandated) endpoint must NOT have its api_mode clobbered.\n+\n+    Only hosts that mandate one wire protocol override a carried value; a\n+    generic OpenAI-compatible relay returns None from host_mandated_api_mode,\n+    so the switch path leaves the resolver's api_mode untouched.\n+    \"\"\"\n+    from hermes_cli.providers import host_mandated_api_mode\n+\n+    assert host_mandated_api_mode(\"https://generic.example.com/v1\") is None\n+    # Lookalike / path-spoof hosts must also NOT be treated as mandated (#32243).\n+    assert host_mandated_api_mode(\"https://api.openai.com.attacker.test/v1\") is None\n+    assert host_mandated_api_mode(\"https://proxy.test/api.openai.com/v1\") is None\n+    # The real endpoints are mandated.\n+    assert host_mandated_api_mode(\"https://api.openai.com/v1\") == \"codex_responses\"\n+    assert host_mandated_api_mode(\"https://api.anthropic.com\") == \"anthropic_messages\"\n"]}]},{"kind":"pr","number":69818,"title":"fix(cli): persist provider on global model switch","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}