Stale base_url and api_mode persisted on model switches
Cluster 25107 · agent. Data as of
1 open item. Fix merged in #69818 on 23 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 16 related items grouped here, 4 issues already closed.
- Fix event
- A fix was merged to main in PR #69818. 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
- 0 issues and 1 PR.
Most members concern model-switch flows (/model in gateway, CLI, TUI) that persist stale or incomplete runtime state such as base_url, api_mode, or provider, with reports of 404s on opencode-go models. One member about an opencode aggregator picker catalog is adjacent, and the exact causes and fix status are not established here.
Open issues0
Open PRs1
- PR#25148fix(tui): persist api_mode in _persist_model_switch()
Fixes on record
-
#53977 fix(models): opencode aggregator picker catalog + uncap + scoped live-first; clear stale gateway base_url
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.pyShow 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": "[redacted:secret-assignment]", + "DEEPSEEK_API_KEY": "[redacted:secret-assignment]", + }, + clear=False, + ): + providers = list_authenticated_providers(max_models=50) + + opencode_zen = next(p for p in providers if p["slug"] == "opencode-zen") + deepseek = next(p for p in providers if p["slug"] == "deepseek") + + assert opencode_zen["models"] == zen_models + assert opencode_zen["total_models"] == len(zen_models) + assert deepseek["models"] == deepseek_models[:50] + assert deepseek["total_models"] == len(deepseek_models) -
tests/hermes_cli/test_provider_live_curated_merge.py@@ -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." - ) -
-
-
#57585 fix(opencode-go): heal stripped /v1 base_url so non-minimax models stop 404ing
files:
hermes_cli/model_switch.py,hermes_cli/models.py,hermes_cli/runtime_provider.py,tests/hermes_cli/test_model_validation.py,tests/hermes_cli/test_runtime_provider_resolution.pyShow the diff
-
hermes_cli/model_switch.py@@ -1303,20 +1303,19 @@ def switch_model( api_mode = determine_api_mode(target_provider, base_url) # OpenCode base URLs end with /v1 for OpenAI-compatible models, but the - # Anthropic SDK prepends its own /v1/messages to the base_url. Strip the - # trailing /v1 so the SDK constructs the correct path (e.g. - # https://opencode.ai/zen/go/v1/messages instead of .../v1/v1/messages). - # Mirrors the same logic in hermes_cli.runtime_provider.resolve_runtime_provider; - # without it, /model switches into an anthropic_messages-routed OpenCode - # model (e.g. `/model minimax-m2.7` on opencode-go, `/model claude-sonnet-4-6` - # on opencode-zen) hit a double /v1 and returned OpenCode's website 404 page. - if ( - api_mode == "anthropic_messages" - and target_provider in {"opencode-zen", "opencode-go"} - and isinstance(base_url, str) - and base_url - ): - base_url = re.sub(r"/v1/?$", "", base_url) + # Anthropic SDK prepends its own /v1/messages to the base_url. Normalize + # symmetrically (strip /v1 for anthropic_messages, re-append it for + # chat_completions / codex_responses). Mirrors the same logic in + # hermes_cli.runtime_provider.resolve_runtime_provider; without the strip, + # /model switches into an anthropic_messages-routed OpenCode model + # (e.g. `/model minimax-m2.7` on opencode-go, `/model claude-sonnet-4-6` + # on opencode-zen) hit a double /v1 and returned OpenCode's website 404 + # page — and without the re-append, a stripped URL persisted to + # model.base_url broke every later chat_completions model (glm, deepseek, + # kimi) the same way. + if target_provider in {"opencode-zen", "opencode-go"} and isinstance(base_url, str): + from hermes_cli.models import normalize_opencode_base_url + base_url = normalize_opencode_base_url(target_provider, api_mode, base_url) # --- Get capabilities (legacy) --- capabilities = get_model_capabilities(target_provider, new_model) -
hermes_cli/models.py@@ -415,14 +415,18 @@ def _xai_curated_models() -> list[str]: "gemini-3.5-flash", "gemini-3.1-pro", "gemini-3-flash", + "minimax-m3", "minimax-m2.7", "minimax-m2.5", "minimax-m3-free", + "glm-5.2", "glm-5.1", "glm-5", + "kimi-k2.7-code", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-free", + "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-plus-free", "qwen3.5-plus", @@ -433,17 +437,23 @@ def _xai_curated_models() -> list[str]: "nemotron-3-ultra-free", ], "opencode-go": [ + "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", + "glm-5.2", "glm-5.1", "glm-5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", + "minimax-m3", "minimax-m2.7", "minimax-m2.5", + "deepseek-v4-pro", + "deepseek-v4-flash", "qwen3.7-max", + "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", ], @@ -3373,12 +3383,14 @@ def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) - GPT-5 / Codex models on Zen use ``/v1/responses`` - Claude models on Zen use ``/v1/messages`` - - MiniMax models on Go use ``/v1/messages`` - - GLM / Kimi on Go use ``/v1/chat/completions`` - - Other Zen models (Gemini, GLM, Kimi, MiniMax, Qwen, etc.) use + - MiniMax and Qwen models on Go use ``/v1/messages`` + - GLM / Kimi / DeepSeek / MiMo on Go use ``/v1/chat/completions`` + - Qwen models on Zen use ``/v1/messages`` + - Other Zen models (Gemini, GLM, Kimi, MiniMax, DeepSeek, etc.) use ``/v1/chat/completions`` - This follows the published OpenCode docs for Zen and Go endpoints. + This follows the published OpenCode docs for Zen and Go endpoints + (https://opencode.ai/docs/zen/ and https://opencode.ai/docs/go/). """ provider = normalize_provider(provider_id) normalized = normalize_opencode_model_id(provider_id, model_id).lower() @@ -3388,7 +3400,9 @@ def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) if provider == "opencode-go": if normalized.startswith("minimax-"): return "anthropic_messages" - if normalized.startswith("qwen3.7-max"): + if normalized.startswith("qwen"): + # All Qwen models on Go (qwen3.7-max, qwen3.7-plus, qwen3.6-plus) + # are served via /v1/messages per the published Go endpoint table. return "anthropic_messages" return "chat_completions" @@ -3397,11 +3411,61 @@ def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) return "anthropic_messages" if normalized.startswith("gpt-"): return "codex_responses" + if normalized.startswith("qwen"): + # Qwen models on Zen moved to /v1/messages per the published + # Zen endpoint table. + return "anthropic_messages" return "chat_completions" return "chat_completions" +def normalize_opencode_base_url( + provider_id: Optional[str], api_mode: Optional[str], base_url: Optional[str] +) -> str: + """Normalize an OpenCode Zen / Go base URL for the target API mode. + + OpenCode's OpenAI-compatible endpoints live under ``/v1`` (the OpenAI SDK + appends ``/chat/completions`` or ``/responses``), while the Anthropic SDK + appends its own ``/v1/messages`` — so anthropic_messages needs the ``/v1`` + suffix stripped. + + Crucially this must be SYMMETRIC. The stripped URL gets persisted to + config (``model.base_url``) by the TUI/desktop and gateway after switching + into an anthropic-routed model (e.g. minimax-m2.7 on Go). A later switch + to a chat_completions model (glm, deepseek, kimi) then inherited the + stripped URL and POSTed to ``https://opencode.ai/zen/go/chat/completions`` + — a 404 (the marketing site). Re-append ``/v1`` for non-anthropic modes + so previously-stripped URLs heal themselves. + + Only opencode.ai-hosted URLs are re-suffixed; custom proxy overrides via + ``OPENCODE_*_BASE_URL`` are left alone unless they already carry ``/v1``. + """ + url = str(base_url or "").strip().rstrip("/") + if not url: + return url + provider = normalize_provider(provider_id) + if provider not in {"opencode-zen", "opencode-go"}: + return url + + import re as _re + + if api_mode == "anthropic_messages": + return _re.sub(r"/v1$", "", url) + + # chat_completions / codex_responses: ensure the /v1 suffix is present on + # official opencode.ai hosts (heals a persisted anthropic-stripped URL). + if url.endswith("/v1"): + return url + try: + host = urllib.parse.urlparse(url).netloc.lower() + except Exception: + host = "" + if host == "opencode.ai" or host.endswith(".opencode.ai"): + return url + "/v1" + return url + + def github_model_reasoning_efforts( model_id: Optional[str], *, -
hermes_cli/runtime_provider.py@@ -503,11 +503,14 @@ def _resolve_runtime_from_pool_entry( api_mode = detected # OpenCode base URLs end with /v1 for OpenAI-compatible models, but the - # Anthropic SDK prepends its own /v1/messages to the base_url. Strip the - # trailing /v1 so the SDK constructs the correct path (e.g. - # https://opencode.ai/zen/go/v1/messages instead of .../v1/v1/messages). - if api_mode == "anthropic_messages" and provider in {"opencode-zen", "opencode-go"}: - base_url = re.sub(r"/v1/?$", "", base_url) + # Anthropic SDK prepends its own /v1/messages to the base_url. Normalize + # symmetrically: strip /v1 for anthropic_messages, re-append it for + # chat_completions / codex_responses (heals a stripped URL persisted to + # model.base_url by an earlier switch into an anthropic-routed model). + if provider in {"opencode-zen", "opencode-go"}: + from hermes_cli.models import normalize_opencode_base_url + + base_url = normalize_opencode_base_url(provider, api_mode, base_url) # Optional opt-in: route OpenAI/Codex turns through `codex app-server`. # Inert when `model.openai_runtime` is unset or "auto". @@ -2025,9 +2028,10 @@ def resolve_runtime_provider( detected = _detect_api_mode_for_url(base_url) if detected: api_mode = detected - # Strip trailing /v1 for OpenCode Anthropic models (see comment above). - if api_mode == "anthropic_messages" and provider in {"opencode-zen", "opencode-go"}: - base_url = re.sub(r"/v1/?$", "", base_url) + # Normalize the /v1 suffix for OpenCode by API mode (see comment above). + if provider in {"opencode-zen", "opencode-go"}: + from hermes_cli.models import normalize_opencode_base_url + base_url = normalize_opencode_base_url(provider, api_mode, base_url) if provider == "lmstudio": base_url = auth_mod._normalize_lmstudio_runtime_base_url(base_url) return { -
tests/hermes_cli/test_model_validation.py@@ -424,6 +424,9 @@ def test_opencode_zen_api_modes_match_docs(self): assert opencode_model_api_mode("opencode-zen", "opencode-zen/claude-sonnet-4-6") == "anthropic_messages" assert opencode_model_api_mode("opencode-zen", "gemini-3-flash") == "chat_completions" assert opencode_model_api_mode("opencode-zen", "minimax-m2.5") == "chat_completions" + # Qwen on Zen is served via /v1/messages per the Zen endpoint table. + assert opencode_model_api_mode("opencode-zen", "qwen3.7-max") == "anthropic_messages" + assert opencode_model_api_mode("opencode-zen", "qwen3.6-plus") == "anthropic_messages" def test_opencode_go_api_modes_match_docs(self): assert opencode_model_api_mode("opencode-go", "glm-5.1") == "chat_completions" @@ -436,6 +439,80 @@ def test_opencode_go_api_modes_match_docs(self): assert opencode_model_api_mode("opencode-go", "opencode-go/minimax-m2.5") == "anthropic_messages" assert opencode_model_api_mode("opencode-go", "qwen3.7-max") == "anthropic_messages" assert opencode_model_api_mode("opencode-go", "opencode-go/qwen3.7-max") == "anthropic_messages" + # All Qwen models on Go route via /v1/messages (Go endpoint table). + assert opencode_model_api_mode("opencode-go", "qwen3.7-plus") == "anthropic_messages" + assert opencode_model_api_mode("opencode-go", "qwen3.6-plus") == "anthropic_messages" + # DeepSeek / MiMo on Go are OpenAI-compatible chat completions. + assert opencode_model_api_mode("opencode-go", "deepseek-v4-pro") == "chat_completions" + assert opencode_model_api_mode("opencode-go", "deepseek-v4-flash") == "chat_completions" + assert opencode_model_api_mode("opencode-go", "mimo-v2.5") == "chat_completions" + assert opencode_model_api_mode("opencode-go", "kimi-k2.7-code") == "chat_completions" + assert opencode_model_api_mode("opencode-go", "glm-5.2") == "chat_completions" + assert opencode_model_api_mode("opencode-go", "minimax-m3") == "anthropic_messages" + + +class TestNormalizeOpencodeBaseUrl: + """Symmetric /v1 normalization for OpenCode Zen / Go base URLs. + + Regression for the 'only minimax works on opencode-go' bug: switching into + an anthropic-routed model strips /v1 from the base URL and that stripped + URL gets persisted to model.base_url; every later chat_completions model + (glm, deepseek, kimi) then POSTed to https://opencode.ai/zen/go/chat/completions + — a 404 (the marketing site). The normalizer must heal a stripped URL. + """ + + def test_strips_v1_for_anthropic_messages(self): + from hermes_cli.models import normalize_opencode_base_url + assert normalize_opencode_base_url( + "opencode-go", "anthropic_messages", "https://opencode.ai/zen/go/v1" + ) == "https://opencode.ai/zen/go" + assert normalize_opencode_base_url( + "opencode-zen", "anthropic_messages", "https://opencode.ai/zen/v1/" + ) == "https://opencode.ai/zen" + + def test_strip_is_idempotent(self): + from hermes_cli.models import normalize_opencode_base_url + assert normalize_opencode_base_url( + "opencode-go", "anthropic_messages", "https://opencode.ai/zen/go" + ) == "https://opencode.ai/zen/go" + + def test_reappends_v1_for_chat_completions(self): + from hermes_cli.models import normalize_opencode_base_url + # The healing case: a stripped URL persisted by a prior anthropic switch. + assert normalize_opencode_base_url( + "opencode-go", "chat_completions", "https://opencode.ai/zen/go" + ) == "https://opencode.ai/zen/go/v1" + assert normalize_opencode_base_url( + "opencode-zen", "codex_responses", "https://opencode.ai/zen" + ) == "https://opencode.ai/zen/v1" + + def test_reappend_is_idempotent(self): + from hermes_cli.models import normalize_opencode_base_url + assert normalize_opencode_base_url( + "opencode-go", "chat_completions", "https://opencode.ai/zen/go/v1" + ) == "https://opencode.ai/zen/go/v1" + + def test_custom_host_not_suffixed(self): + from hermes_cli.models import normalize_opencode_base_url + # A user's proxy override without /v1 is left alone (we can't know its + # path layout), but the anthropic strip still applies when it has /v1. + assert normalize_opencode_base_url( + "opencode-go", "chat_completions", "https://myproxy.example.com/opencode" + ) == "https://myproxy.example.com/opencode" + assert normalize_opencode_base_url( + "opencode-go", "anthropic_messages", "https://myproxy.example.com/opencode/v1" + ) == "https://myproxy.example.com/opencode" + + def test_non_opencode_provider_untouched(self): + from hermes_cli.models import normalize_opencode_base_url + assert normalize_opencode_base_url( + "openrouter", "chat_completions", "https://openrouter.ai/api" + ) == "https://openrouter.ai/api" + + def test_empty_url_passthrough(self): + from hermes_cli.models import normalize_opencode_base_url + assert normalize_opencode_base_url("opencode-go", "chat_completions", "") == "" + assert normalize_opencode_base_url("opencode-go", "chat_completions", None) == "" class TestAzureFoundryModelApiMode: -
tests/hermes_cli/test_runtime_provider_resolution.py@@ -1683,6 +1683,34 @@ def test_opencode_go_model_derivation_beats_stale_persisted_api_mode(monkeypatch assert resolved["api_mode"] == "anthropic_messages" +def test_opencode_go_heals_persisted_stripped_base_url(monkeypatch): + """A stripped base_url persisted after switching into an anthropic-routed + model (e.g. minimax-m2.7 on Go) must be healed back to .../v1 when the + target model routes via chat_completions. Without the heal, glm/deepseek/ + kimi POST to https://opencode.ai/zen/go/chat/completions — a 404 (the + marketing site). This was the 'only minimax works on opencode-go' bug. + """ + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-go") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "opencode-go", + "default": "glm-5.1", + # Stripped URL persisted by a previous anthropic_messages switch. + "base_url": "https://opencode.ai/zen/go", + }, + ) + monkeypatch.setenv("OPENCODE_GO_API_KEY", "test-opencode-go-key") + monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False) + + resolved = rp.resolve_runtime_provider(requested="opencode-go") + + assert resolved["provider"] == "opencode-go" + assert resolved["api_mode"] == "chat_completions" + assert resolved["base_url"] == "https://opencode.ai/zen/go/v1" + + def test_named_custom_provider_anthropic_api_mode(monkeypatch): """Custom providers should accept api_mode: anthropic_messages.""" monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-anthropic-proxy")
-
-
#66563 fix(model-switch): route reasoning models to Responses API on switch + persist api_mode globally
files:
cli.py,gateway/slash_commands.py,hermes_cli/model_switch.py,hermes_cli/providers.py,tests/gateway/test_25107_stale_base_url_api_mode.py,tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py,tests/hermes_cli/test_model_switch_openai_api_mode.pyShow the diff
-
cli.py@@ -7979,6 +7979,14 @@ def _apply_model_switch_result(self, result, persist_global: bool) -> None: save_config_value("model.default", result.new_model) if result.provider_changed: save_config_value("model.provider", result.target_provider) + # base_url/api_mode were previously never persisted here, so a + # global switch left the OLD provider's endpoint/wire-protocol in + # config.yaml. result.base_url/api_mode are always freshly + # resolved for the target provider (see model_switch.py), so sync + # them every time; None clears a value the new provider doesn't + # need (#25106). + save_config_value("model.base_url", result.base_url or None) + save_config_value("model.api_mode", result.api_mode or None) _cprint(" Saved to config.yaml (--global)") else: _cprint(" (session only — add --global to persist)") @@ -8291,6 +8299,10 @@ def _handle_model_switch(self, cmd_original: str): save_config_value("model.default", result.new_model) if result.provider_changed: save_config_value("model.provider", result.target_provider) + # See _apply_model_switch_result above for why base_url/api_mode + # must be synced on every global switch (#25106). + save_config_value("model.base_url", result.base_url or None) + save_config_value("model.api_mode", result.api_mode or None) _cprint(" Saved to config.yaml") else: _cprint(" (session only — add --global to persist)") -
gateway/slash_commands.py@@ -1723,9 +1723,24 @@ async def _on_model_selected_scoped( _persist_cfg["model"] = _persist_model_cfg _persist_model_cfg["default"] = result.new_model _persist_model_cfg["provider"] = result.target_provider + # Named providers always resolve base_url/api_mode fresh, + # so any leftover is cleared unconditionally below. Custom + # providers have no registry entry to re-derive from, so + # they need an explicit set-or-clear here — the previous + # lone `if result.base_url:` left a stale base_url behind + # when switching to a custom provider whose resolver + # returned an empty base_url (#25107). + _is_custom_target = str(result.target_provider or "").strip().lower() == "custom" if result.base_url: _persist_model_cfg["base_url"] = result.base_url - if str(result.target_provider or "").strip().lower() != "custom": + elif _is_custom_target: + _persist_model_cfg.pop("base_url", None) + if _is_custom_target: + if result.api_mode: + _persist_model_cfg["api_mode"] = result.api_mode + else: + _persist_model_cfg.pop("api_mode", None) + else: clear_model_endpoint_credentials(_persist_model_cfg, clear_base_url=True) from hermes_cli.config import save_config save_config(_persist_cfg) @@ -1988,9 +2003,19 @@ async def _finish_switch() -> str: cfg["model"] = model_cfg model_cfg["default"] = result.new_model model_cfg["provider"] = result.target_provider + # See the picker handler above for why custom providers need an + # explicit set-or-clear instead of the old lone truthy check (#25107). + _is_custom_target = str(result.target_provider or "").strip().lower() == "custom" if result.base_url: model_cfg["base_url"] = result.base_url - if str(result.target_provider or "").strip().lower() != "custom": + elif _is_custom_target: + model_cfg.pop("base_url", None) + if _is_custom_target: + if result.api_mode: + model_cfg["api_mode"] = result.api_mode + else: + model_cfg.pop("api_mode", None) + else: clear_model_endpoint_credentials(model_cfg, clear_base_url=True) from hermes_cli.config import save_config save_config(cfg) -
hermes_cli/model_switch.py@@ -30,6 +30,7 @@ custom_provider_slug, determine_api_mode, get_label, + host_mandated_api_mode, is_aggregator, resolve_provider_full, ) @@ -1255,6 +1256,21 @@ def switch_model( if not api_key: [redacted:secret-assignment] = "no-key-required" + # --- Resolve api_mode from the final (provider, base_url) before validation --- + # Two cases this closes, both surfaced when the switched model's reasoning + # is actually applied (post the reasoning-unification refactor): + # 1. api_mode empty (e.g. alias cleared it above) → fill from the endpoint. + # 2. api_mode carried a STALE value from the previous session state + # (e.g. a same-provider /model switch to gpt-5.x on api.openai.com that + # kept the prior openrouter/chat_completions mode). A host that mandates + # one wire protocol must override the stale value — otherwise the request + # goes out on chat_completions and OpenAI 400s on tools+reasoning_effort. + _mandated_mode = host_mandated_api_mode(base_url) + if _mandated_mode is not None: + api_mode = _mandated_mode + elif not api_mode: + api_mode = determine_api_mode(target_provider, base_url) + # --- Normalize model name for target provider --- new_model = normalize_model_for_provider(new_model, target_provider) -
hermes_cli/providers.py@@ -549,45 +549,61 @@ def is_routing_aggregator(provider: str) -> bool: return is_aggregator(provider_norm) +def host_mandated_api_mode(base_url: str = "") -> Optional[str]: + """Return the wire protocol a specific endpoint *requires*, or None. + + Some hosts only accept one API mode and reject the others outright: + - api.openai.com only accepts the Responses API for its (reasoning) + models when tools + reasoning are in play (chat/completions 400s). + - api.anthropic.com / ``…/anthropic`` suffixes speak native Messages. + - Kimi's ``/coding`` endpoint speaks native Messages. + - AWS Bedrock runtime hosts speak Converse. + + These are *mandatory* — a session carrying a stale api_mode (e.g. a + /model switch that kept the previous provider's ``chat_completions``) + must be overridden to the host's required mode, not merely filled in + when empty. Generic / unknown endpoints return None so an explicitly + configured api_mode on them is never clobbered. + """ + if not base_url: + return None + url_lower = base_url.rstrip("/").lower() + hostname = base_url_hostname(base_url) + # Exact-hostname matching only — never bare substring — so lookalike hosts + # (api.openai.com.attacker.test) and path-segment spoofs + # (proxy.test/api.openai.com/v1) are NOT treated as the real endpoint. (#32243) + if hostname == "api.kimi.com" and "/coding" in url_lower: + return "anthropic_messages" + if hostname == "api.anthropic.com" or url_lower.endswith("/anthropic"): + return "anthropic_messages" + if hostname == "api.openai.com": + return "codex_responses" + if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"): + return "bedrock_converse" + return None + + def determine_api_mode(provider: str, base_url: str = "") -> str: """Determine the API mode (wire protocol) for a provider/endpoint. Resolution order: - 1. Known provider → transport → TRANSPORT_TO_API_MODE. - 2. URL heuristics for unknown / custom providers. - 3. Default: 'chat_completions'. + 1. Host-mandated mode (special endpoints that only accept one protocol). + 2. Known provider → transport → TRANSPORT_TO_API_MODE. + 3. Direct provider checks (bedrock). + 4. Default: 'chat_completions'. """ + mandated = host_mandated_api_mode(base_url) + if mandated is not None: + return mandated + pdef = get_provider(provider) if pdef is not None: - # Even for known providers, check URL heuristics for special endpoints - # (e.g. kimi /coding endpoint needs anthropic_messages even on 'custom') - if base_url: - url_lower = base_url.rstrip("/").lower() - if "api.kimi.com/coding" in url_lower: - return "anthropic_messages" - if url_lower.endswith("/anthropic") or "api.anthropic.com" in url_lower: - return "anthropic_messages" - if "api.openai.com" in url_lower: - return "codex_responses" return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions") # Direct provider checks for providers not in HERMES_OVERLAYS if provider == "bedrock": return "bedrock_converse" - # URL-based heuristics for custom / unknown providers - if base_url: - url_lower = base_url.rstrip("/").lower() - hostname = base_url_hostname(base_url) - if url_lower.endswith("/anthropic") or hostname == "api.anthropic.com": - return "anthropic_messages" - if hostname == "api.kimi.com" and "/coding" in url_lower: - return "anthropic_messages" - if hostname == "api.openai.com": - return "codex_responses" - if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"): - return "bedrock_converse" - return "chat_completions" -
tests/gateway/test_25107_stale_base_url_api_mode.py@@ -0,0 +1,194 @@ +"""Regression tests for #25107: gateway /model switch left a stale +``base_url``/never persisted ``api_mode`` in config.yaml when switching to a +custom provider whose resolver returned an empty ``base_url``. + +Root cause: both the picker-tap path (``_on_model_selected`` in +``gateway/slash_commands.py``) and the typed ``/model X --global`` path +(``_finish_switch``) guarded the persist block with two *independent* +``if``s: + + if result.base_url: + model_cfg["base_url"] = result.base_url + if target_provider != "custom": + clear_model_endpoint_credentials(model_cfg, clear_base_url=True) + +For a NAMED provider the second ``if`` always clears any stale value, so the +bug was invisible there. But for a ``custom`` provider with an empty +resolved ``base_url``, NEITHER branch fires: the old base_url survives +untouched, and ``api_mode`` was never written to ``model_cfg`` at all in +either branch (only present in the in-memory session override). + +Fix: an explicit set-or-clear for both fields when the target provider is +custom, so a genuine switch always leaves config.yaml coherent with the +resolved result. +""" + +import types + +import yaml +import pytest + +from gateway.config import Platform +from gateway.platforms.base import MessageEvent, MessageType +from gateway.run import GatewayRunner +from gateway.session import SessionSource + + +class _FakePickerAdapter: + def __init__(self): + self.captured_callback = None + + async def send_model_picker(self, *, on_model_selected, **kwargs): + self.captured_callback = on_model_selected + return types.SimpleNamespace(success=True) + + +def _make_runner(adapter=None): + runner = object.__new__(GatewayRunner) + runner.adapters = {Platform.TELEGRAM: adapter} if adapter else {} + runner._voice_mode = {} + runner._session_model_overrides = {} + runner._running_agents = {} + return runner + + +def _make_event(text): + return MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"), + ) + + +def _fake_switch_result(*, base_url="", api_mode=""): + from hermes_cli.model_switch import ModelSwitchResult + + return ModelSwitchResult( + success=True, + new_model="local-llama", + target_provider="custom", + provider_changed=True, + api_key="[redacted:secret-assignment]", + base_url=base_url, + api_mode=api_mode, + provider_label="Custom", + is_global=True, + ) + + +def _setup_isolated_home(tmp_path, monkeypatch, model_yaml_value, *, base_url="", api_mode=""): + import gateway.run as gateway_run + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + cfg_path = hermes_home / "config.yaml" + cfg_path.write_text( + yaml.safe_dump({"model": model_yaml_value, "providers": {}}), + encoding="utf-8", + ) + + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr( + "hermes_cli.model_switch.list_picker_providers", + lambda **kw: [{"slug": "custom", "name": "Custom", "models": ["local-llama"]}], + ) + monkeypatch.setattr( + "hermes_cli.model_switch.switch_model", + lambda **kw: _fake_switch_result(base_url=base_url, api_mode=api_mode), + ) + monkeypatch.setattr( + "hermes_cli.model_switch.resolve_display_context_length", + lambda *a, **k: 8192, + ) + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home) + monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home) + return cfg_path + + +_STALE_MODEL_CFG = { + "default": "old-custom-model", + "provider": "custom", + "base_url": "https://old-stale-endpoint.example/v1", + "api_key": "[redacted:secret-assignment]", + "api_mode": "anthropic_messages", +} + + +# --------------------------------------------------------------------------- +# Typed `/model X --global` path (_finish_switch) +# --------------------------------------------------------------------------- + + [redacted:email] +async def test_typed_switch_to_custom_clears_stale_base_url_and_api_mode(tmp_path, monkeypatch): + """Switching to a custom provider whose resolver returns no base_url/ + api_mode must clear the previous custom endpoint's leftovers, not keep + routing at the old host/protocol (#25107).""" + cfg_path = _setup_isolated_home(tmp_path, monkeypatch, dict(_STALE_MODEL_CFG)) + + result = await _make_runner()._handle_model_command( + _make_event("/model local-llama --provider custom --global") + ) + + assert result is not None + written = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert written["model"]["default"] == "local-llama" + assert "base_url" not in written["model"], ( + "stale base_url from the old custom endpoint must be cleared" + ) + assert "api_mode" not in written["model"], ( + "stale api_mode from the old custom endpoint must be cleared" + ) + + [redacted:email] +async def test_typed_switch_to_custom_persists_resolved_base_url_and_api_mode(tmp_path, monkeypatch): + """The normal case: a custom-provider switch that DOES resolve a fresh + base_url/api_mode must persist both (api_mode was never written here + before the fix).""" + cfg_path = _setup_isolated_home( + tmp_path, + monkeypatch, + dict(_STALE_MODEL_CFG), + base_url="https://new-endpoint.example/v1", + api_mode="anthropic_messages", + ) + + result = await _make_runner()._handle_model_command( + _make_event("/model local-llama --provider custom --global") + ) + + assert result is not None + written = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert written["model"]["base_url"] == "https://new-endpoint.example/v1" + assert written["model"]["api_mode"] == "anthropic_messages" + + +# --------------------------------------------------------------------------- +# Picker-tap path (_on_model_selected) +# --------------------------------------------------------------------------- + + +async def _drive_picker(runner, event): + sent = await runner._handle_model_command(event) + assert sent is None + adapter = runner.adapters[Platform.TELEGRAM] + assert adapter.captured_callback is not None, "picker callback was not wired" + return await adapter.captured_callback("12345", "local-llama", "custom") + + [redacted:email] +async def test_picker_tap_to_custom_clears_stale_base_url_and_api_mode(tmp_path, monkeypatch): + """Same bug, picker-tap path: tapping a custom-provider model with an + empty resolved base_url must clear the previous custom endpoint (#25107). + """ + adapter = _FakePickerAdapter() + cfg_path = _setup_isolated_home(tmp_path, monkeypatch, dict(_STALE_MODEL_CFG)) + + confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model")) + + assert confirmation is not None + written = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert "base_url" not in written["model"] + assert "api_mode" not in written["model"] -
tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py@@ -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": "[redacted:secret-assignment]", + "base_url": runtime_base_url, + "api_mode": runtime_api_mode, + }, + ), + patch( + "hermes_cli.models.validate_requested_model", + return_value=_MOCK_VALIDATION, + ), + patch("hermes_cli.model_switch.get_model_info", return_value=None), + patch("hermes_cli.model_switch.get_model_capabilities", return_value=None), + patch("hermes_cli.models.detect_provider_for_model", return_value=None), + ): + return switch_model( + raw_input=raw_input, + current_provider=current_provider, + current_model=current_model, + explicit_provider=explicit_provider, + ) + + +def test_stale_chat_completions_overridden_on_openai_direct(): + """The incident: stale chat_completions on api.openai.com → codex_responses. + + Switching from an openrouter/chat_completions session to gpt-5.6-sol on + api.openai.com must flip the wire protocol to the Responses API so tools + + reasoning are preserved, instead of 400ing on chat/completions. + """ + result = _run_openai_switch( + raw_input="gpt-5.6-sol", + current_provider="openrouter", + current_model="anthropic/claude-opus-4.8", + explicit_provider="openai-api", + runtime_api_mode="chat_completions", # stale value carried over + ) + + assert result.success, f"switch_model failed: {result.error_message}" + assert result.target_provider == "openai-api" + assert result.new_model == "gpt-5.6-sol" + assert result.api_mode == "codex_responses" + + +def test_empty_api_mode_filled_on_openai_direct(): + """An empty runtime api_mode on api.openai.com resolves to codex_responses.""" + result = _run_openai_switch( + raw_input="gpt-5.6-sol", + runtime_api_mode="", # empty — the earlier fill-if-empty subcase + ) + + assert result.success, f"switch_model failed: {result.error_message}" + assert result.api_mode == "codex_responses" + + +def test_generic_endpoint_keeps_explicit_api_mode(): + """A generic (non-host-mandated) endpoint must NOT have its api_mode clobbered. + + Only hosts that mandate one wire protocol override a carried value; a + generic OpenAI-compatible relay returns None from host_mandated_api_mode, + so the switch path leaves the resolver's api_mode untouched. + """ + from hermes_cli.providers import host_mandated_api_mode + + assert host_mandated_api_mode("https://generic.example.com/v1") is None + # Lookalike / path-spoof hosts must also NOT be treated as mandated (#32243). + assert host_mandated_api_mode("https://api.openai.com.attacker.test/v1") is None + assert host_mandated_api_mode("https://proxy.test/api.openai.com/v1") is None + # The real endpoints are mandated. + assert host_mandated_api_mode("https://api.openai.com/v1") == "codex_responses" + assert host_mandated_api_mode("https://api.anthropic.com") == "anthropic_messages"
-
-
#69818 fix(cli): persist provider on global model switch
files:
cli.py,tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py
7 PRs closed without merging
From the close comments: superseded by another PR 3 · done elsewhere 2 · merged via another change 2
-
#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 -
#51488 Clear stale base_url on gateway model switches · merged via another change →
f6deabc,#53977,#49129 -
#56249 fix(gateway): don't persist base_url for non-custom providers on /model switch · superseded by another PR →
f6deabc -
#57281 fix(runtime): close opencode-go/zen model-switch 404 on both resolver paths (#57259) · done elsewhere →
#57585,#57259,#56157
Closed issues · 4
- Closed#25107[Bug]: Gateway /model switch leaves stale base_url and does not persist api_mode
- Closed#51486Gateway model switches persist stale base_url for built-in providers
- Closed#56158[Bug]: /model switch from Telegram persists stale base_url → 404 on opencode-go chat_completions models
- Closed#57259[Bug]: Opencode go Only working with Minimax models
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.11: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab