Stale base_url/api_mode across provider contexts

NousResearch/hermes-agent · cluster 14879 · agent

Data as of

11 open items. Fix merged in #19623. Check it covers your case.

The short answer

3open issues
8open PRs
46items grouped
Known?
Yes. 46 related items grouped here, 11 issues already closed.
Fix event
A fix was merged to main in PR #19623. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
3 issues and 8 PRs.

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.

Open issues3

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

Open PRs8

  • PR#20563Fixes #20558
  • PR#25148fix(tui): persist api_mode in _persist_model_switch()
  • PR#46853fix(cron): propagate runtime provider config to scheduled jobs
  • PR#54540fix(cli): pass target_model to resolve_runtime_provider in _ensure_runtime_credentials (Fixes #54147)
  • PR#65609fix(cli): resolve runtime using overridden model
  • PR#68403fix(profiles): count global + external skills in _count_skills
  • PR#105869fix(cli): resolve Bedrock wire from the session model at CLI startup
  • PR#113563fix(cli): resolve runtime using overridden model (#65587, salvage #65609)

Fixes on record

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

  • #19623 fix(delegation): pass target_model to resolve_runtime_provider in _resolve_delegation_credentials

    merged to main

    files: tools/delegate_tool.py

    Show the diff
    • tools/delegate_tool.py
      @@ -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}. "
  • #53977 fix(models): opencode aggregator picker catalog + uncap + scoped live-first; clear stale gateway base_url

    merged to main

    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

    Show the diff
    • gateway/slash_commands.py
      @@ -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:
      @@ -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
      @@ -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.
       
      @@ -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
      @@ -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__)
       
       
      @@ -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)
      @@ -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
      @@ -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",
      @@ -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",
      @@ -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",
      @@ -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,
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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:
      @@ -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."""
      @@ -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."
      -        )
      -
  • #66563 fix(model-switch): route reasoning models to Responses API on switch + persist api_mode globally

    merged to main

    files: cli.py, gateway/slash_commands.py, hermes_cli/model_switch.py, hermes_cli/providers.py, tests/gateway/test_25107_stale_base_url_api_mode.py, tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py, tests/hermes_cli/test_model_switch_openai_api_mode.py

    Show the diff
    • cli.py
      @@ -7979,6 +7979,14 @@ def _apply_model_switch_result(self, result, persist_global: bool) -> None:
                   save_config_value("model.default", result.new_model)
                   if result.provider_changed:
                       save_config_value("model.provider", result.target_provider)
      +            # base_url/api_mode were previously never persisted here, so a
      +            # global switch left the OLD provider's endpoint/wire-protocol in
      +            # config.yaml. result.base_url/api_mode are always freshly
      +            # resolved for the target provider (see model_switch.py), so sync
      +            # them every time; None clears a value the new provider doesn't
      +            # need (#25106).
      +            save_config_value("model.base_url", result.base_url or None)
      +            save_config_value("model.api_mode", result.api_mode or None)
                   _cprint("    Saved to config.yaml (--global)")
               else:
                   _cprint("    (session only — add --global to persist)")
      @@ -8291,6 +8299,10 @@ def _handle_model_switch(self, cmd_original: str):
                   save_config_value("model.default", result.new_model)
                   if result.provider_changed:
                       save_config_value("model.provider", result.target_provider)
      +            # See _apply_model_switch_result above for why base_url/api_mode
      +            # must be synced on every global switch (#25106).
      +            save_config_value("model.base_url", result.base_url or None)
      +            save_config_value("model.api_mode", result.api_mode or None)
                   _cprint("    Saved to config.yaml")
               else:
                   _cprint("    (session only — add --global to persist)")
    • gateway/slash_commands.py
      @@ -1723,9 +1723,24 @@ async def _on_model_selected_scoped(
                                           _persist_cfg["model"] = _persist_model_cfg
                                       _persist_model_cfg["default"] = result.new_model
                                       _persist_model_cfg["provider"] = result.target_provider
      +                                # Named providers always resolve base_url/api_mode fresh,
      +                                # so any leftover is cleared unconditionally below. Custom
      +                                # providers have no registry entry to re-derive from, so
      +                                # they need an explicit set-or-clear here — the previous
      +                                # lone `if result.base_url:` left a stale base_url behind
      +                                # when switching to a custom provider whose resolver
      +                                # returned an empty base_url (#25107).
      +                                _is_custom_target = str(result.target_provider or "").strip().lower() == "custom"
                                       if result.base_url:
                                           _persist_model_cfg["base_url"] = result.base_url
      -                                if str(result.target_provider or "").strip().lower() != "custom":
      +                                elif _is_custom_target:
      +                                    _persist_model_cfg.pop("base_url", None)
      +                                if _is_custom_target:
      +                                    if result.api_mode:
      +                                        _persist_model_cfg["api_mode"] = result.api_mode
      +                                    else:
      +                                        _persist_model_cfg.pop("api_mode", None)
      +                                else:
                                           clear_model_endpoint_credentials(_persist_model_cfg, clear_base_url=True)
                                       from hermes_cli.config import save_config
                                       save_config(_persist_cfg)
      @@ -1988,9 +2003,19 @@ async def _finish_switch() -> str:
                               cfg["model"] = model_cfg
                           model_cfg["default"] = result.new_model
                           model_cfg["provider"] = result.target_provider
      +                    # See the picker handler above for why custom providers need an
      +                    # explicit set-or-clear instead of the old lone truthy check (#25107).
      +                    _is_custom_target = str(result.target_provider or "").strip().lower() == "custom"
                           if result.base_url:
                               model_cfg["base_url"] = result.base_url
      -                    if str(result.target_provider or "").strip().lower() != "custom":
      +                    elif _is_custom_target:
      +                        model_cfg.pop("base_url", None)
      +                    if _is_custom_target:
      +                        if result.api_mode:
      +                            model_cfg["api_mode"] = result.api_mode
      +                        else:
      +                            model_cfg.pop("api_mode", None)
      +                    else:
                               clear_model_endpoint_credentials(model_cfg, clear_base_url=True)
                           from hermes_cli.config import save_config
                           save_config(cfg)
    • hermes_cli/model_switch.py
      @@ -30,6 +30,7 @@
           custom_provider_slug,
           determine_api_mode,
           get_label,
      +    host_mandated_api_mode,
           is_aggregator,
           resolve_provider_full,
       )
      @@ -1255,6 +1256,21 @@ def switch_model(
                   if not api_key:
                       [redacted:secret-assignment] = "no-key-required"
       
      +    # --- Resolve api_mode from the final (provider, base_url) before validation ---
      +    # Two cases this closes, both surfaced when the switched model's reasoning
      +    # is actually applied (post the reasoning-unification refactor):
      +    #   1. api_mode empty (e.g. alias cleared it above) → fill from the endpoint.
      +    #   2. api_mode carried a STALE value from the previous session state
      +    #      (e.g. a same-provider /model switch to gpt-5.x on api.openai.com that
      +    #      kept the prior openrouter/chat_completions mode). A host that mandates
      +    #      one wire protocol must override the stale value — otherwise the request
      +    #      goes out on chat_completions and OpenAI 400s on tools+reasoning_effort.
      +    _mandated_mode = host_mandated_api_mode(base_url)
      +    if _mandated_mode is not None:
      +        api_mode = _mandated_mode
      +    elif not api_mode:
      +        api_mode = determine_api_mode(target_provider, base_url)
      +
           # --- Normalize model name for target provider ---
           new_model = normalize_model_for_provider(new_model, target_provider)
       
    • hermes_cli/providers.py
      @@ -549,45 +549,61 @@ def is_routing_aggregator(provider: str) -> bool:
           return is_aggregator(provider_norm)
       
       
      +def host_mandated_api_mode(base_url: str = "") -> Optional[str]:
      +    """Return the wire protocol a specific endpoint *requires*, or None.
      +
      +    Some hosts only accept one API mode and reject the others outright:
      +      - api.openai.com only accepts the Responses API for its (reasoning)
      +        models when tools + reasoning are in play (chat/completions 400s).
      +      - api.anthropic.com / ``…/anthropic`` suffixes speak native Messages.
      +      - Kimi's ``/coding`` endpoint speaks native Messages.
      +      - AWS Bedrock runtime hosts speak Converse.
      +
      +    These are *mandatory* — a session carrying a stale api_mode (e.g. a
      +    /model switch that kept the previous provider's ``chat_completions``)
      +    must be overridden to the host's required mode, not merely filled in
      +    when empty. Generic / unknown endpoints return None so an explicitly
      +    configured api_mode on them is never clobbered.
      +    """
      +    if not base_url:
      +        return None
      +    url_lower = base_url.rstrip("/").lower()
      +    hostname = base_url_hostname(base_url)
      +    # Exact-hostname matching only — never bare substring — so lookalike hosts
      +    # (api.openai.com.attacker.test) and path-segment spoofs
      +    # (proxy.test/api.openai.com/v1) are NOT treated as the real endpoint. (#32243)
      +    if hostname == "api.kimi.com" and "/coding" in url_lower:
      +        return "anthropic_messages"
      +    if hostname == "api.anthropic.com" or url_lower.endswith("/anthropic"):
      +        return "anthropic_messages"
      +    if hostname == "api.openai.com":
      +        return "codex_responses"
      +    if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"):
      +        return "bedrock_converse"
      +    return None
      +
      +
       def determine_api_mode(provider: str, base_url: str = "") -> str:
           """Determine the API mode (wire protocol) for a provider/endpoint.
       
           Resolution order:
      -      1. Known provider → transport → TRANSPORT_TO_API_MODE.
      -      2. URL heuristics for unknown / custom providers.
      -      3. Default: 'chat_completions'.
      +      1. Host-mandated mode (special endpoints that only accept one protocol).
      +      2. Known provider → transport → TRANSPORT_TO_API_MODE.
      +      3. Direct provider checks (bedrock).
      +      4. Default: 'chat_completions'.
           """
      +    mandated = host_mandated_api_mode(base_url)
      +    if mandated is not None:
      +        return mandated
      +
           pdef = get_provider(provider)
           if pdef is not None:
      -        # Even for known providers, check URL heuristics for special endpoints
      -        # (e.g. kimi /coding endpoint needs anthropic_messages even on 'custom')
      -        if base_url:
      -            url_lower = base_url.rstrip("/").lower()
      -            if "api.kimi.com/coding" in url_lower:
      -                return "anthropic_messages"
      -            if url_lower.endswith("/anthropic") or "api.anthropic.com" in url_lower:
      -                return "anthropic_messages"
      -            if "api.openai.com" in url_lower:
      -                return "codex_responses"
               return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions")
       
           # Direct provider checks for providers not in HERMES_OVERLAYS
           if provider == "bedrock":
               return "bedrock_converse"
       
      -    # URL-based heuristics for custom / unknown providers
      -    if base_url:
      -        url_lower = base_url.rstrip("/").lower()
      -        hostname = base_url_hostname(base_url)
      -        if url_lower.endswith("/anthropic") or hostname == "api.anthropic.com":
      -            return "anthropic_messages"
      -        if hostname == "api.kimi.com" and "/coding" in url_lower:
      -            return "anthropic_messages"
      -        if hostname == "api.openai.com":
      -            return "codex_responses"
      -        if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"):
      -            return "bedrock_converse"
      -
           return "chat_completions"
       
       
    • tests/gateway/test_25107_stale_base_url_api_mode.py
      @@ -0,0 +1,194 @@
      +"""Regression tests for #25107: gateway /model switch left a stale
      +``base_url``/never persisted ``api_mode`` in config.yaml when switching to a
      +custom provider whose resolver returned an empty ``base_url``.
      +
      +Root cause: both the picker-tap path (``_on_model_selected`` in
      +``gateway/slash_commands.py``) and the typed ``/model X --global`` path
      +(``_finish_switch``) guarded the persist block with two *independent*
      +``if``s:
      +
      +    if result.base_url:
      +        model_cfg["base_url"] = result.base_url
      +    if target_provider != "custom":
      +        clear_model_endpoint_credentials(model_cfg, clear_base_url=True)
      +
      +For a NAMED provider the second ``if`` always clears any stale value, so the
      +bug was invisible there. But for a ``custom`` provider with an empty
      +resolved ``base_url``, NEITHER branch fires: the old base_url survives
      +untouched, and ``api_mode`` was never written to ``model_cfg`` at all in
      +either branch (only present in the in-memory session override).
      +
      +Fix: an explicit set-or-clear for both fields when the target provider is
      +custom, so a genuine switch always leaves config.yaml coherent with the
      +resolved result.
      +"""
      +
      +import types
      +
      +import yaml
      +import pytest
      +
      +from gateway.config import Platform
      +from gateway.platforms.base import MessageEvent, MessageType
      +from gateway.run import GatewayRunner
      +from gateway.session import SessionSource
      +
      +
      +class _FakePickerAdapter:
      +    def __init__(self):
      +        self.captured_callback = None
      +
      +    async def send_model_picker(self, *, on_model_selected, **kwargs):
      +        self.captured_callback = on_model_selected
      +        return types.SimpleNamespace(success=True)
      +
      +
      +def _make_runner(adapter=None):
      +    runner = object.__new__(GatewayRunner)
      +    runner.adapters = {Platform.TELEGRAM: adapter} if adapter else {}
      +    runner._voice_mode = {}
      +    runner._session_model_overrides = {}
      +    runner._running_agents = {}
      +    return runner
      +
      +
      +def _make_event(text):
      +    return MessageEvent(
      +        text=text,
      +        message_type=MessageType.TEXT,
      +        source=SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"),
      +    )
      +
      +
      +def _fake_switch_result(*, base_url="", api_mode=""):
      +    from hermes_cli.model_switch import ModelSwitchResult
      +
      +    return ModelSwitchResult(
      +        success=True,
      +        new_model="local-llama",
      +        target_provider="custom",
      +        provider_changed=True,
      +        api_key="[redacted:secret-assignment]",
      +        base_url=base_url,
      +        api_mode=api_mode,
      +        provider_label="Custom",
      +        is_global=True,
      +    )
      +
      +
      +def _setup_isolated_home(tmp_path, monkeypatch, model_yaml_value, *, base_url="", api_mode=""):
      +    import gateway.run as gateway_run
      +
      +    hermes_home = tmp_path / ".hermes"
      +    hermes_home.mkdir()
      +    cfg_path = hermes_home / "config.yaml"
      +    cfg_path.write_text(
      +        yaml.safe_dump({"model": model_yaml_value, "providers": {}}),
      +        encoding="utf-8",
      +    )
      +
      +    monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
      +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
      +    monkeypatch.setattr(
      +        "hermes_cli.model_switch.list_picker_providers",
      +        lambda **kw: [{"slug": "custom", "name": "Custom", "models": ["local-llama"]}],
      +    )
      +    monkeypatch.setattr(
      +        "hermes_cli.model_switch.switch_model",
      +        lambda **kw: _fake_switch_result(base_url=base_url, api_mode=api_mode),
      +    )
      +    monkeypatch.setattr(
      +        "hermes_cli.model_switch.resolve_display_context_length",
      +        lambda *a, **k: 8192,
      +    )
      +    monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home)
      +    monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home)
      +    return cfg_path
      +
      +
      +_STALE_MODEL_CFG = {
      +    "default": "old-custom-model",
      +    "provider": "custom",
      +    "base_url": "https://old-stale-endpoint.example/v1",
      +    "api_key": "sk-stale",
      +    "api_mode": "anthropic_messages",
      +}
      +
      +
      +# ---------------------------------------------------------------------------
      +# Typed `/model X --global` path (_finish_switch)
      +# ---------------------------------------------------------------------------
      +
      +
      [redacted:email]
      +async def test_typed_switch_to_custom_clears_stale_base_url_and_api_mode(tmp_path, monkeypatch):
      +    """Switching to a custom provider whose resolver returns no base_url/
      +    api_mode must clear the previous custom endpoint's leftovers, not keep
      +    routing at the old host/protocol (#25107)."""
      +    cfg_path = _setup_isolated_home(tmp_path, monkeypatch, dict(_STALE_MODEL_CFG))
      +
      +    result = await _make_runner()._handle_model_command(
      +        _make_event("/model local-llama --provider custom --global")
      +    )
      +
      +    assert result is not None
      +    written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
      +    assert written["model"]["default"] == "local-llama"
      +    assert "base_url" not in written["model"], (
      +        "stale base_url from the old custom endpoint must be cleared"
      +    )
      +    assert "api_mode" not in written["model"], (
      +        "stale api_mode from the old custom endpoint must be cleared"
      +    )
      +
      +
      [redacted:email]
      +async def test_typed_switch_to_custom_persists_resolved_base_url_and_api_mode(tmp_path, monkeypatch):
      +    """The normal case: a custom-provider switch that DOES resolve a fresh
      +    base_url/api_mode must persist both (api_mode was never written here
      +    before the fix)."""
      +    cfg_path = _setup_isolated_home(
      +        tmp_path,
      +        monkeypatch,
      +        dict(_STALE_MODEL_CFG),
      +        base_url="https://new-endpoint.example/v1",
      +        api_mode="anthropic_messages",
      +    )
      +
      +    result = await _make_runner()._handle_model_command(
      +        _make_event("/model local-llama --provider custom --global")
      +    )
      +
      +    assert result is not None
      +    written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
      +    assert written["model"]["base_url"] == "https://new-endpoint.example/v1"
      +    assert written["model"]["api_mode"] == "anthropic_messages"
      +
      +
      +# ---------------------------------------------------------------------------
      +# Picker-tap path (_on_model_selected)
      +# ---------------------------------------------------------------------------
      +
      +
      +async def _drive_picker(runner, event):
      +    sent = await runner._handle_model_command(event)
      +    assert sent is None
      +    adapter = runner.adapters[Platform.TELEGRAM]
      +    assert adapter.captured_callback is not None, "picker callback was not wired"
      +    return await adapter.captured_callback("12345", "local-llama", "custom")
      +
      +
      [redacted:email]
      +async def test_picker_tap_to_custom_clears_stale_base_url_and_api_mode(tmp_path, monkeypatch):
      +    """Same bug, picker-tap path: tapping a custom-provider model with an
      +    empty resolved base_url must clear the previous custom endpoint (#25107).
      +    """
      +    adapter = _FakePickerAdapter()
      +    cfg_path = _setup_isolated_home(tmp_path, monkeypatch, dict(_STALE_MODEL_CFG))
      +
      +    confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model"))
      +
      +    assert confirmation is not None
      +    written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
      +    assert "base_url" not in written["model"]
      +    assert "api_mode" not in written["model"]
    • tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py
      @@ -0,0 +1,160 @@
      +"""Regression tests for #25106: CLI `/model <name> --global` never persisted
      +``model.base_url``/``model.api_mode`` to config.yaml, so a global provider
      +switch left the PREVIOUS provider's endpoint/wire-protocol on disk. The next
      +`hermes` launch re-read the stale base_url and routed the new model at the
      +old host.
      +
      +Both ``_handle_model_switch`` (typed ``/model <name>``) and
      +``_apply_model_switch_result`` (interactive picker) shared the same gap: the
      +persistence block wrote ``model.default``/``model.provider`` but never
      +touched ``base_url``/``api_mode`` at all. Fix: sync both on every global
      +switch, clearing to ``None`` when the resolved result doesn't need them —
      +mirroring the already-correct ``tui_gateway/server.py:_persist_model_switch``
      +pattern (fixed for #48305).
      +"""
      +
      +from unittest.mock import MagicMock, patch
      +
      +import pytest
      +
      +from hermes_cli.model_switch import ModelSwitchResult
      +
      +
      +def _make_result(*, base_url="https://api.minimax.io/v1", api_mode="chat_completions", provider_changed=True):
      +    return ModelSwitchResult(
      +        success=True,
      +        new_model="MiniMax-M3",
      +        target_provider="custom:minimax",
      +        provider_changed=provider_changed,
      +        api_key="[redacted:secret-assignment]",
      +        base_url=base_url,
      +        api_mode=api_mode,
      +        warning_message="",
      +        provider_label="MiniMax (custom)",
      +        resolved_via_alias=False,
      +        capabilities=None,
      +        model_info=None,
      +        is_global=True,
      +    )
      +
      +
      +class _StubCLI:
      +    """Minimum attrs/methods `_handle_model_switch` reads or calls on self."""
      +
      +    agent = None
      +    model = "old-model"
      +    provider = "copilot"
      +    requested_provider = "copilot"
      +    api_key = "[redacted:secret-assignment]"
      +    base_url = "https://api.githubcopilot.com"
      +    api_mode = "chat_completions"
      +    _explicit_api_key = ""
      +    _explicit_base_url = ""
      +    conversation_history = []
      +    _pending_model_switch_note = ""
      +
      +    def _confirm_expensive_model_switch(self, result) -> bool:
      +        return True
      +
      +    def _open_model_picker(self, *a, **k):
      +        raise AssertionError("picker should not open when a model name is given")
      +
      +
      +def _run_switch(monkeypatch, result, cmd="/model MiniMax-M3 --global"):
      +    import cli as cli_mod
      +
      +    monkeypatch.setattr(cli_mod, "_cprint", lambda *a, **k: None)
      +    saved: dict[str, object] = {}
      +
      +    def _fake_save(key, value):
      +        saved[key] = value
      +
      +    monkeypatch.setattr(cli_mod, "save_config_value", _fake_save)
      +    monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **kw: result)
      +    monkeypatch.setattr(
      +        "hermes_cli.inventory.load_picker_context",
      +        lambda: (_ for _ in ()).throw(RuntimeError("no picker context in test")),
      +    )
      +    cli_mod.HermesCLI._handle_model_switch(_StubCLI(), cmd)
      +    return saved
      +
      +
      +def test_global_switch_persists_base_url_and_api_mode(monkeypatch):
      +    """The core #25106 fix: a --global switch to a new provider/endpoint must
      +    write the newly resolved base_url and api_mode, not just default/provider."""
      +    saved = _run_switch(monkeypatch, _make_result())
      +
      +    assert saved["model.default"] == "MiniMax-M3"
      +    assert saved["model.provider"] == "custom:minimax"
      +    assert saved["model.base_url"] == "https://api.minimax.io/v1"
      +    assert saved["model.api_mode"] == "chat_completions"
      +
      +
      +def test_global_switch_clears_base_url_and_api_mode_when_unresolved(monkeypatch):
      +    """When the resolver returns no base_url/api_mode for the new provider
      +    (e.g. a named provider needing neither), any previous value must be
      +    cleared (None) rather than silently left in config.yaml."""
      +    result = _make_result(base_url="", api_mode="")
      +    saved = _run_switch(monkeypatch, result)
      +
      +    assert saved["model.base_url"] is None
      +    assert saved["model.api_mode"] is None
      +
      +
      +def test_session_only_switch_does_not_touch_config(monkeypatch):
      +    """--session must not call save_config_value at all — persistence stays
      +    entirely in-memory."""
      +    import cli as cli_mod
      +
      +    monkeypatch.setattr(cli_mod, "_cprint", lambda *a, **k: None)
      +    save_calls = []
      +    monkeypatch.setattr(cli_mod, "save_config_value", lambda *a, **k: save_calls.append(a))
      +    monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **kw: _make_result())
      +    monkeypatch.setattr(
      +        "hermes_cli.inventory.load_picker_context",
      +        lambda: (_ for _ in ()).throw(RuntimeError("no picker context in test")),
      +    )
      +
      +    cli_mod.HermesCLI._handle_model_switch(_StubCLI(), "/model MiniMax-M3 --session")
      +
      +    assert save_calls == []
      +
      +
      +def _run_apply(monkeypatch, result, persist_global=True):
      +    """Drives `_apply_model_switch_result` directly — the interactive-picker
      +    sibling of `_handle_model_switch`. Unlike the tests above, and unlike
      +    `test_apply_model_switch_result_context.py` (which only ever calls this
      +    method with `persist_global=False`), this exercises the `persist_global=True`
      +    branch that actually writes to config.yaml."""
      +    import cli as cli_mod
      +
      +    monkeypatch.setattr(cli_mod, "_cprint", lambda *a, **k: None)
      +    saved: dict[str, object] = {}
      +
      +    def _fake_save(key, value):
      +        saved[key] = value
      +
      +    monkeypatch.setattr(cli_mod, "save_config_value", _fake_save)
      +    cli_mod.HermesCLI._apply_model_switch_result(_StubCLI(), result, persist_global)
      +    return saved
      +
      +
      +def test_picker_global_switch_persists_base_url_and_api_mode(monkeypatch):
      +    """Picker-path counterpart of `test_global_switch_persists_base_url_and_api_mode`:
      +    `_apply_model_switch_result(..., persist_global=True)` must sync base_url/api_mode
      +    too, not just default/provider."""
      +    saved = _run_apply(monkeypatch, _make_result())
      +
      +    assert saved["model.default"] == "MiniMax-M3"
      +    assert saved["model.provider"] == "custom:minimax"
      +    assert saved["model.base_url"] == "https://api.minimax.io/v1"
      +    assert saved["model.api_mode"] == "chat_completions"
      +
      +
      +def test_picker_global_switch_clears_base_url_and_api_mode_when_unresolved(monkeypatch):
      +    """Picker-path counterpart of `test_global_switch_clears_base_url_and_api_mode_when_unresolved`."""
      +    result = _make_result(base_url="", api_mode="")
      +    saved = _run_apply(monkeypatch, result)
      +
      +    assert saved["model.base_url"] is None
      +    assert saved["model.api_mode"] is None
    • tests/hermes_cli/test_model_switch_openai_api_mode.py
      @@ -0,0 +1,113 @@
      +"""Regression tests for OpenAI-direct api_mode recomputation during /model switch.
      +
      +api.openai.com only accepts the Responses API (codex_responses) for its
      +reasoning models when tools + reasoning are in play — /v1/chat/completions
      +returns HTTP 400 ("Function tools with reasoning_effort are not supported").
      +
      +When a session switches to a GPT-5.x model on api.openai.com while carrying a
      +stale ``chat_completions`` api_mode from the previous provider (e.g. an
      +openrouter default), the switch must override the stale value with the
      +host-mandated ``codex_responses``. Filling only when empty (the earlier fix)
      +was insufficient: the carried value was a *non-empty but wrong* mode.
      +
      +This surfaced after the reasoning-unification refactor made the switched
      +model's reasoning effort actually apply, turning a silently-dropped
      +reasoning_effort into a live one that OpenAI 400s on the chat_completions path.
      +"""
      +
      +from unittest.mock import patch
      +
      +from hermes_cli.model_switch import switch_model
      +
      +_MOCK_VALIDATION = {
      +    "accepted": True,
      +    "persist": True,
      +    "recognized": True,
      +    "message": None,
      +}
      +
      +
      +def _run_openai_switch(
      +    raw_input: str,
      +    current_provider: str = "openrouter",
      +    current_model: str = "anthropic/claude-opus-4.8",
      +    explicit_provider: str = "openai-api",
      +    runtime_api_mode: str = "chat_completions",
      +    runtime_base_url: str = "https://api.openai.com/v1",
      +):
      +    """Run switch_model with OpenAI-direct mocks and return the result."""
      +    with (
      +        patch("hermes_cli.model_switch.resolve_alias", return_value=None),
      +        patch("hermes_cli.model_switch.list_provider_models", return_value=[]),
      +        patch(
      +            "hermes_cli.runtime_provider.resolve_runtime_provider",
      +            return_value={
      +                "api_key": "sk-test",
      +                "base_url": runtime_base_url,
      +                "api_mode": runtime_api_mode,
      +            },
      +        ),
      +        patch(
      +            "hermes_cli.models.validate_requested_model",
      +            return_value=_MOCK_VALIDATION,
      +        ),
      +        patch("hermes_cli.model_switch.get_model_info", return_value=None),
      +        patch("hermes_cli.model_switch.get_model_capabilities", return_value=None),
      +        patch("hermes_cli.models.detect_provider_for_model", return_value=None),
      +    ):
      +        return switch_model(
      +            raw_input=raw_input,
      +            current_provider=current_provider,
      +            current_model=current_model,
      +            explicit_provider=explicit_provider,
      +        )
      +
      +
      +def test_stale_chat_completions_overridden_on_openai_direct():
      +    """The incident: stale chat_completions on api.openai.com → codex_responses.
      +
      +    Switching from an openrouter/chat_completions session to gpt-5.6-sol on
      +    api.openai.com must flip the wire protocol to the Responses API so tools +
      +    reasoning are preserved, instead of 400ing on chat/completions.
      +    """
      +    result = _run_openai_switch(
      +        raw_input="gpt-5.6-sol",
      +        current_provider="openrouter",
      +        current_model="anthropic/claude-opus-4.8",
      +        explicit_provider="openai-api",
      +        runtime_api_mode="chat_completions",  # stale value carried over
      +    )
      +
      +    assert result.success, f"switch_model failed: {result.error_message}"
      +    assert result.target_provider == "openai-api"
      +    assert result.new_model == "gpt-5.6-sol"
      +    assert result.api_mode == "codex_responses"
      +
      +
      +def test_empty_api_mode_filled_on_openai_direct():
      +    """An empty runtime api_mode on api.openai.com resolves to codex_responses."""
      +    result = _run_openai_switch(
      +        raw_input="gpt-5.6-sol",
      +        runtime_api_mode="",  # empty — the earlier fill-if-empty subcase
      +    )
      +
      +    assert result.success, f"switch_model failed: {result.error_message}"
      +    assert result.api_mode == "codex_responses"
      +
      +
      +def test_generic_endpoint_keeps_explicit_api_mode():
      +    """A generic (non-host-mandated) endpoint must NOT have its api_mode clobbered.
      +
      +    Only hosts that mandate one wire protocol override a carried value; a
      +    generic OpenAI-compatible relay returns None from host_mandated_api_mode,
      +    so the switch path leaves the resolver's api_mode untouched.
      +    """
      +    from hermes_cli.providers import host_mandated_api_mode
      +
      +    assert host_mandated_api_mode("https://generic.example.com/v1") is None
      +    # Lookalike / path-spoof hosts must also NOT be treated as mandated (#32243).
      +    assert host_mandated_api_mode("https://api.openai.com.attacker.test/v1") is None
      +    assert host_mandated_api_mode("https://proxy.test/api.openai.com/v1") is None
      +    # The real endpoints are mandated.
      +    assert host_mandated_api_mode("https://api.openai.com/v1") == "codex_responses"
      +    assert host_mandated_api_mode("https://api.anthropic.com") == "anthropic_messages"
  • #69818 fix(cli): persist provider on global model switch

    merged to main

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

