# NousResearch/hermes-agent · Stale base_url and api_mode persisted on model switches

## Open now

- 0 issues, 1 PR open on GitHub.
- PR #25148: fix(tui): persist api_mode in _persist_model_switch()

Most members concern model-switch flows (/model in gateway, CLI, TUI) that persist stale or incomplete runtime state such as base_url, api_mode, or provider, with reports of 404s on opencode-go models. One member about an opencode aggregator picker catalog is adjacent, and the exact causes and fix status are not established here.

## TL;DR

- 16 member(s): 0 open issue(s), 4 closed issue(s), 1 open PR(s), 4 merged PR(s), 7 PR(s) closed without merging.
- A fix was merged to main in PR #69818. Verify it addresses your case.
- **Recommended:** read PR #69818 (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: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.11`: 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

- #25107: \[Bug\]: Gateway /model switch leaves stale base_url and does not persist api_mode
- #51486: Gateway model switches persist stale base_url for built-in providers
- #56158: \[Bug\]: /model switch from Telegram persists stale base_url → 404 on opencode-go chat_completions models
- #57259: \[Bug\]: Opencode go Only working with Minimax models

## Prior & competing PRs

- **#53977: fix(models): opencode aggregator picker catalog + uncap + scoped live-first; clear stale gateway base_url**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/slash_commands.py`, `hermes_cli/config.py`, `hermes_cli/model_switch.py`, `hermes_cli/models.py`, `infographic/model-picker-fixes/infographic.png`, `scripts/release.py`, `tests/gateway/test_model_command_flat_string_config.py`, `tests/gateway/test_model_picker_persist.py`, `tests/hermes_cli/test_opencode_zen_model_limit.py`, `tests/hermes_cli/test_provider_live_curated_merge.py`
  - fix hunks:
  - `gateway/slash_commands.py`

    ```diff
    @@ -1361,7 +1361,7 @@ async def _on_model_selected(
                                     if result.base_url:
                                         _persist_model_cfg["base_url"] = result.base_url
                                     if str(result.target_provider or "").strip().lower() != "custom":
    -                                    clear_model_endpoint_credentials(_persist_model_cfg)
    +                                    clear_model_endpoint_credentials(_persist_model_cfg, clear_base_url=True)
                                     from hermes_cli.config import save_config
                                     save_config(_persist_cfg)
                                 except Exception as e:
    ```

    ```diff
    @@ -1598,7 +1598,7 @@ async def _finish_switch() -> str:
                         if result.base_url:
                             model_cfg["base_url"] = result.base_url
                         if str(result.target_provider or "").strip().lower() != "custom":
    -                        clear_model_endpoint_credentials(model_cfg)
    +                        clear_model_endpoint_credentials(model_cfg, clear_base_url=True)
                         from hermes_cli.config import save_config
                         save_config(cfg)
                     except Exception as e:
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -4131,6 +4131,7 @@ def clear_model_endpoint_credentials(
         *,
         clear_api_key: bool = True,
         clear_api_mode: bool = True,
    +    clear_base_url: bool = False,
     ) -> Dict[str, Any]:
         """Remove stale inline endpoint credentials from a model config.
     
    ```

    ```diff
    @@ -4147,6 +4148,8 @@ def clear_model_endpoint_credentials(
             model_cfg.pop("api", None)
         if clear_api_mode:
             model_cfg.pop("api_mode", None)
    +    if clear_base_url:
    +        model_cfg.pop("base_url", None)
         return model_cfg
     
     
    ```
  - `hermes_cli/model_switch.py`

    ```diff
    @@ -44,6 +44,11 @@
         list_provider_models,
     )
     
    +# Providers whose picker model list should NOT be capped by max_models.
    +# OpenCode Zen / Go are aggregators whose full catalogs (70+ models each) must
    +# be visible so users can pick any model they have access to.
    +_UNCAPPED_PICKER_PROVIDERS: frozenset[str] = frozenset({"opencode-zen", "opencode-go"})
    +
     logger = logging.getLogger(__name__)
     
     
    ```

    ```diff
    @@ -1650,7 +1655,10 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
                 if hermes_id in _MODELS_DEV_PREFERRED:
                     model_ids = _merge_with_models_dev(hermes_id, model_ids)
             total = len(model_ids)
    -        top = model_ids[:max_models] if max_models is not None else model_ids
    +        if hermes_id in _UNCAPPED_PICKER_PROVIDERS:
    +            top = model_ids  # Aggregator: show full catalog regardless of max_models
    +        else:
    +            top = model_ids[:max_models] if max_models is not None else model_ids
     
             slug = hermes_id
             pinfo = _mdev_pinfo(mdev_id)
    ```

    ```diff
    @@ -1813,7 +1821,10 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
                     if hermes_slug in _MODELS_DEV_PREFERRED:
                         model_ids = _merge_with_models_dev(hermes_slug, model_ids)
             total = len(model_ids)
    -        top = model_ids[:max_models] if max_models is not None else model_ids
    +        if hermes_slug in _UNCAPPED_PICKER_PROVIDERS:
    +            top = model_ids  # Aggregator: show full catalog regardless of max_models
    +        else:
    +            top = model_ids[:max_models] if max_models is not None else model_ids
     
             results.append({
                 "slug": hermes_slug,
    ```
  - `hermes_cli/models.py`

    ```diff
    @@ -381,9 +381,15 @@ def _xai_curated_models() -> list[str]:
         ],
         "opencode-zen": [
             "kimi-k2.5",
    +        "kimi-k2.6",
    +        "gpt-5.5",
    +        "gpt-5.5-pro",
             "gpt-5.4-pro",
             "gpt-5.4",
    +        "gpt-5.4-mini",
    +        "gpt-5.4-nano",
             "gpt-5.3-codex",
    +        "gpt-5.3-codex-spark",
             "gpt-5.2",
             "gpt-5.2-codex",
             "gpt-5.1",
    ```

    ```diff
    @@ -393,6 +399,9 @@ def _xai_curated_models() -> list[str]:
             "gpt-5",
             "gpt-5-codex",
             "gpt-5-nano",
    +        "claude-fable-5",
    +        "claude-opus-4-8",
    +        "claude-opus-4-7",
             "claude-opus-4-6",
             "claude-opus-4-5",
             "claude-opus-4-1",
    ```

    ```diff
    @@ -400,21 +409,25 @@ def _xai_curated_models() -> list[str]:
             "claude-sonnet-4-5",
             "claude-sonnet-4",
             "claude-haiku-4-5",
    -        "claude-3-5-haiku",
    +        "gemini-3.5-flash",
             "gemini-3.1-pro",
    -        "gemini-3-pro",
             "gemini-3-flash",
             "minimax-m2.7",
             "minimax-m2.5",
    -        "minimax-m2.5-free",
    -        "minimax-m2.1",
    +        "minimax-m3-free",
    +        "glm-5.1",
             "glm-5",
    -        "glm-4.7",
    -        "glm-4.6",
    -        "kimi-k2-thinking",
    -        "kimi-k2",
    -        "qwen3-coder",
    +        "deepseek-v4-pro",
    +        "deepseek-v4-flash",
    +        "deepseek-v4-flash-free",
    +        "qwen3.6-plus",
    +        "qwen3.6-plus-free",
    +        "qwen3.5-plus",
    +        "grok-build-0.1",
             "big-pickle",
    +        "mimo-v2.5-free",
    +        "north-mini-code-free",
    +        "nemotron-3-ultra-free",
         ],
         "opencode-go": [
             "kimi-k2.6",
    ```

    ```diff
    @@ -1782,6 +1795,17 @@ def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool:
     # away from the model's native vendor). None are currently defined.
     _BORROWED_MODEL_PROVIDERS: frozenset[str] = frozenset()
     
    +# Providers whose live /v1/models endpoint is the authoritative catalog, so the
    +# curated list is a discovery-only fallback. For these, the picker merges
    +# live-first (live entries lead, curated-only entries append). Every OTHER
    +# provider keeps curated-first (commit 658ac1d86, #46309) so a deliberately
    +# surfaced newest model stays at the top even when the live API lags. OpenCode
    +# Zen / Go re-expose dozens of upstream vendors and rotate them frequently, so
    +# their stale curated entries must not pollute the top of the picker. (#49129)
    +_LIVE_FIRST_PICKER_PROVIDERS: frozenset[str] = frozenset(
    +    {"opencode-zen", "opencode-go"}
    +)
    +
     
     def _resolve_static_model_alias(
         name_lower: str,
    ```

    ```diff
    @@ -2420,15 +2444,22 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
                         # Merge static curated list with live API results so
                         # models that the live endpoint omits (stale cache,
                         # partial rollout) still appear in the picker.
    -                    # Curated entries come first so deliberately-surfaced
    -                    # newest models (e.g. kimi-k2.7-code, #46309) stay at
    -                    # the top of the picker; live-only entries are appended
    -                    # afterwards for discovery.  (#46850)
    +                    #
    +                    # Single providers (kimi, zai) use curated-first
    +                    # (commit 658ac1d86) to surface newest models even when live
    +                    # API lags (#46309). OpenCode Zen / Go are different: their
    +                    # live API is the authoritative catalog, so they merge
    +                    # live-first — live entries lead and stale curated entries
    +                    # no longer pollute the top of the picker. (#49129)
                         curated = list(_PROVIDER_MODELS.get(normalized, []))
                         if curated:
    -                        merged = list(curated)
    -                        merged_lower = {m.lower() for m in curated}
    -                        for m in live:
    +                        if normalized in _LIVE_FIRST_PICKER_PROVIDERS:
    +                            primary, secondary = live, curated
    +                        else:
    +                            primary, secondary = curated, live
    +                        merged = list(primary)
    +                        merged_lower = {m.lower() for m in primary}
    +                        for m in secondary:
                                 if m.lower() not in merged_lower:
                                     merged.append(m)
                                     merged_lower.add(m.lower())
    ```
  - `scripts/release.py`

    ```diff
    @@ -1713,6 +1713,8 @@
         "[redacted:email]": "qingshan89",  # PR #46895 co-author (desktop remote artifact download)
         "[redacted:email]": "AJ",  # PR #44510 co-author (desktop named-profile boot loop)
         "[redacted:email]": "steveonjava",  # PR #29669 (redact secrets in kanban tool payloads)
    +    "[redacted:email]": "Afnath-max",  # PR #49129 salvage (opencode-zen catalog refresh + uncapped/live-first picker)
    +    "[redacted:email]": "ipriyaaanshu",  # PR #51488 salvage (clear stale base_url on gateway model switches; #25107)
     }
     
     
    ```
  - `tests/gateway/test_model_command_flat_string_config.py`

    ```diff
    @@ -102,7 +102,7 @@ async def test_model_global_persists_when_config_has_flat_string_model(tmp_path,
         )
         assert written["model"]["default"] == "gpt-5.5"
         assert written["model"]["provider"] == "openrouter"
    -    assert written["model"]["base_url"] == "https://openrouter.ai/api/v1"
    +    assert "base_url" not in written["model"]
     
     
     @pytest.mark.asyncio
    ```
  - `tests/gateway/test_model_picker_persist.py`

    ```diff
    @@ -171,7 +171,7 @@ async def test_picker_tap_persists_by_default(tmp_path, monkeypatch, seed_model)
         )
         assert written["model"]["default"] == "gpt-5.5"
         assert written["model"]["provider"] == "openrouter"
    -    assert written["model"]["base_url"] == "https://openrouter.ai/api/v1"
    +    assert "base_url" not in written["model"]
         assert "api_key" not in written["model"]
         assert "api_mode" not in written["model"]
     
    ```
  - `tests/hermes_cli/test_opencode_zen_model_limit.py`

    ```diff
    @@ -0,0 +1,51 @@
    +"""Regression tests for OpenCode Zen model picker limits."""
    +
    +import os
    +from unittest.mock import patch
    +
    +import hermes_cli.providers as providers_mod
    +from hermes_cli.model_switch import list_authenticated_providers
    +
    +
    +def test_opencode_zen_lists_all_models_while_other_providers_remain_capped(monkeypatch):
    +    """OpenCode Zen is an aggregator product, so the picker must expose its full catalog."""
    +    zen_models = [f"zen-model-{i}" for i in range(57)]
    +    deepseek_models = [f"deepseek-model-{i}" for i in range(57)]
    +
    +    monkeypatch.setattr(
    +        "agent.models_dev.PROVIDER_TO_MODELS_DEV",
    +        {
    +            "opencode-zen": "opencode",
    +            "deepseek": "deepseek",
    +        },
    +    )
    +    monkeypatch.setattr(
    +        "agent.models_dev.fetch_models_dev",
    +        lambda: {"opencode": {}, "deepseek": {}},
    +    )
    +    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    +    monkeypatch.setattr(
    +        "hermes_cli.models.cached_provider_model_ids",
    +        lambda provider: {
    +            "opencode-zen": zen_models,
    +            "deepseek": deepseek_models,
    +        }.get(provider, []),
    +    )
    +
    +    with patch.dict(
    +        os.environ,
    +        {
    +            "OPENCODE_ZEN_API_KEY": "[redacted:secret-assignment]",
    +            "DEEPSEEK_API_KEY": "[redacted:secret-assignment]",
    +        },
    +        clear=False,
    +    ):
    +        providers = list_authenticated_providers(max_models=50)
    +
    +    opencode_zen = next(p for p in providers if p["slug"] == "opencode-zen")
    +    deepseek = next(p for p in providers if p["slug"] == "deepseek")
    +
    +    assert opencode_zen["models"] == zen_models
    +    assert opencode_zen["total_models"] == len(zen_models)
    +    assert deepseek["models"] == deepseek_models[:50]
    +    assert deepseek["total_models"] == len(deepseek_models)
    ```
  - `tests/hermes_cli/test_provider_live_curated_merge.py`

    ```diff
    @@ -1,13 +1,23 @@
     """Tests for live+curated merge in the generic profile-based provider path.
     
    -Guards the fix for #46850: when a provider's live /v1/models endpoint
    -returns a stale or incomplete list, the static curated models from
    -``_PROVIDER_MODELS`` must still appear in the merged result.
    +Guards two contracts:
    +
    +* #46850 — when a provider's live /v1/models endpoint returns a stale or
    +  incomplete list, the static curated models from ``_PROVIDER_MODELS`` must
    +  still appear in the merged result (nothing is dropped).
    +* #46309 / #49129 — merge *order* is per-provider. Single providers
    +  (kimi, zai) stay **curated-first** so a deliberately surfaced newest model
    +  leads even when the live API lags. ``_LIVE_FIRST_PICKER_PROVIDERS``
    +  (OpenCode Zen / Go) flip to **live-first** because their live API is the
    +  authoritative catalog and stale curated entries must not lead the picker.
     """
     
     from unittest.mock import MagicMock, patch
     
    -from hermes_cli.models import _PROVIDER_MODELS, provider_model_ids
    +from hermes_cli.models import (
    +    _LIVE_FIRST_PICKER_PROVIDERS,
    +    provider_model_ids,
    +)
     
     
     class TestGenericProviderLiveCuratedMerge:
    ```

    ```diff
    @@ -22,46 +32,84 @@ def _make_profile(self, models=None):
             p.fallback_models = None
             return p
     
    -    def test_live_models_merged_with_curated(self):
    -        """Curated models come first; live-only models are appended."""
    -        live = ["glm-5.2", "glm-5.1", "glm-5"]
    -        curated = _PROVIDER_MODELS["zai"]  # includes glm-5.1, glm-5, glm-4.5, etc.
    +    def test_curated_first_for_single_provider(self):
    +        """Single providers (zai) stay curated-first; live-only appended."""
    +        assert "zai" not in _LIVE_FIRST_PICKER_PROVIDERS
    +        curated = ["glm-5.2", "glm-5.1", "glm-5"]  # authoritative-intent order
    +        # Live API lags AND surfaces a brand-new model not yet curated.
    +        live = ["glm-5", "glm-6-preview"]
             profile = self._make_profile(live)
     
             with (
                 patch("providers.get_provider_profile", return_value=profile),
    -            patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "k", "base_url": ""}),
    +            patch(
    +                "hermes_cli.auth.resolve_api_key_provider_credentials",
    +                return_value={"api_key": "k", "base_url": ""},
    +            ),
    +            patch.dict("hermes_cli.models._PROVIDER_MODELS", {"zai": curated}),
             ):
                 result = provider_model_ids("zai")
     
    -        # Curated entries first, in catalog order (keeps newest curated models
    -        # like glm-5.2 at the top of the picker — see #46309).
    -        assert result[: len(curated)] == list(curated)
    -        assert result[0] == "glm-5.2"
    -        # Models present in both live and curated are not duplicated.
    -        assert result.count("glm-5.2") == 1
    -        assert result.count("glm-5.1") == 1
    -        # Curated-only entries are part of the result (e.g. glm-4.5).
    -        result_lower = [m.lower() for m in result]
    -        assert "glm-4.5" in result_lower
    -        assert "glm-4.5-flash" in result_lower
    -
    -    def test_no_duplicate_models(self):
    -        """Models appearing in both live and curated are not duplicated."""
    -        live = ["glm-5.1", "glm-5"]
    -        curated = ["glm-5.1", "glm-5", "glm-4.5"]
    +        # Curated entries lead (commit 658ac1d86, #46309).
    +        assert result[: len(curated)] == curated
    +        # Live-only entries (glm-6-preview) still surface, appended afterwards.
    +        assert "glm-6-preview" in result
    +        assert result.index("glm-6-preview") >= len(curated)
    +        # No duplicates for models present in both.
    +        assert result.count("glm-5") == 1
    +
    +    def test_live_first_for_opencode_zen(self):
    +        """OpenCode Zen flips to live-first; curated-only models appended."""
    +        assert "opencode-zen" in _LIVE_FIRST_PICKER_PROVIDERS
    +        live = ["nemotron-3-ultra-free", "gpt-5.5", "claude-fable-5"]
    +        curated = ["gpt-5.5", "claude-fable-5", "big-pickle"]
             profile = self._make_profile(live)
     
             with (
                 patch("providers.get_provider_profile", return_value=profile),
    -            patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "k", "base_url": ""}),
    -            patch.dict("hermes_cli.models._PROVIDER_MODELS", {"zai": curated}),
    +            patch(
    +                "hermes_cli.auth.resolve_api_key_provider_credentials",
    +                return_value={"api_key": "k", "base_url": ""},
    +            ),
    +            patch.dict("hermes_cli.models._PROVIDER_MODELS", {"opencode-zen": curated}),
             ):
    -            result = provider_model_ids("zai")
    +            result = provider_model_ids("opencode-zen")
    +
    +        # Live entries lead (authoritative aggregator catalog).
    +        assert result[: len(live)] == list(live)
    +        assert result[0] == "nemotron-3-ultra-free"
    +        # Curated-only entries (big-pickle) appended for discovery.
    +        assert "big-pickle" in result
    +        assert result.index("big-pickle") >= len(live)
    +        # No duplicates.
    +        assert result.count("gpt-5.5") == 1
    +
    +    def test_no_models_dropped_either_direction(self):
    +        """Every live AND curated model survives the merge for both modes."""
    +        live = ["a", "b"]
    +        # zai = curated-first
    +        with (
    +            patch("providers.get_provider_profile", return_value=self._make_profile(live)),
    +            patch(
    +                "hermes_cli.auth.resolve_api_key_provider_credentials",
    +                return_value={"api_key": "k", "base_url": ""},
    +            ),
    +            patch.dict("hermes_cli.models._PROVIDER_MODELS", {"zai": ["c", "b"]}),
    +        ):
    +            zai_result = set(provider_model_ids("zai"))
    +        assert {"a", "b", "c"} <= zai_result
     
    -        assert result.count("glm-5.1") == 1
    -        assert result.count("glm-5") == 1
    -        assert result == ["glm-5.1", "glm-5", "glm-4.5"]
    +        # opencode-zen = live-first
    +        with (
    +            patch("providers.get_provider_profile", return_value=self._make_profile(live)),
    +            patch(
    +                "hermes_cli.auth.resolve_api_key_provider_credentials",
    +                return_value={"api_key": "k", "base_url": ""},
    +            ),
    +            patch.dict("hermes_cli.models._PROVIDER_MODELS", {"opencode-zen": ["c", "b"]}),
    +        ):
    +            zen_result = set(provider_model_ids("opencode-zen"))
    +        assert {"a", "b", "c"} <= zen_result
     
         def test_case_insensitive_dedup(self):
             """Dedup is case-insensitive but preserves first occurrence casing."""
    ```

    ```diff
    @@ -71,129 +119,13 @@ def test_case_insensitive_dedup(self):
     
             with (
                 patch("providers.get_provider_profile", return_value=profile),
    -            patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "k", "base_url": ""}),
    +            patch(
    +                "hermes_cli.auth.resolve_api_key_provider_credentials",
    +                return_value={"api_key": "k", "base_url": ""},
    +            ),
                 patch.dict("hermes_cli.models._PROVIDER_MODELS", {"zai": curated}),
             ):
                 result = provider_model_ids("zai")
     
    -        # Curated-first: curated casing wins for models present in both.
    +        # zai is curated-first: curated casing wins for models present in both.
             assert result == ["glm-5.1", "GLM-5", "glm-4.5"]
    -
    -    def test_empty_curated_returns_live_only(self):
    -        """When no curated list exists, live is returned as-is."""
    -        live = ["model-a", "model-b"]
    -        profile = self._make_profile(live)
    -
    -        with (
    -            patch("providers.get_provider_profile", return_value=profile),
    -            patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "k", "base_url": ""}),
    -            patch.dict("hermes_cli.models._PROVIDER_MODELS", {"zai": []}),
    -        ):
    -            result = provider_model_ids("zai")
    -
    -        assert result == ["model-a", "model-b"]
    -
    -    def test_live_empty_falls_back_to_curated(self):
    -        """When live returns nothing, curated static list is used.
    -
    -        ZAI is in _MODELS_DEV_PREFERRED so the fallback path merges with
    -        models.dev.  We mock _merge_with_models_dev to isolate the test.
    -        """
    -        curated = ["glm-5.1", "glm-5", "glm-4.5"]
    -        profile = self._make_profile([])
    -
    -        with (
    -            patch("providers.get_provider_profile", return_value=profile),
    -            patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "k", "base_url": ""}),
    -            patch.dict("hermes_cli.models._PROVIDER_MODELS", {"zai": curated}),
    -            patch("hermes_cli.models._merge_with_models_dev", return_value=curated),
    -        ):
    -            result = provider_model_ids("zai")
    -
    -        assert result == curated
    -
    -
    -class TestValidateRequestedModelCuratedFallback:
    -    """validate_requested_model falls back to curated catalog when live API omits model."""
    -
    -    def test_model_in_curated_but_not_live_is_accepted(self):
    -        """When live /v1/models omits a model that exists in the curated
    -        catalog, validate_requested_model should accept it with a note.
    -
    -        Patches the real ``_PROVIDER_MODELS`` source (not the function under
    -        test) so the curated-catalog fallback is genuinely exercised.
    -        """
    -        from hermes_cli.models import validate_requested_model
    -
    -        # Live API returns only glm-5.1, but curated has glm-5.2
    -        live_models = ["glm-5.1"]
    -        curated = ["glm-5.2", "glm-5.1", "glm-5", "glm-4.5"]
    -
    -        with (
    -            patch("hermes_cli.models.fetch_api_models", return_value=live_models),
    -            patch.dict("hermes_cli.models._PROVIDER_MODELS", {"zai": curated}),
    -        ):
    -            result = validate_requested_model("glm-5.2", "zai", api_key="dummy")
    -
    -        assert result["accepted"] is True
    -        assert result["recognized"] is True
    -        assert result["message"] is not None
    -        assert "curated catalog" in result["message"]
    -
    -    def test_model_not_in_curated_nor_live_is_rejected(self):
    -        """When a model is in neither live nor curated, it should be rejected."""
    -        from hermes_cli.models import validate_requested_model
    -
    -        live_models = ["glm-5.1"]
    -        curated = ["glm-5.1", "glm-5", "glm-4.5"]
    -
    -        with (
    -            patch("hermes_cli.models.fetch_api_models", return_value=live_models),
    -            patch.dict("hermes_cli.models._PROVIDER_MODELS", {"zai": curated}),
    -        ):
    -            result = validate_requested_model("nonexistent-model", "zai", api_key="dummy")
    -
    -        assert result["accepted"] is False
    -
    -    def test_model_in_live_is_accepted_without_curated_check(self):
    -        """When the model is in the live API, it should be accepted directly."""
    -        from hermes_cli.models import validate_requested_model
    -
    -        live_models = ["glm-5.1", "glm-5"]
    -
    -        with patch("hermes_cli.models.fetch_api_models", return_value=live_models):
    -            result = validate_requested_model("glm-5.1", "zai", api_key="dummy")
    -
    -        assert result["accepted"] is True
    -        assert result["recognized"] is True
    -        assert result["message"] is None
    -
    -    def test_curated_fallback_is_scoped_to_the_current_provider(self):
    -        """The curated fallback must not leak models across providers.
    -
    -        A model that lives in some OTHER provider's catalog (or only on an
    -        aggregator like OpenRouter) must still be rejected when the current
    -        provider neither lists it live nor ships it in its OWN curated
    -        catalog.  The fallback keys on ``_provider_keys(normalized)``, so
    -        catalog membership is checked per-provider, never globally.
    -        """
    -        from hermes_cli.models import validate_requested_model
    -
    -        # `some-other-model` is known to a DIFFERENT provider, not to zai.
    -        # zai's live listing also omits it.  It must be rejected.
    -        live_models = ["glm-5.1"]
    -
    -        with (
    -            patch("hermes_cli.models.fetch_api_models", return_value=live_models),
    -            patch.dict(
    -                "hermes_cli.models._PROVIDER_MODELS",
    -                {"zai": ["glm-5.2", "glm-5.1"], "openrouter": ["some-other-model"]},
    -            ),
    -        ):
    -            result = validate_requested_model("some-other-model", "zai", api_key="dummy")
    -
    -        assert result["accepted"] is False, (
    -            "A model only present in another provider's catalog must not be "
    -            "accepted on this provider via the curated fallback."
    -        )
    -
    ```
