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
- 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
Open PRs0
Fixes on record
-
#66563 fix(model-switch): route reasoning models to Responses API on switch + persist api_mode globally
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.pyShow 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
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.00means 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