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