# NousResearch/hermes-agent · CLI global model switch state persistence fixes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 6 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #66563. Verify it addresses your case.
- **Recommended:** read PR #66563 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-66563`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.33`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #25106: \[Bug\]: CLI --global model switch does not persist/clear model.base_url and model.api_mode

## Prior & competing PRs

- **#25137: fix: persist CLI model runtime state**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#66563: fix(model-switch): route reasoning models to Responses API on switch + persist api_mode globally**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `cli.py`

    ```diff
    @@ -7979,6 +7979,14 @@ def _apply_model_switch_result(self, result, persist_global: bool) -> None:
                 save_config_value("model.default", result.new_model)
                 if result.provider_changed:
                     save_config_value("model.provider", result.target_provider)
    +            # base_url/api_mode were previously never persisted here, so a
    +            # global switch left the OLD provider's endpoint/wire-protocol in
    +            # config.yaml. result.base_url/api_mode are always freshly
    +            # resolved for the target provider (see model_switch.py), so sync
    +            # them every time; None clears a value the new provider doesn't
    +            # need (#25106).
    +            save_config_value("model.base_url", result.base_url or None)
    +            save_config_value("model.api_mode", result.api_mode or None)
                 _cprint("    Saved to config.yaml (--global)")
             else:
                 _cprint("    (session only — add --global to persist)")
    ```

    ```diff
    @@ -8291,6 +8299,10 @@ def _handle_model_switch(self, cmd_original: str):
                 save_config_value("model.default", result.new_model)
                 if result.provider_changed:
                     save_config_value("model.provider", result.target_provider)
    +            # See _apply_model_switch_result above for why base_url/api_mode
    +            # must be synced on every global switch (#25106).
    +            save_config_value("model.base_url", result.base_url or None)
    +            save_config_value("model.api_mode", result.api_mode or None)
                 _cprint("    Saved to config.yaml")
             else:
                 _cprint("    (session only — add --global to persist)")
    ```
  - `gateway/slash_commands.py`

    ```diff
    @@ -1723,9 +1723,24 @@ async def _on_model_selected_scoped(
                                         _persist_cfg["model"] = _persist_model_cfg
                                     _persist_model_cfg["default"] = result.new_model
                                     _persist_model_cfg["provider"] = result.target_provider
    +                                # Named providers always resolve base_url/api_mode fresh,
    +                                # so any leftover is cleared unconditionally below. Custom
    +                                # providers have no registry entry to re-derive from, so
    +                                # they need an explicit set-or-clear here — the previous
    +                                # lone `if result.base_url:` left a stale base_url behind
    +                                # when switching to a custom provider whose resolver
    +                                # returned an empty base_url (#25107).
    +                                _is_custom_target = str(result.target_provider or "").strip().lower() == "custom"
                                     if result.base_url:
                                         _persist_model_cfg["base_url"] = result.base_url
    -                                if str(result.target_provider or "").strip().lower() != "custom":
    +                                elif _is_custom_target:
    +                                    _persist_model_cfg.pop("base_url", None)
    +                                if _is_custom_target:
    +                                    if result.api_mode:
    +                                        _persist_model_cfg["api_mode"] = result.api_mode
    +                                    else:
    +                                        _persist_model_cfg.pop("api_mode", None)
    +                                else:
                                         clear_model_endpoint_credentials(_persist_model_cfg, clear_base_url=True)
                                     from hermes_cli.config import save_config
                                     save_config(_persist_cfg)
    ```

    ```diff
    @@ -1988,9 +2003,19 @@ async def _finish_switch() -> str:
                             cfg["model"] = model_cfg
                         model_cfg["default"] = result.new_model
                         model_cfg["provider"] = result.target_provider
    +                    # See the picker handler above for why custom providers need an
    +                    # explicit set-or-clear instead of the old lone truthy check (#25107).
    +                    _is_custom_target = str(result.target_provider or "").strip().lower() == "custom"
                         if result.base_url:
                             model_cfg["base_url"] = result.base_url
    -                    if str(result.target_provider or "").strip().lower() != "custom":
    +                    elif _is_custom_target:
    +                        model_cfg.pop("base_url", None)
    +                    if _is_custom_target:
    +                        if result.api_mode:
    +                            model_cfg["api_mode"] = result.api_mode
    +                        else:
    +                            model_cfg.pop("api_mode", None)
    +                    else:
                             clear_model_endpoint_credentials(model_cfg, clear_base_url=True)
                         from hermes_cli.config import save_config
                         save_config(cfg)
    ```
  - `hermes_cli/model_switch.py`

    ```diff
    @@ -30,6 +30,7 @@
         custom_provider_slug,
         determine_api_mode,
         get_label,
    +    host_mandated_api_mode,
         is_aggregator,
         resolve_provider_full,
     )
    ```

    ```diff
    @@ -1255,6 +1256,21 @@ def switch_model(
                 if not api_key:
                     [redacted:secret-assignment] = "no-key-required"
     
    +    # --- Resolve api_mode from the final (provider, base_url) before validation ---
    +    # Two cases this closes, both surfaced when the switched model's reasoning
    +    # is actually applied (post the reasoning-unification refactor):
    +    #   1. api_mode empty (e.g. alias cleared it above) → fill from the endpoint.
    +    #   2. api_mode carried a STALE value from the previous session state
    +    #      (e.g. a same-provider /model switch to gpt-5.x on api.openai.com that
    +    #      kept the prior openrouter/chat_completions mode). A host that mandates
    +    #      one wire protocol must override the stale value — otherwise the request
    +    #      goes out on chat_completions and OpenAI 400s on tools+reasoning_effort.
    +    _mandated_mode = host_mandated_api_mode(base_url)
    +    if _mandated_mode is not None:
    +        api_mode = _mandated_mode
    +    elif not api_mode:
    +        api_mode = determine_api_mode(target_provider, base_url)
    +
         # --- Normalize model name for target provider ---
         new_model = normalize_model_for_provider(new_model, target_provider)
     
    ```
  - `hermes_cli/providers.py`

    ```diff
    @@ -549,45 +549,61 @@ def is_routing_aggregator(provider: str) -> bool:
         return is_aggregator(provider_norm)
     
     
    +def host_mandated_api_mode(base_url: str = "") -> Optional[str]:
    +    """Return the wire protocol a specific endpoint *requires*, or None.
    +
    +    Some hosts only accept one API mode and reject the others outright:
    +      - api.openai.com only accepts the Responses API for its (reasoning)
    +        models when tools + reasoning are in play (chat/completions 400s).
    +      - api.anthropic.com / ``…/anthropic`` suffixes speak native Messages.
    +      - Kimi's ``/coding`` endpoint speaks native Messages.
    +      - AWS Bedrock runtime hosts speak Converse.
    +
    +    These are *mandatory* — a session carrying a stale api_mode (e.g. a
    +    /model switch that kept the previous provider's ``chat_completions``)
    +    must be overridden to the host's required mode, not merely filled in
    +    when empty. Generic / unknown endpoints return None so an explicitly
    +    configured api_mode on them is never clobbered.
    +    """
    +    if not base_url:
    +        return None
    +    url_lower = base_url.rstrip("/").lower()
    +    hostname = base_url_hostname(base_url)
    +    # Exact-hostname matching only — never bare substring — so lookalike hosts
    +    # (api.openai.com.attacker.test) and path-segment spoofs
    +    # (proxy.test/api.openai.com/v1) are NOT treated as the real endpoint. (#32243)
    +    if hostname == "api.kimi.com" and "/coding" in url_lower:
    +        return "anthropic_messages"
    +    if hostname == "api.anthropic.com" or url_lower.endswith("/anthropic"):
    +        return "anthropic_messages"
    +    if hostname == "api.openai.com":
    +        return "codex_responses"
    +    if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"):
    +        return "bedrock_converse"
    +    return None
    +
    +
     def determine_api_mode(provider: str, base_url: str = "") -> str:
         """Determine the API mode (wire protocol) for a provider/endpoint.
     
         Resolution order:
    -      1. Known provider → transport → TRANSPORT_TO_API_MODE.
    -      2. URL heuristics for unknown / custom providers.
    -      3. Default: 'chat_completions'.
    +      1. Host-mandated mode (special endpoints that only accept one protocol).
    +      2. Known provider → transport → TRANSPORT_TO_API_MODE.
    +      3. Direct provider checks (bedrock).
    +      4. Default: 'chat_completions'.
         """
    +    mandated = host_mandated_api_mode(base_url)
    +    if mandated is not None:
    +        return mandated
    +
         pdef = get_provider(provider)
         if pdef is not None:
    -        # Even for known providers, check URL heuristics for special endpoints
    -        # (e.g. kimi /coding endpoint needs anthropic_messages even on 'custom')
    -        if base_url:
    -            url_lower = base_url.rstrip("/").lower()
    -            if "api.kimi.com/coding" in url_lower:
    -                return "anthropic_messages"
    -            if url_lower.endswith("/anthropic") or "api.anthropic.com" in url_lower:
    -                return "anthropic_messages"
    -            if "api.openai.com" in url_lower:
    -                return "codex_responses"
             return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions")
     
         # Direct provider checks for providers not in HERMES_OVERLAYS
         if provider == "bedrock":
             return "bedrock_converse"
     
    -    # URL-based heuristics for custom / unknown providers
    -    if base_url:
    -        url_lower = base_url.rstrip("/").lower()
    -        hostname = base_url_hostname(base_url)
    -        if url_lower.endswith("/anthropic") or hostname == "api.anthropic.com":
    -            return "anthropic_messages"
    -        if hostname == "api.kimi.com" and "/coding" in url_lower:
    -            return "anthropic_messages"
    -        if hostname == "api.openai.com":
    -            return "codex_responses"
    -        if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"):
    -            return "bedrock_converse"
    -
         return "chat_completions"
     
     
    ```
  - `tests/gateway/test_25107_stale_base_url_api_mode.py`

    ```diff
    @@ -0,0 +1,194 @@
    +"""Regression tests for #25107: gateway /model switch left a stale
    +``base_url``/never persisted ``api_mode`` in config.yaml when switching to a
    +custom provider whose resolver returned an empty ``base_url``.
    +
    +Root cause: both the picker-tap path (``_on_model_selected`` in
    +``gateway/slash_commands.py``) and the typed ``/model X --global`` path
    +(``_finish_switch``) guarded the persist block with two *independent*
    +``if``s:
    +
    +    if result.base_url:
    +        model_cfg["base_url"] = result.base_url
    +    if target_provider != "custom":
    +        clear_model_endpoint_credentials(model_cfg, clear_base_url=True)
    +
    +For a NAMED provider the second ``if`` always clears any stale value, so the
    +bug was invisible there. But for a ``custom`` provider with an empty
    +resolved ``base_url``, NEITHER branch fires: the old base_url survives
    +untouched, and ``api_mode`` was never written to ``model_cfg`` at all in
    +either branch (only present in the in-memory session override).
    +
    +Fix: an explicit set-or-clear for both fields when the target provider is
    +custom, so a genuine switch always leaves config.yaml coherent with the
    +resolved result.
    +"""
    +
    +import types
    +
    +import yaml
    +import pytest
    +
    +from gateway.config import Platform
    +from gateway.platforms.base import MessageEvent, MessageType
    +from gateway.run import GatewayRunner
    +from gateway.session import SessionSource
    +
    +
    +class _FakePickerAdapter:
    +    def __init__(self):
    +        self.captured_callback = None
    +
    +    async def send_model_picker(self, *, on_model_selected, **kwargs):
    +        self.captured_callback = on_model_selected
    +        return types.SimpleNamespace(success=True)
    +
    +
    +def _make_runner(adapter=None):
    +    runner = object.__new__(GatewayRunner)
    +    runner.adapters = {Platform.TELEGRAM: adapter} if adapter else {}
    +    runner._voice_mode = {}
    +    runner._session_model_overrides = {}
    +    runner._running_agents = {}
    +    return runner
    +
    +
    +def _make_event(text):
    +    return MessageEvent(
    +        text=text,
    +        message_type=MessageType.TEXT,
    +        source=SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"),
    +    )
    +
    +
    +def _fake_switch_result(*, base_url="", api_mode=""):
    +    from hermes_cli.model_switch import ModelSwitchResult
    +
    +    return ModelSwitchResult(
    +        success=True,
    +        new_model="local-llama",
    +        target_provider="custom",
    +        provider_changed=True,
    +        api_key="[redacted:secret-assignment]",
    +        base_url=base_url,
    +        api_mode=api_mode,
    +        provider_label="Custom",
    +        is_global=True,
    +    )
    +
    +
    +def _setup_isolated_home(tmp_path, monkeypatch, model_yaml_value, *, base_url="", api_mode=""):
    +    import gateway.run as gateway_run
    +
    +    hermes_home = tmp_path / ".hermes"
    +    hermes_home.mkdir()
    +    cfg_path = hermes_home / "config.yaml"
    +    cfg_path.write_text(
    +        yaml.safe_dump({"model": model_yaml_value, "providers": {}}),
    +        encoding="utf-8",
    +    )
    +
    +    monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr(
    +        "hermes_cli.model_switch.list_picker_providers",
    +        lambda **kw: [{"slug": "custom", "name": "Custom", "models": ["local-llama"]}],
    +    )
    +    monkeypatch.setattr(
    +        "hermes_cli.model_switch.switch_model",
    +        lambda **kw: _fake_switch_result(base_url=base_url, api_mode=api_mode),
    +    )
    +    monkeypatch.setattr(
    +        "hermes_cli.model_switch.resolve_display_context_length",
    +        lambda *a, **k: 8192,
    +    )
    +    monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home)
    +    monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home)
    +    return cfg_path
    +
    +
    +_STALE_MODEL_CFG = {
    +    "default": "old-custom-model",
    +    "provider": "custom",
    +    "base_url": "https://old-stale-endpoint.example/v1",
    +    "api_key": "sk-stale",
    +    "api_mode": "anthropic_messages",
    +}
    +
    +
    +# ---------------------------------------------------------------------------
    +# Typed `/model X --global` path (_finish_switch)
    +# ---------------------------------------------------------------------------
    +
    +
    [redacted:email]
    +async def test_typed_switch_to_custom_clears_stale_base_url_and_api_mode(tmp_path, monkeypatch):
    +    """Switching to a custom provider whose resolver returns no base_url/
    +    api_mode must clear the previous custom endpoint's leftovers, not keep
    +    routing at the old host/protocol (#25107)."""
    +    cfg_path = _setup_isolated_home(tmp_path, monkeypatch, dict(_STALE_MODEL_CFG))
    +
    +    result = await _make_runner()._handle_model_command(
    +        _make_event("/model local-llama --provider custom --global")
    +    )
    +
    +    assert result is not None
    +    written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
    +    assert written["model"]["default"] == "local-llama"
    +    assert "base_url" not in written["model"], (
    +        "stale base_url from the old custom endpoint must be cleared"
    +    )
    +    assert "api_mode" not in written["model"], (
    +        "stale api_mode from the old custom endpoint must be cleared"
    +    )
    +
    +
    [redacted:email]
    +async def test_typed_switch_to_custom_persists_resolved_base_url_and_api_mode(tmp_path, monkeypatch):
    +    """The normal case: a custom-provider switch that DOES resolve a fresh
    +    base_url/api_mode must persist both (api_mode was never written here
    +    before the fix)."""
    +    cfg_path = _setup_isolated_home(
    +        tmp_path,
    +        monkeypatch,
    +        dict(_STALE_MODEL_CFG),
    +        base_url="https://new-endpoint.example/v1",
    +        api_mode="anthropic_messages",
    +    )
    +
    +    result = await _make_runner()._handle_model_command(
    +        _make_event("/model local-llama --provider custom --global")
    +    )
    +
    +    assert result is not None
    +    written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
    +    assert written["model"]["base_url"] == "https://new-endpoint.example/v1"
    +    assert written["model"]["api_mode"] == "anthropic_messages"
    +
    +
    +# ---------------------------------------------------------------------------
    +# Picker-tap path (_on_model_selected)
    +# ---------------------------------------------------------------------------
    +
    +
    +async def _drive_picker(runner, event):
    +    sent = await runner._handle_model_command(event)
    +    assert sent is None
    +    adapter = runner.adapters[Platform.TELEGRAM]
    +    assert adapter.captured_callback is not None, "picker callback was not wired"
    +    return await adapter.captured_callback("12345", "local-llama", "custom")
    +
    +
    [redacted:email]
    +async def test_picker_tap_to_custom_clears_stale_base_url_and_api_mode(tmp_path, monkeypatch):
    +    """Same bug, picker-tap path: tapping a custom-provider model with an
    +    empty resolved base_url must clear the previous custom endpoint (#25107).
    +    """
    +    adapter = _FakePickerAdapter()
    +    cfg_path = _setup_isolated_home(tmp_path, monkeypatch, dict(_STALE_MODEL_CFG))
    +
    +    confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model"))
    +
    +    assert confirmation is not None
    +    written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
    +    assert "base_url" not in written["model"]
    +    assert "api_mode" not in written["model"]
    ```
  - `tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py`

    ```diff
    @@ -0,0 +1,160 @@
    +"""Regression tests for #25106: CLI `/model <name> --global` never persisted
    +``model.base_url``/``model.api_mode`` to config.yaml, so a global provider
    +switch left the PREVIOUS provider's endpoint/wire-protocol on disk. The next
    +`hermes` launch re-read the stale base_url and routed the new model at the
    +old host.
    +
    +Both ``_handle_model_switch`` (typed ``/model <name>``) and
    +``_apply_model_switch_result`` (interactive picker) shared the same gap: the
    +persistence block wrote ``model.default``/``model.provider`` but never
    +touched ``base_url``/``api_mode`` at all. Fix: sync both on every global
    +switch, clearing to ``None`` when the resolved result doesn't need them —
    +mirroring the already-correct ``tui_gateway/server.py:_persist_model_switch``
    +pattern (fixed for #48305).
    +"""
    +
    +from unittest.mock import MagicMock, patch
    +
    +import pytest
    +
    +from hermes_cli.model_switch import ModelSwitchResult
    +
    +
    +def _make_result(*, base_url="https://api.minimax.io/v1", api_mode="chat_completions", provider_changed=True):
    +    return ModelSwitchResult(
    +        success=True,
    +        new_model="MiniMax-M3",
    +        target_provider="custom:minimax",
    +        provider_changed=provider_changed,
    +        api_key="[redacted:secret-assignment]",
    +        base_url=base_url,
    +        api_mode=api_mode,
    +        warning_message="",
    +        provider_label="MiniMax (custom)",
    +        resolved_via_alias=False,
    +        capabilities=None,
    +        model_info=None,
    +        is_global=True,
    +    )
    +
    +
    +class _StubCLI:
    +    """Minimum attrs/methods `_handle_model_switch` reads or calls on self."""
    +
    +    agent = None
    +    model = "old-model"
    +    provider = "copilot"
    +    requested_provider = "copilot"
    +    api_key = "[redacted:secret-assignment]"
    +    base_url = "https://api.githubcopilot.com"
    +    api_mode = "chat_completions"
    +    _explicit_api_key = ""
    +    _explicit_base_url = ""
    +    conversation_history = []
    +    _pending_model_switch_note = ""
    +
    +    def _confirm_expensive_model_switch(self, result) -> bool:
    +        return True
    +
    +    def _open_model_picker(self, *a, **k):
    +        raise AssertionError("picker should not open when a model name is given")
    +
    +
    +def _run_switch(monkeypatch, result, cmd="/model MiniMax-M3 --global"):
    +    import cli as cli_mod
    +
    +    monkeypatch.setattr(cli_mod, "_cprint", lambda *a, **k: None)
    +    saved: dict[str, object] = {}
    +
    +    def _fake_save(key, value):
    +        saved[key] = value
    +
    +    monkeypatch.setattr(cli_mod, "save_config_value", _fake_save)
    +    monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **kw: result)
    +    monkeypatch.setattr(
    +        "hermes_cli.inventory.load_picker_context",
    +        lambda: (_ for _ in ()).throw(RuntimeError("no picker context in test")),
    +    )
    +    cli_mod.HermesCLI._handle_model_switch(_StubCLI(), cmd)
    +    return saved
    +
    +
    +def test_global_switch_persists_base_url_and_api_mode(monkeypatch):
    +    """The core #25106 fix: a --global switch to a new provider/endpoint must
    +    write the newly resolved base_url and api_mode, not just default/provider."""
    +    saved = _run_switch(monkeypatch, _make_result())
    +
    +    assert saved["model.default"] == "MiniMax-M3"
    +    assert saved["model.provider"] == "custom:minimax"
    +    assert saved["model.base_url"] == "https://api.minimax.io/v1"
    +    assert saved["model.api_mode"] == "chat_completions"
    +
    +
    +def test_global_switch_clears_base_url_and_api_mode_when_unresolved(monkeypatch):
    +    """When the resolver returns no base_url/api_mode for the new provider
    +    (e.g. a named provider needing neither), any previous value must be
    +    cleared (None) rather than silently left in config.yaml."""
    +    result = _make_result(base_url="", api_mode="")
    +    saved = _run_switch(monkeypatch, result)
    +
    +    assert saved["model.base_url"] is None
    +    assert saved["model.api_mode"] is None
    +
    +
    +def test_session_only_switch_does_not_touch_config(monkeypatch):
    +    """--session must not call save_config_value at all — persistence stays
    +    entirely in-memory."""
    +    import cli as cli_mod
    +
    +    monkeypatch.setattr(cli_mod, "_cprint", lambda *a, **k: None)
    +    save_calls = []
    +    monkeypatch.setattr(cli_mod, "save_config_value", lambda *a, **k: save_calls.append(a))
    +    monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **kw: _make_result())
    +    monkeypatch.setattr(
    +        "hermes_cli.inventory.load_picker_context",
    +        lambda: (_ for _ in ()).throw(RuntimeError("no picker context in test")),
    +    )
    +
    +    cli_mod.HermesCLI._handle_model_switch(_StubCLI(), "/model MiniMax-M3 --session")
    +
    +    assert save_calls == []
    +
    +
    +def _run_apply(monkeypatch, result, persist_global=True):
    +    """Drives `_apply_model_switch_result` directly — the interactive-picker
    +    sibling of `_handle_model_switch`. Unlike the tests above, and unlike
    +    `test_apply_model_switch_result_context.py` (which only ever calls this
    +    method with `persist_global=False`), this exercises the `persist_global=True`
    +    branch that actually writes to config.yaml."""
    +    import cli as cli_mod
    +
    +    monkeypatch.setattr(cli_mod, "_cprint", lambda *a, **k: None)
    +    saved: dict[str, object] = {}
    +
    +    def _fake_save(key, value):
    +        saved[key] = value
    +
    +    monkeypatch.setattr(cli_mod, "save_config_value", _fake_save)
    +    cli_mod.HermesCLI._apply_model_switch_result(_StubCLI(), result, persist_global)
    +    return saved
    +
    +
    +def test_picker_global_switch_persists_base_url_and_api_mode(monkeypatch):
    +    """Picker-path counterpart of `test_global_switch_persists_base_url_and_api_mode`:
    +    `_apply_model_switch_result(..., persist_global=True)` must sync base_url/api_mode
    +    too, not just default/provider."""
    +    saved = _run_apply(monkeypatch, _make_result())
    +
    +    assert saved["model.default"] == "MiniMax-M3"
    +    assert saved["model.provider"] == "custom:minimax"
    +    assert saved["model.base_url"] == "https://api.minimax.io/v1"
    +    assert saved["model.api_mode"] == "chat_completions"
    +
    +
    +def test_picker_global_switch_clears_base_url_and_api_mode_when_unresolved(monkeypatch):
    +    """Picker-path counterpart of `test_global_switch_clears_base_url_and_api_mode_when_unresolved`."""
    +    result = _make_result(base_url="", api_mode="")
    +    saved = _run_apply(monkeypatch, result)
    +
    +    assert saved["model.base_url"] is None
    +    assert saved["model.api_mode"] is None
    ```
  - `tests/hermes_cli/test_model_switch_openai_api_mode.py`

    ```diff
    @@ -0,0 +1,113 @@
    +"""Regression tests for OpenAI-direct api_mode recomputation during /model switch.
    +
    +api.openai.com only accepts the Responses API (codex_responses) for its
    +reasoning models when tools + reasoning are in play — /v1/chat/completions
    +returns HTTP 400 ("Function tools with reasoning_effort are not supported").
    +
    +When a session switches to a GPT-5.x model on api.openai.com while carrying a
    +stale ``chat_completions`` api_mode from the previous provider (e.g. an
    +openrouter default), the switch must override the stale value with the
    +host-mandated ``codex_responses``. Filling only when empty (the earlier fix)
    +was insufficient: the carried value was a *non-empty but wrong* mode.
    +
    +This surfaced after the reasoning-unification refactor made the switched
    +model's reasoning effort actually apply, turning a silently-dropped
    +reasoning_effort into a live one that OpenAI 400s on the chat_completions path.
    +"""
    +
    +from unittest.mock import patch
    +
    +from hermes_cli.model_switch import switch_model
    +
    +_MOCK_VALIDATION = {
    +    "accepted": True,
    +    "persist": True,
    +    "recognized": True,
    +    "message": None,
    +}
    +
    +
    +def _run_openai_switch(
    +    raw_input: str,
    +    current_provider: str = "openrouter",
    +    current_model: str = "anthropic/claude-opus-4.8",
    +    explicit_provider: str = "openai-api",
    +    runtime_api_mode: str = "chat_completions",
    +    runtime_base_url: str = "https://api.openai.com/v1",
    +):
    +    """Run switch_model with OpenAI-direct mocks and return the result."""
    +    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-test",
    +                "base_url": runtime_base_url,
    +                "api_mode": runtime_api_mode,
    +            },
    +        ),
    +        patch(
    +            "hermes_cli.models.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,
    +            explicit_provider=explicit_provider,
    +        )
    +
    +
    +def test_stale_chat_completions_overridden_on_openai_direct():
    +    """The incident: stale chat_completions on api.openai.com → codex_responses.
    +
    +    Switching from an openrouter/chat_completions session to gpt-5.6-sol on
    +    api.openai.com must flip the wire protocol to the Responses API so tools +
    +    reasoning are preserved, instead of 400ing on chat/completions.
    +    """
    +    result = _run_openai_switch(
    +        raw_input="gpt-5.6-sol",
    +        current_provider="openrouter",
    +        current_model="anthropic/claude-opus-4.8",
    +        explicit_provider="openai-api",
    +        runtime_api_mode="chat_completions",  # stale value carried over
    +    )
    +
    +    assert result.success, f"switch_model failed: {result.error_message}"
    +    assert result.target_provider == "openai-api"
    +    assert result.new_model == "gpt-5.6-sol"
    +    assert result.api_mode == "codex_responses"
    +
    +
    +def test_empty_api_mode_filled_on_openai_direct():
    +    """An empty runtime api_mode on api.openai.com resolves to codex_responses."""
    +    result = _run_openai_switch(
    +        raw_input="gpt-5.6-sol",
    +        runtime_api_mode="",  # empty — the earlier fill-if-empty subcase
    +    )
    +
    +    assert result.success, f"switch_model failed: {result.error_message}"
    +    assert result.api_mode == "codex_responses"
    +
    +
    +def test_generic_endpoint_keeps_explicit_api_mode():
    +    """A generic (non-host-mandated) endpoint must NOT have its api_mode clobbered.
    +
    +    Only hosts that mandate one wire protocol override a carried value; a
    +    generic OpenAI-compatible relay returns None from host_mandated_api_mode,
    +    so the switch path leaves the resolver's api_mode untouched.
    +    """
    +    from hermes_cli.providers import host_mandated_api_mode
    +
    +    assert host_mandated_api_mode("https://generic.example.com/v1") is None
    +    # Lookalike / path-spoof hosts must also NOT be treated as mandated (#32243).
    +    assert host_mandated_api_mode("https://api.openai.com.attacker.test/v1") is None
    +    assert host_mandated_api_mode("https://proxy.test/api.openai.com/v1") is None
    +    # The real endpoints are mandated.
    +    assert host_mandated_api_mode("https://api.openai.com/v1") == "codex_responses"
    +    assert host_mandated_api_mode("https://api.anthropic.com") == "anthropic_messages"
    ```
- **#69818: fix(cli): persist provider on global model switch**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py`
- **#25131: fix(agent): persist complete model runtime state on /model switch**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `398634eed`, `#25106`, `#69818`
- **#60970: fix(cli,gateway): sync base_url/api_mode on global model switch persist**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `398634eed`, `e7f208fd7`, `#66563`, `#25106`, `#25107`

## Fix status

- Merged to main in PR #66563 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-23`.
- Subsystem: `agent`
