# NousResearch/hermes-agent · Stale base_url/api_mode across provider contexts

## Open now

- 3 issues, 8 PRs open on GitHub.
- #45245: Cron scheduler omits target_model when resolving runtime provider — same root cause as #18586
- #50292: fix(bedrock): non-Claude models routed through Anthropic SDK when config default is Claude
- #65587: Bug: chat -q model override does not re-resolve provider transport
- PR #20563: Fixes #20558
- PR #25148: fix(tui): persist api_mode in _persist_model_switch()
- PR #46853: fix(cron): propagate runtime provider config to scheduled jobs
- PR #54540: fix(cli): pass target_model to resolve_runtime_provider in _ensure_runtime_credentials (Fixes #54147)
- PR #65609: fix(cli): resolve runtime using overridden model
- PR #68403: fix(profiles): count global + external skills in _count_skills
- PR #105869: fix(cli): resolve Bedrock wire from the session model at CLI startup
- PR #113563: fix(cli): resolve runtime using overridden model (#65587, salvage #65609)

These issues and patches center on provider transport configuration (base_url, api_mode) being drawn from outdated defaults rather than the actual target model, affecting delegation, cron jobs, CLI model switching, and gateway routing across multiple providers including OpenCode and Bedrock.

## TL;DR

- 46 member(s): 3 open issue(s), 11 closed issue(s), 8 open PR(s), 4 merged PR(s), 20 PR(s) closed without merging.
- A fix was merged to main in PR #19623. Verify it addresses your case.
- **Recommended:** read PR #19623 (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.04`: 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.

## Open related issues

- #45245: Cron scheduler omits target_model when resolving runtime provider — same root cause as #18586
- #50292: fix(bedrock): non-Claude models routed through Anthropic SDK when config default is Claude
- #65587: Bug: chat -q model override does not re-resolve provider transport

## Closed / duplicate issues

- #14879: \[Bug\] /model command strips /v1 from OpenCode Go endpoint when switching from MiniMax provider
- #15319: delegate_task 404 when delegation model differs from main model on opencode-go/opencode-zen
- #18586: Bug Report: \`delegate_task\` resolves wrong \`api_mode\`/\`base_url\` for opencode-go when \`target_model\` is omitted
- #20558: \[Bug\]: Delegation subagents fail to resolve credentials and hit 404s on mixed-model providers (OpenCode)
- #25107: \[Bug\]: Gateway /model switch leaves stale base_url and does not persist api_mode
- #30944: bug: /model TUI picker silently routes Bedrock Claude models through wrong API (bedrock_converse instead of anthropic_messages)
- #43946: \[Bug\]: Non-Claude Bedrock models fail with malformed tool/message payloads (Kimi, DeepSeek, Amazon Nova)
- #51486: Gateway model switches persist stale base_url for built-in providers
- #54147: \[Bug\]: \`hermes chat -m <model>\` uses stale api_mode from config default → 404 on opencode-go
- #56158: \[Bug\]: /model switch from Telegram persists stale base_url → 404 on opencode-go chat_completions models
- #68762: Cron jobs fail with HTTP 401 when config.yaml model.default is switched to an anthropic_messages-routed model (e.g. qwen on opencode-go)

## Prior & competing PRs

- **#18605: fix: pass target_model to resolve_runtime_provider in delegation**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#18799: fix(delegation): pass target_model when resolving opencode-go provider**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#19623: fix(delegation): pass target_model to resolve_runtime_provider in _resolve_delegation_credentials**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `tools/delegate_tool.py`
  - fix hunks:
  - `tools/delegate_tool.py`

    ```diff
    @@ -2299,7 +2299,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
         try:
             from hermes_cli.runtime_provider import resolve_runtime_provider
     
    -        runtime = resolve_runtime_provider(requested=configured_provider)
    +        runtime = resolve_runtime_provider(requested=configured_provider, target_model=configured_model)
         except Exception as exc:
             raise ValueError(
                 f"Cannot resolve delegation provider '{configured_provider}': {exc}. "
    ```
- **#45338: fix(cron): pass target_model to resolve_runtime_provider (#45245)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#45244`, `#45245`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#50578: fix(bedrock): route non-Claude Bedrock models off the Anthropic SDK**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `284d06cabfb5f19051c83320115fde74d9e344c1`, `#51933`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#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`; implication `superseded`
  - 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": "test-zen-key",
    +            "DEEPSEEK_API_KEY": "test-deepseek-key",
    +        },
    +        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._