20 PRs closed without merging

From the close comments: done elsewhere 8 · superseded by another PR 6 · merged via another change 3 · duplicate 2 · other 1

  • #14919 fix(model): only strip /v1 from OpenCode URLs on fresh provider resolution · other → #4508, #14879
  • #15320 fix(delegation): pass target_model to resolve_runtime_provider in _resolve_delegation_credentials · merged via another change → #19623
  • #15447 fix(tools): pass target_model to resolve_runtime_provider in delegation (#15319) · done elsewhere → 83bbe9b
  • #18605 fix: pass target_model to resolve_runtime_provider in delegation · done elsewhere
  • #18799 fix(delegation): pass target_model when resolving opencode-go provider · done elsewhere
Show 15 more
  • #25131 fix(agent): persist complete model runtime state on /model switch · superseded by another PR → 398634e, #25106, #69818
  • #25138 fix: persist gateway model runtime state · superseded by another PR → 51d826f
  • #45244 fix(cron): pass target_model to resolve_runtime_provider · superseded by another PR → #78427
  • #45248 fix(cron): pass target_model to resolve_runtime_provider for pinned-model jobs · duplicate → #45244
  • #45294 fix(cron): pass target_model to resolve_runtime_provider in run_job · duplicate → #45244
  • #45338 fix(cron): pass target_model to resolve_runtime_provider (#45245) · superseded by another PR → #45244, #45245
  • #50578 fix(bedrock): route non-Claude Bedrock models off the Anthropic SDK · done elsewhere → 284d06c, #51933
  • #51488 Clear stale base_url on gateway model switches · merged via another change → f6deabc, #53977, #49129
  • #54148 fix(cli): pass target_model to resolve_runtime_provider in _ensure_runtime_credentials · done elsewhere
  • #54365 fix: pass target_model to resolve_runtime_provider in _ensure_runtime_credentials (#54147) · superseded by another PR → #54148
  • #56249 fix(gateway): don't persist base_url for non-custom providers on /model switch · superseded by another PR → f6deabc
  • #59407 fix(agent): prevent stale moa://local base_url from surviving model s… · done elsewhere → 6eb39c2, #56158, #47828
  • #60970 fix(cli,gateway): sync base_url/api_mode on global model switch persist · merged via another change → 398634e, e7f208f, #66563, #25106, #25107
  • #68763 fix(cron): pass target_model to resolve_runtime_provider to prevent base_url mismatch · done elsewhere → 786df3c
  • #68846 fix(cron): pass target_model to resolve_runtime_provider to prevent base_url mismatch · done elsewhere → 786df3c, #68403
