CLI global model switch state persistence fixes

NousResearch/hermes-agent · cluster 25106 · agent

Data as of

Nothing open on GitHub. Fix merged in #66563. Check it covers your case.

The short answer

0open issues
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #66563. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #66563. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #66563 fix(model-switch): route reasoning models to Responses API on switch + persist api_mode globally

    merged to main

    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

    Show the diff
    • cli.py
      @@ -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)")
      @@ -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
      @@ -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)
      @@ -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
      @@ -30,6 +30,7 @@
           custom_provider_slug,
           determine_api_mode,
           get_label,
      +    host_mandated_api_mode,
           is_aggregator,
           resolve_provider_full,
       )
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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

    files: cli.py, tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py

3 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1 · superseded by another PR 1

  • #25131 fix(agent): persist complete model runtime state on /model switch · superseded by another PR → 398634e, #25106, #69818
  • #25137 fix: persist CLI model runtime state · done elsewhere
  • #60970 fix(cli,gateway): sync base_url/api_mode on global model switch persist · merged via another change → 398634e, e7f208f, #66563, #25106, #25107
Closed issues · 1
  • Closed#25106[Bug]: CLI --global model switch does not persist/clear model.base_url and model.api_mode
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.33: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#66563, #69818
Closed issues
1
Attempts
3 PRs closed without merging
Fix link checked
23 Jul 2026

For your agent

The same facts, typed.