- **#56249: fix(gateway): don't persist base_url for non-custom providers on /model switch**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `f6deabca0`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#57585: fix(opencode-go): heal stripped /v1 base_url so non-minimax models stop 404ing**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/model_switch.py`, `hermes_cli/models.py`, `hermes_cli/runtime_provider.py`, `tests/hermes_cli/test_model_validation.py`, `tests/hermes_cli/test_runtime_provider_resolution.py`
  - fix hunks:
  - `hermes_cli/model_switch.py`

    ```diff
    @@ -1303,20 +1303,19 @@ def switch_model(
             api_mode = determine_api_mode(target_provider, base_url)
     
         # OpenCode base URLs end with /v1 for OpenAI-compatible models, but the
    -    # Anthropic SDK prepends its own /v1/messages to the base_url.  Strip the
    -    # trailing /v1 so the SDK constructs the correct path (e.g.
    -    # https://opencode.ai/zen/go/v1/messages instead of .../v1/v1/messages).
    -    # Mirrors the same logic in hermes_cli.runtime_provider.resolve_runtime_provider;
    -    # without it, /model switches into an anthropic_messages-routed OpenCode
    -    # model (e.g. `/model minimax-m2.7` on opencode-go, `/model claude-sonnet-4-6`
    -    # on opencode-zen) hit a double /v1 and returned OpenCode's website 404 page.
    -    if (
    -        api_mode == "anthropic_messages"
    -        and target_provider in {"opencode-zen", "opencode-go"}
    -        and isinstance(base_url, str)
    -        and base_url
    -    ):
    -        base_url = re.sub(r"/v1/?$", "", base_url)
    +    # Anthropic SDK prepends its own /v1/messages to the base_url.  Normalize
    +    # symmetrically (strip /v1 for anthropic_messages, re-append it for
    +    # chat_completions / codex_responses).  Mirrors the same logic in
    +    # hermes_cli.runtime_provider.resolve_runtime_provider; without the strip,
    +    # /model switches into an anthropic_messages-routed OpenCode model
    +    # (e.g. `/model minimax-m2.7` on opencode-go, `/model claude-sonnet-4-6`
    +    # on opencode-zen) hit a double /v1 and returned OpenCode's website 404
    +    # page — and without the re-append, a stripped URL persisted to
    +    # model.base_url broke every later chat_completions model (glm, deepseek,
    +    # kimi) the same way.
    +    if target_provider in {"opencode-zen", "opencode-go"} and isinstance(base_url, str):
    +        from hermes_cli.models import normalize_opencode_base_url
    +        base_url = normalize_opencode_base_url(target_provider, api_mode, base_url)
     
         # --- Get capabilities (legacy) ---
         capabilities = get_model_capabilities(target_provider, new_model)
    ```
  - `hermes_cli/models.py`

    ```diff
    @@ -415,14 +415,18 @@ def _xai_curated_models() -> list[str]:
             "gemini-3.5-flash",
             "gemini-3.1-pro",
             "gemini-3-flash",
    +        "minimax-m3",
             "minimax-m2.7",
             "minimax-m2.5",
             "minimax-m3-free",
    +        "glm-5.2",
             "glm-5.1",
             "glm-5",
    +        "kimi-k2.7-code",
             "deepseek-v4-pro",
             "deepseek-v4-flash",
             "deepseek-v4-flash-free",
    +        "qwen3.7-plus",
             "qwen3.6-plus",
             "qwen3.6-plus-free",
             "qwen3.5-plus",
    ```

    ```diff
    @@ -433,17 +437,23 @@ def _xai_curated_models() -> list[str]:
             "nemotron-3-ultra-free",
         ],
         "opencode-go": [
    +        "kimi-k2.7-code",
             "kimi-k2.6",
             "kimi-k2.5",
    +        "glm-5.2",
             "glm-5.1",
             "glm-5",
             "mimo-v2.5-pro",
             "mimo-v2.5",
             "mimo-v2-pro",
             "mimo-v2-omni",
    +        "minimax-m3",
             "minimax-m2.7",
             "minimax-m2.5",
    +        "deepseek-v4-pro",
    +        "deepseek-v4-flash",
             "qwen3.7-max",
    +        "qwen3.7-plus",
             "qwen3.6-plus",
             "qwen3.5-plus",
         ],
    ```

    ```diff
    @@ -3373,12 +3383,14 @@ def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str])
     
         - GPT-5 / Codex models on Zen use ``/v1/responses``
         - Claude models on Zen use ``/v1/messages``
    -    - MiniMax models on Go use ``/v1/messages``
    -    - GLM / Kimi on Go use ``/v1/chat/completions``
    -    - Other Zen models (Gemini, GLM, Kimi, MiniMax, Qwen, etc.) use
    +    - MiniMax and Qwen models on Go use ``/v1/messages``
    +    - GLM / Kimi / DeepSeek / MiMo on Go use ``/v1/chat/completions``
    +    - Qwen models on Zen use ``/v1/messages``
    +    - Other Zen models (Gemini, GLM, Kimi, MiniMax, DeepSeek, etc.) use
           ``/v1/chat/completions``
     
    -    This follows the published OpenCode docs for Zen and Go endpoints.
    +    This follows the published OpenCode docs for Zen and Go endpoints
    +    (https://opencode.ai/docs/zen/ and https://opencode.ai/docs/go/).
         """
         provider = normalize_provider(provider_id)
         normalized = normalize_opencode_model_id(provider_id, model_id).lower()
    ```

    ```diff
    @@ -3388,7 +3400,9 @@ def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str])
         if provider == "opencode-go":
             if normalized.startswith("minimax-"):
                 return "anthropic_messages"
    -        if normalized.startswith("qwen3.7-max"):
    +        if normalized.startswith("qwen"):
    +            # All Qwen models on Go (qwen3.7-max, qwen3.7-plus, qwen3.6-plus)
    +            # are served via /v1/messages per the published Go endpoint table.
                 return "anthropic_messages"
             return "chat_completions"
     
    ```

    ```diff
    @@ -3397,11 +3411,61 @@ def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str])
                 return "anthropic_messages"
             if normalized.startswith("gpt-"):
                 return "codex_responses"
    +        if normalized.startswith("qwen"):
    +            # Qwen models on Zen moved to /v1/messages per the published
    +            # Zen endpoint table.
    +            return "anthropic_messages"
             return "chat_completions"
     
         return "chat_completions"
     
     
    +def normalize_opencode_base_url(
    +    provider_id: Optional[str], api_mode: Optional[str], base_url: Optional[str]
    +) -> str:
    +    """Normalize an OpenCode Zen / Go base URL for the target API mode.
    +
    +    OpenCode's OpenAI-compatible endpoints live under ``/v1`` (the OpenAI SDK
    +    appends ``/chat/completions`` or ``/responses``), while the Anthropic SDK
    +    appends its own ``/v1/messages`` — so anthropic_messages needs the ``/v1``
    +    suffix stripped.
    +
    +    Crucially this must be SYMMETRIC.  The stripped URL gets persisted to
    +    config (``model.base_url``) by the TUI/desktop and gateway after switching
    +    into an anthropic-routed model (e.g. minimax-m2.7 on Go).  A later switch
    +    to a chat_completions model (glm, deepseek, kimi) then inherited the
    +    stripped URL and POSTed to ``https://opencode.ai/zen/go/chat/completions``
    +    — a 404 (the marketing site).  Re-append ``/v1`` for non-anthropic modes
    +    so previously-stripped URLs heal themselves.
    +
    +    Only opencode.ai-hosted URLs are re-suffixed; custom proxy overrides via
    +    ``OPENCODE_*_BASE_URL`` are left alone unless they already carry ``/v1``.
    +    """
    +    url = str(base_url or "").strip().rstrip("/")
    +    if not url:
    +        return url
    +    provider = normalize_provider(provider_id)
    +    if provider not in {"opencode-zen", "opencode-go"}:
    +        return url
    +
    +    import re as _re
    +
    +    if api_mode == "anthropic_messages":
    +        return _re.sub(r"/v1$", "", url)
    +
    +    # chat_completions / codex_responses: ensure the /v1 suffix is present on
    +    # official opencode.ai hosts (heals a persisted anthropic-stripped URL).
    +    if url.endswith("/v1"):
    +        return url
    +    try:
    +        host = urllib.parse.urlparse(url).netloc.lower()
    +    except Exception:
    +        host = ""
    +    if host == "opencode.ai" or host.endswith(".opencode.ai"):
    +        return url + "/v1"
    +    return url
    +
    +
     def github_model_reasoning_efforts(
         model_id: Optional[str],
         *,
    ```
  - `hermes_cli/runtime_provider.py`

    ```diff
    @@ -503,11 +503,14 @@ def _resolve_runtime_from_pool_entry(
                     api_mode = detected
     
         # OpenCode base URLs end with /v1 for OpenAI-compatible models, but the
    -    # Anthropic SDK prepends its own /v1/messages to the base_url.  Strip the
    -    # trailing /v1 so the SDK constructs the correct path (e.g.
    -    # https://opencode.ai/zen/go/v1/messages instead of .../v1/v1/messages).
    -    if api_mode == "anthropic_messages" and provider in {"opencode-zen", "opencode-go"}:
    -        base_url = re.sub(r"/v1/?$", "", base_url)
    +    # Anthropic SDK prepends its own /v1/messages to the base_url.  Normalize
    +    # symmetrically: strip /v1 for anthropic_messages, re-append it for
    +    # chat_completions / codex_responses (heals a stripped URL persisted to
    +    # model.base_url by an earlier switch into an anthropic-routed model).
    +    if provider in {"opencode-zen", "opencode-go"}:
    +        from hermes_cli.models import normalize_opencode_base_url
    +
    +        base_url = normalize_opencode_base_url(provider, api_mode, base_url)
     
         # Optional opt-in: route OpenAI/Codex turns through `codex app-server`.
         # Inert when `model.openai_runtime` is unset or "auto".
    ```

    ```diff
    @@ -2025,9 +2028,10 @@ def resolve_runtime_provider(
                     detected = _detect_api_mode_for_url(base_url)
                     if detected:
                         api_mode = detected
    -        # Strip trailing /v1 for OpenCode Anthropic models (see comment above).
    -        if api_mode == "anthropic_messages" and provider in {"opencode-zen", "opencode-go"}:
    -            base_url = re.sub(r"/v1/?$", "", base_url)
    +        # Normalize the /v1 suffix for OpenCode by API mode (see comment above).
    +        if provider in {"opencode-zen", "opencode-go"}:
    +            from hermes_cli.models import normalize_opencode_base_url
    +            base_url = normalize_opencode_base_url(provider, api_mode, base_url)
             if provider == "lmstudio":
                 base_url = auth_mod._normalize_lmstudio_runtime_base_url(base_url)
             return {
    ```
  - `tests/hermes_cli/test_model_validation.py`

    ```diff
    @@ -424,6 +424,9 @@ def test_opencode_zen_api_modes_match_docs(self):
             assert opencode_model_api_mode("opencode-zen", "opencode-zen/claude-sonnet-4-6") == "anthropic_messages"
             assert opencode_model_api_mode("opencode-zen", "gemini-3-flash") == "chat_completions"
             assert opencode_model_api_mode("opencode-zen", "minimax-m2.5") == "chat_completions"
    +        # Qwen on Zen is served via /v1/messages per the Zen endpoint table.
    +        assert opencode_model_api_mode("opencode-zen", "qwen3.7-max") == "anthropic_messages"
    +        assert opencode_model_api_mode("opencode-zen", "qwen3.6-plus") == "anthropic_messages"
     
         def test_opencode_go_api_modes_match_docs(self):
             assert opencode_model_api_mode("opencode-go", "glm-5.1") == "chat_completions"
    ```

    ```diff
    @@ -436,6 +439,80 @@ def test_opencode_go_api_modes_match_docs(self):
             assert opencode_model_api_mode("opencode-go", "opencode-go/minimax-m2.5") == "anthropic_messages"
             assert opencode_model_api_mode("opencode-go", "qwen3.7-max") == "anthropic_messages"
             assert opencode_model_api_mode("opencode-go", "opencode-go/qwen3.7-max") == "anthropic_messages"
    +        # All Qwen models on Go route via /v1/messages (Go endpoint table).
    +        assert opencode_model_api_mode("opencode-go", "qwen3.7-plus") == "anthropic_messages"
    +        assert opencode_model_api_mode("opencode-go", "qwen3.6-plus") == "anthropic_messages"
    +        # DeepSeek / MiMo on Go are OpenAI-compatible chat completions.
    +        assert opencode_model_api_mode("opencode-go", "deepseek-v4-pro") == "chat_completions"
    +        assert opencode_model_api_mode("opencode-go", "deepseek-v4-flash") == "chat_completions"
    +        assert opencode_model_api_mode("opencode-go", "mimo-v2.5") == "chat_completions"
    +        assert opencode_model_api_mode("opencode-go", "kimi-k2.7-code") == "chat_completions"
    +        assert opencode_model_api_mode("opencode-go", "glm-5.2") == "chat_completions"
    +        assert opencode_model_api_mode("opencode-go", "minimax-m3") == "anthropic_messages"
    +
    +
    +class TestNormalizeOpencodeBaseUrl:
    +    """Symmetric /v1 normalization for OpenCode Zen / Go base URLs.
    +
    +    Regression for the 'only minimax works on opencode-go' bug: switching into
    +    an anthropic-routed model strips /v1 from the base URL and that stripped
    +    URL gets persisted to model.base_url; every later chat_completions model
    +    (glm, deepseek, kimi) then POSTed to https://opencode.ai/zen/go/chat/completions
    +    — a 404 (the marketing site).  The normalizer must heal a stripped URL.
    +    """
    +
    +    def test_strips_v1_for_anthropic_messages(self):
    +        from hermes_cli.models import normalize_opencode_base_url
    +        assert normalize_opencode_base_url(
    +            "opencode-go", "anthropic_messages", "https://opencode.ai/zen/go/v1"
    +        ) == "https://opencode.ai/zen/go"
    +        assert normalize_opencode_base_url(
    +            "opencode-zen", "anthropic_messages", "https://opencode.ai/zen/v1/"
    +        ) == "https://opencode.ai/zen"
    +
    +    def test_strip_is_idempotent(self):
    +        from hermes_cli.models import normalize_opencode_base_url
    +        assert normalize_opencode_base_url(
    +            "opencode-go", "anthropic_messages", "https://opencode.ai/zen/go"
    +        ) == "https://opencode.ai/zen/go"
    +
    +    def test_reappends_v1_for_chat_completions(self):
    +        from hermes_cli.models import normalize_opencode_base_url
    +        # The healing case: a stripped URL persisted by a prior anthropic switch.
    +        assert normalize_opencode_base_url(
    +            "opencode-go", "chat_completions", "https://opencode.ai/zen/go"
    +        ) == "https://opencode.ai/zen/go/v1"
    +        assert normalize_opencode_base_url(
    +            "opencode-zen", "codex_responses", "https://opencode.ai/zen"
    +        ) == "https://opencode.ai/zen/v1"
    +
    +    def test_reappend_is_idempotent(self):
    +        from hermes_cli.models import normalize_opencode_base_url
    +        assert normalize_opencode_base_url(
    +            "opencode-go", "chat_completions", "https://opencode.ai/zen/go/v1"
    +        ) == "https://opencode.ai/zen/go/v1"
    +
    +    def test_custom_host_not_suffixed(self):
    +        from hermes_cli.models import normalize_opencode_base_url
    +        # A user's proxy override without /v1 is left alone (we can't know its
    +        # path layout), but the anthropic strip still applies when it has /v1.
    +        assert normalize_opencode_base_url(
    +            "opencode-go", "chat_completions", "https://myproxy.example.com/opencode"
    +        ) == "https://myproxy.example.com/opencode"
    +        assert normalize_opencode_base_url(
    +            "opencode-go", "anthropic_messages", "https://myproxy.example.com/opencode/v1"
    +        ) == "https://myproxy.example.com/opencode"
    +
    +    def test_non_opencode_provider_untouched(self):
    +        from hermes_cli.models import normalize_opencode_base_url
    +        assert normalize_opencode_base_url(
    +            "openrouter", "chat_completions", "https://openrouter.ai/api"
    +        ) == "https://openrouter.ai/api"
    +
    +    def test_empty_url_passthrough(self):
    +        from hermes_cli.models import normalize_opencode_base_url
    +        assert normalize_opencode_base_url("opencode-go", "chat_completions", "") == ""
    +        assert normalize_opencode_base_url("opencode-go", "chat_completions", None) == ""
     
     
     class TestAzureFoundryModelApiMode:
    ```
  - `tests/hermes_cli/test_runtime_provider_resolution.py`

    ```diff
    @@ -1683,6 +1683,34 @@ def test_opencode_go_model_derivation_beats_stale_persisted_api_mode(monkeypatch
         assert resolved["api_mode"] == "anthropic_messages"
     
     
    +def test_opencode_go_heals_persisted_stripped_base_url(monkeypatch):
    +    """A stripped base_url persisted after switching into an anthropic-routed
    +    model (e.g. minimax-m2.7 on Go) must be healed back to .../v1 when the
    +    target model routes via chat_completions.  Without the heal, glm/deepseek/
    +    kimi POST to https://opencode.ai/zen/go/chat/completions — a 404 (the
    +    marketing site).  This was the 'only minimax works on opencode-go' bug.
    +    """
    +    monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-go")
    +    monkeypatch.setattr(
    +        rp,
    +        "_get_model_config",
    +        lambda: {
    +            "provider": "opencode-go",
    +            "default": "glm-5.1",
    +            # Stripped URL persisted by a previous anthropic_messages switch.
    +            "base_url": "https://opencode.ai/zen/go",
    +        },
    +    )
    +    monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-opencode-go-key")
    +    monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
    +
    +    resolved = rp.resolve_runtime_provider(requested="opencode-go")
    +
    +    assert resolved["provider"] == "opencode-go"
    +    assert resolved["api_mode"] == "chat_completions"
    +    assert resolved["base_url"] == "https://opencode.ai/zen/go/v1"
    +
    +
     def test_named_custom_provider_anthropic_api_mode(monkeypatch):
         """Custom providers should accept api_mode: anthropic_messages."""
         monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-anthropic-proxy")
    ```
- **#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`
  - 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": "[redacted:secret-assignment]",
    +    "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": "[redacted:secret-assignment]",
    +                "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`
  - 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`
- **#25138: fix: persist gateway model runtime state**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `51d826f88`
- **#25148: fix(tui): persist api_mode in _persist_model_switch()**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `tests/test_tui_gateway_server.py`, `tui_gateway/server.py`
- **#51488: Clear stale base_url on gateway model switches**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `f6deabca0`, `#53977`, `#49129`
- **#57281: fix(runtime): close opencode-go/zen model-switch 404 on both resolver paths (#57259)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#57585`, `#57259`, `#56157`
- **#59407: fix(agent): prevent stale moa://local base_url from surviving model s…**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `6eb39c2bb`, `#56158`, `#47828`
- **#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 #69818 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-23`.
- Subsystem: `agent`