- **#66563: fix(model-switch): route reasoning models to Responses API on switch + persist api_mode globally**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `gateway/slash_commands.py`, `hermes_cli/model_switch.py`, `hermes_cli/providers.py`, `tests/gateway/test_25107_stale_base_url_api_mode.py`, `tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py`, `tests/hermes_cli/test_model_switch_openai_api_mode.py`
  - fix hunks:
  - `cli.py`

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

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

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

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

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

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

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

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

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

    ```diff
    @@ -0,0 +1,113 @@
    +"""Regression tests for OpenAI-direct api_mode recomputation during /model switch.
    +
    +api.openai.com only accepts the Responses API (codex_responses) for its
    +reasoning models when tools + reasoning are in play — /v1/chat/completions
    +returns HTTP 400 ("Function tools with reasoning_effort are not supported").
    +
    +When a session switches to a GPT-5.x model on api.openai.com while carrying a
    +stale ``chat_completions`` api_mode from the previous provider (e.g. an
    +openrouter default), the switch must override the stale value with the
    +host-mandated ``codex_responses``. Filling only when empty (the earlier fix)
    +was insufficient: the carried value was a *non-empty but wrong* mode.
    +
    +This surfaced after the reasoning-unification refactor made the switched
    +model's reasoning effort actually apply, turning a silently-dropped
    +reasoning_effort into a live one that OpenAI 400s on the chat_completions path.
    +"""
    +
    +from unittest.mock import patch
    +
    +from hermes_cli.model_switch import switch_model
    +
    +_MOCK_VALIDATION = {
    +    "accepted": True,
    +    "persist": True,
    +    "recognized": True,
    +    "message": None,
    +}
    +
    +
    +def _run_openai_switch(
    +    raw_input: str,
    +    current_provider: str = "openrouter",
    +    current_model: str = "anthropic/claude-opus-4.8",
    +    explicit_provider: str = "openai-api",
    +    runtime_api_mode: str = "chat_completions",
    +    runtime_base_url: str = "https://api.openai.com/v1",
    +):
    +    """Run switch_model with OpenAI-direct mocks and return the result."""
    +    with (
    +        patch("hermes_cli.model_switch.resolve_alias", return_value=None),
    +        patch("hermes_cli.model_switch.list_provider_models", return_value=[]),
    +        patch(
    +            "hermes_cli.runtime_provider.resolve_runtime_provider",
    +            return_value={
    +                "api_key": "sk-test",
    +                "base_url": runtime_base_url,
    +                "api_mode": runtime_api_mode,
    +            },
    +        ),
    +        patch(
    +            "hermes_cli.models.validate_requested_model",
    +            return_value=_MOCK_VALIDATION,
    +        ),
    +        patch("hermes_cli.model_switch.get_model_info", return_value=None),
    +        patch("hermes_cli.model_switch.get_model_capabilities", return_value=None),
    +        patch("hermes_cli.models.detect_provider_for_model", return_value=None),
    +    ):
    +        return switch_model(
    +            raw_input=raw_input,
    +            current_provider=current_provider,
    +            current_model=current_model,
    +            explicit_provider=explicit_provider,
    +        )
    +
    +
    +def test_stale_chat_completions_overridden_on_openai_direct():
    +    """The incident: stale chat_completions on api.openai.com → codex_responses.
    +
    +    Switching from an openrouter/chat_completions session to gpt-5.6-sol on
    +    api.openai.com must flip the wire protocol to the Responses API so tools +
    +    reasoning are preserved, instead of 400ing on chat/completions.
    +    """
    +    result = _run_openai_switch(
    +        raw_input="gpt-5.6-sol",
    +        current_provider="openrouter",
    +        current_model="anthropic/claude-opus-4.8",
    +        explicit_provider="openai-api",
    +        runtime_api_mode="chat_completions",  # stale value carried over
    +    )
    +
    +    assert result.success, f"switch_model failed: {result.error_message}"
    +    assert result.target_provider == "openai-api"
    +    assert result.new_model == "gpt-5.6-sol"
    +    assert result.api_mode == "codex_responses"
    +
    +
    +def test_empty_api_mode_filled_on_openai_direct():
    +    """An empty runtime api_mode on api.openai.com resolves to codex_responses."""
    +    result = _run_openai_switch(
    +        raw_input="gpt-5.6-sol",
    +        runtime_api_mode="",  # empty — the earlier fill-if-empty subcase
    +    )
    +
    +    assert result.success, f"switch_model failed: {result.error_message}"
    +    assert result.api_mode == "codex_responses"
    +
    +
    +def test_generic_endpoint_keeps_explicit_api_mode():
    +    """A generic (non-host-mandated) endpoint must NOT have its api_mode clobbered.
    +
    +    Only hosts that mandate one wire protocol override a carried value; a
    +    generic OpenAI-compatible relay returns None from host_mandated_api_mode,
    +    so the switch path leaves the resolver's api_mode untouched.
    +    """
    +    from hermes_cli.providers import host_mandated_api_mode
    +
    +    assert host_mandated_api_mode("https://generic.example.com/v1") is None
    +    # Lookalike / path-spoof hosts must also NOT be treated as mandated (#32243).
    +    assert host_mandated_api_mode("https://api.openai.com.attacker.test/v1") is None
    +    assert host_mandated_api_mode("https://proxy.test/api.openai.com/v1") is None
    +    # The real endpoints are mandated.
    +    assert host_mandated_api_mode("https://api.openai.com/v1") == "codex_responses"
    +    assert host_mandated_api_mode("https://api.anthropic.com") == "anthropic_messages"
    ```