Closed issues · 11
  • Closed#14879[Bug] /model command strips /v1 from OpenCode Go endpoint when switching from MiniMax provider
  • Closed#15319delegate_task 404 when delegation model differs from main model on opencode-go/opencode-zen
  • Closed#18586Bug Report: `delegate_task` resolves wrong `api_mode`/`base_url` for opencode-go when `target_model` is omitted
  • Closed#20558[Bug]: Delegation subagents fail to resolve credentials and hit 404s on mixed-model providers (OpenCode)
  • Closed#25107[Bug]: Gateway /model switch leaves stale base_url and does not persist api_mode
  • Closed#30944bug: /model TUI picker silently routes Bedrock Claude models through wrong API (bedrock_converse instead of anthropic_messages)
  • Closed#43946[Bug]: Non-Claude Bedrock models fail with malformed tool/message payloads (Kimi, DeepSeek, Amazon Nova)
  • Closed#51486Gateway model switches persist stale base_url for built-in providers
  • Closed#54147[Bug]: `hermes chat -m <model>` uses stale api_mode from config default → 404 on opencode-go
  • Closed#56158[Bug]: /model switch from Telegram persists stale base_url → 404 on opencode-go chat_completions models
  • Closed#68762Cron jobs fail with HTTP 401 when config.yaml model.default is switched to an anthropic_messages-routed model (e.g. qwen on opencode-go)
How this cluster was grouped
  • Link evidence (partly structural links): 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 item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

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

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#19623, #53977, #66563, #69818
Closed issues
11
Attempts
20 PRs closed without merging
Fix link checked
23 Jul 2026

For your agent

The same facts, typed.