- **#69818: fix(cli): persist provider on global model switch**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py`
- **#14919: fix(model): only strip /v1 from OpenCode URLs on fresh provider resolution**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#4508`, `#14879`, `#14879`, `#4508`
- **#15320: fix(delegation): pass target_model to resolve_runtime_provider in _resolve_delegation_credentials**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#19623`
- **#15447: fix(tools): pass target_model to resolve_runtime_provider in delegation (#15319)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `83bbe9b458cc19dc7d12c5dbf4e8e68ff37659b2`
- **#20563: Fixes #20558**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `f0e46c5e9e8d4f780561554684e33810fc4f2f8f`
  - files: `hermes_cli/auth.py`, `tools/delegate_tool.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`
- **#45244: fix(cron): pass target_model to resolve_runtime_provider**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#78427`
- **#45248: fix(cron): pass target_model to resolve_runtime_provider for pinned-model jobs**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#45244`
- **#45294: fix(cron): pass target_model to resolve_runtime_provider in run_job**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#45244`
- **#46853: fix(cron): propagate runtime provider config to scheduled jobs**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - files: `cron/scheduler.py`, `tests/cron/test_scheduler.py`
- **#51488: Clear stale base_url on gateway model switches**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `f6deabca0`, `#53977`, `#49129`
- **#54148: fix(cli): pass target_model to resolve_runtime_provider in _ensure_runtime_credentials**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#54365: fix: pass target_model to resolve_runtime_provider in _ensure_runtime_credentials (#54147)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#54148`
- **#54540: fix(cli): pass target_model to resolve_runtime_provider in _ensure_runtime_credentials (Fixes #54147)**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#54148`, `#54147`, `#54365`, `#54148`
  - files: `hermes_cli/cli_agent_setup_mixin.py`
- **#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`
- **#65609: fix(cli): resolve runtime using overridden model**. open
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `90f0a690ace1`, `#65587`, `#65609`, `#65609`, `#65609`, `#374151`, `#65587`, `#65609`
  - files: `hermes_cli/cli_agent_setup_mixin.py`, `tests/hermes_cli/test_cli_runtime_target_model.py`
- **#68403: fix(profiles): count global + external skills in _count_skills**. open
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `98b0d653954d`, `#68403`, `#68403`, `#75798`, `#68403`, `#68403`, `#75798`, `#68403`, `#75798`, `#68403`, `#68403`, `#75798`, `#68403`, `#75798`, `#68403`, `#374151`, `#68403`, `#75798`
  - files: `hermes_cli/profiles.py`
- **#68763: fix(cron): pass target_model to resolve_runtime_provider to prevent base_url mismatch**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `786df3ca6cf3fa063391491caa63d7ffad17e286`
- **#68846: fix(cron): pass target_model to resolve_runtime_provider to prevent base_url mismatch**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `786df3ca6cf3fa063391491caa63d7ffad17e286`, `#68403`
- **#105869: fix(cli): resolve Bedrock wire from the session model at CLI startup**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `0d07248d9`, `284d06cab`, `#50292`
  - files: `hermes_cli/cli_agent_setup_mixin.py`, `tests/hermes_cli/test_cli_bedrock_session_model_wire.py`
- **#113563: fix(cli): resolve runtime using overridden model (#65587, salvage #65609)**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `contributors/emails/[redacted:email]`, `contributors/emails/[redacted:email]`, `hermes_cli/cli_agent_setup_mixin.py`, `tests/hermes_cli/test_cli_runtime_target_model.py`

## Fix status

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