{"repo":"NousResearch/hermes-agent","public_id":"14879","display_label":"Stale base_url/api_mode across provider contexts","summary":"These issues and patches center on provider transport configuration (base_url, api_mode) being drawn from outdated defaults rather than the actual target model, affecting delegation, cron jobs, CLI model switching, and gateway routing across multiple providers including OpenCode and Bedrock.","subsystem":"agent","counts":{"members":46,"open_issues":3,"open_issues_distinct":3,"open_prs":8,"closed_issues":11,"merged_prs":4,"attempt_prs":28},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":19623},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-23T04:18:51+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.03857004830917874},"fix_edges":[{"issue_number":15319,"pr_number":19623,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":25106,"pr_number":66563,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":25106,"pr_number":69818,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":25107,"pr_number":53977,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":25107,"pr_number":66563,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":25107,"pr_number":69818,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":14879,"title":"[Bug] /model command strips /v1 from OpenCode Go endpoint when switching from MiniMax provider","url":null,"state":"closed"},{"kind":"issue","number":15319,"title":"delegate_task 404 when delegation model differs from main model on opencode-go/opencode-zen","url":null,"state":"closed"},{"kind":"issue","number":18586,"title":"Bug Report: `delegate_task` resolves wrong `api_mode`/`base_url` for opencode-go when `target_model` is omitted","url":null,"state":"closed"},{"kind":"issue","number":20558,"title":"[Bug]: Delegation subagents fail to resolve credentials and hit 404s on mixed-model providers (OpenCode)","url":null,"state":"closed"},{"kind":"issue","number":25107,"title":"[Bug]: Gateway /model switch leaves stale base_url and does not persist api_mode","url":null,"state":"closed"},{"kind":"issue","number":30944,"title":"bug: /model TUI picker silently routes Bedrock Claude models through wrong API (bedrock_converse instead of anthropic_messages)","url":null,"state":"closed"},{"kind":"issue","number":43946,"title":"[Bug]: Non-Claude Bedrock models fail with malformed tool/message payloads (Kimi, DeepSeek, Amazon Nova)","url":null,"state":"closed"},{"kind":"issue","number":45245,"title":"Cron scheduler omits target_model when resolving runtime provider — same root cause as #18586","url":null,"state":"open"},{"kind":"issue","number":50292,"title":"fix(bedrock): non-Claude models routed through Anthropic SDK when config default is Claude","url":null,"state":"open"},{"kind":"issue","number":51486,"title":"Gateway model switches persist stale base_url for built-in providers","url":null,"state":"closed"},{"kind":"issue","number":54147,"title":"[Bug]: `hermes chat -m <model>` uses stale api_mode from config default → 404 on opencode-go","url":null,"state":"closed"},{"kind":"issue","number":56158,"title":"[Bug]: /model switch from Telegram persists stale base_url → 404 on opencode-go chat_completions models","url":null,"state":"closed"},{"kind":"issue","number":65587,"title":"Bug: chat -q model override does not re-resolve provider transport","url":null,"state":"open"},{"kind":"issue","number":68762,"title":"Cron jobs fail with HTTP 401 when config.yaml model.default is switched to an anthropic_messages-routed model (e.g. qwen on opencode-go)","url":null,"state":"closed"},{"kind":"pr","number":14919,"title":"fix(model): only strip /v1 from OpenCode URLs on fresh provider resolution","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4508","number":4508},{"kind":"pr","value":"#14879","number":14879},{"kind":"pr","value":"#14879","number":14879},{"kind":"pr","value":"#4508","number":4508}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":15320,"title":"fix(delegation): pass target_model to resolve_runtime_provider in _resolve_delegation_credentials","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19623","number":19623}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":15447,"title":"fix(tools): pass target_model to resolve_runtime_provider in delegation (#15319)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"83bbe9b458cc19dc7d12c5dbf4e8e68ff37659b2","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":18605,"title":"fix: pass target_model to resolve_runtime_provider in delegation","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":18799,"title":"fix(delegation): pass target_model when resolving opencode-go provider","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":19623,"title":"fix(delegation): pass target_model to resolve_runtime_provider in _resolve_delegation_credentials","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tools/delegate_tool.py"],"fix_hunks":[{"file":"tools/delegate_tool.py","hunks":["@@ -2299,7 +2299,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:\n     try:\n         from hermes_cli.runtime_provider import resolve_runtime_provider\n \n-        runtime = resolve_runtime_provider(requested=configured_provider)\n+        runtime = resolve_runtime_provider(requested=configured_provider, target_model=configured_model)\n     except Exception as exc:\n         raise ValueError(\n             f\"Cannot resolve delegation provider '{configured_provider}': {exc}. \"\n"]}]},{"kind":"pr","number":20563,"title":"Fixes #20558","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"f0e46c5e9e8d4f780561554684e33810fc4f2f8f","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":25131,"title":"fix(agent): persist complete model runtime state on /model switch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"398634eed","number":0},{"kind":"pr","value":"#25106","number":25106},{"kind":"pr","value":"#69818","number":69818}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":25138,"title":"fix: persist gateway model runtime state","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"51d826f88","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":25148,"title":"fix(tui): persist api_mode in _persist_model_switch()","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":45244,"title":"fix(cron): pass target_model to resolve_runtime_provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#78427","number":78427}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":45248,"title":"fix(cron): pass target_model to resolve_runtime_provider for pinned-model jobs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45244","number":45244}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":45294,"title":"fix(cron): pass target_model to resolve_runtime_provider in run_job","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45244","number":45244}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":45338,"title":"fix(cron): pass target_model to resolve_runtime_provider (#45245)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45244","number":45244},{"kind":"pr","value":"#45245","number":45245}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":46853,"title":"fix(cron): propagate runtime provider config to scheduled jobs","url":null,"state":"open","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":50578,"title":"fix(bedrock): route non-Claude Bedrock models off the Anthropic SDK","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"284d06cabfb5f19051c83320115fde74d9e344c1","number":0},{"kind":"pr","value":"#51933","number":51933}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":51488,"title":"Clear stale base_url on gateway model switches","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f6deabca0","number":0},{"kind":"pr","value":"#53977","number":53977},{"kind":"pr","value":"#49129","number":49129}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":53977,"title":"fix(models): opencode aggregator picker catalog + uncap + scoped live-first; clear stale gateway base_url","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/slash_commands.py","hermes_cli/config.py","hermes_cli/model_switch.py","hermes_cli/models.py","infographic/model-picker-fixes/infographic.png","scripts/release.py","tests/gateway/test_model_command_flat_string_config.py","tests/gateway/test_model_picker_persist.py","tests/hermes_cli/test_opencode_zen_model_limit.py","tests/hermes_cli/test_provider_live_curated_merge.py"],"fix_hunks":[{"file":"gateway/slash_commands.py","hunks":["@@ -1361,7 +1361,7 @@ async def _on_model_selected(\n                                 if result.base_url:\n                                     _persist_model_cfg[\"base_url\"] = result.base_url\n                                 if str(result.target_provider or \"\").strip().lower() != \"custom\":\n-                                    clear_model_endpoint_credentials(_persist_model_cfg)\n+                                    clear_model_endpoint_credentials(_persist_model_cfg, clear_base_url=True)\n                                 from hermes_cli.config import save_config\n                                 save_config(_persist_cfg)\n                             except Exception as e:","@@ -1598,7 +1598,7 @@ async def _finish_switch() -> str:\n                     if result.base_url:\n                         model_cfg[\"base_url\"] = result.base_url\n                     if str(result.target_provider or \"\").strip().lower() != \"custom\":\n-                        clear_model_endpoint_credentials(model_cfg)\n+                        clear_model_endpoint_credentials(model_cfg, clear_base_url=True)\n                     from hermes_cli.config import save_config\n                     save_config(cfg)\n                 except Exception as e:"]},{"file":"hermes_cli/config.py","hunks":["@@ -4131,6 +4131,7 @@ def clear_model_endpoint_credentials(\n     *,\n     clear_api_key: bool = True,\n     clear_api_mode: bool = True,\n+    clear_base_url: bool = False,\n ) -> Dict[str, Any]:\n     \"\"\"Remove stale inline endpoint credentials from a model config.\n ","@@ -4147,6 +4148,8 @@ def clear_model_endpoint_credentials(\n         model_cfg.pop(\"api\", None)\n     if clear_api_mode:\n         model_cfg.pop(\"api_mode\", None)\n+    if clear_base_url:\n+        model_cfg.pop(\"base_url\", None)\n     return model_cfg\n \n "]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -44,6 +44,11 @@\n     list_provider_models,\n )\n \n+# Providers whose picker model list should NOT be capped by max_models.\n+# OpenCode Zen / Go are aggregators whose full catalogs (70+ models each) must\n+# be visible so users can pick any model they have access to.\n+_UNCAPPED_PICKER_PROVIDERS: frozenset[str] = frozenset({\"opencode-zen\", \"opencode-go\"})\n+\n logger = logging.getLogger(__name__)\n \n ","@@ -1650,7 +1655,10 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:\n             if hermes_id in _MODELS_DEV_PREFERRED:\n                 model_ids = _merge_with_models_dev(hermes_id, model_ids)\n         total = len(model_ids)\n-        top = model_ids[:max_models] if max_models is not None else model_ids\n+        if hermes_id in _UNCAPPED_PICKER_PROVIDERS:\n+            top = model_ids  # Aggregator: show full catalog regardless of max_models\n+        else:\n+            top = model_ids[:max_models] if max_models is not None else model_ids\n \n         slug = hermes_id\n         pinfo = _mdev_pinfo(mdev_id)","@@ -1813,7 +1821,10 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:\n                 if hermes_slug in _MODELS_DEV_PREFERRED:\n                     model_ids = _merge_with_models_dev(hermes_slug, model_ids)\n         total = len(model_ids)\n-        top = model_ids[:max_models] if max_models is not None else model_ids\n+        if hermes_slug in _UNCAPPED_PICKER_PROVIDERS:\n+            top = model_ids  # Aggregator: show full catalog regardless of max_models\n+        else:\n+            top = model_ids[:max_models] if max_models is not None else model_ids\n \n         results.append({\n             \"slug\": hermes_slug,"]},{"file":"hermes_cli/models.py","hunks":["@@ -381,9 +381,15 @@ def _xai_curated_models() -> list[str]:\n     ],\n     \"opencode-zen\": [\n         \"kimi-k2.5\",\n+        \"kimi-k2.6\",\n+        \"gpt-5.5\",\n+        \"gpt-5.5-pro\",\n         \"gpt-5.4-pro\",\n         \"gpt-5.4\",\n+        \"gpt-5.4-mini\",\n+        \"gpt-5.4-nano\",\n         \"gpt-5.3-codex\",\n+        \"gpt-5.3-codex-spark\",\n         \"gpt-5.2\",\n         \"gpt-5.2-codex\",\n         \"gpt-5.1\",","@@ -393,6 +399,9 @@ def _xai_curated_models() -> list[str]:\n         \"gpt-5\",\n         \"gpt-5-codex\",\n         \"gpt-5-nano\",\n+        \"claude-fable-5\",\n+        \"claude-opus-4-8\",\n+        \"claude-opus-4-7\",\n         \"claude-opus-4-6\",\n         \"claude-opus-4-5\",\n         \"claude-opus-4-1\",","@@ -400,21 +409,25 @@ def _xai_curated_models() -> list[str]:\n         \"claude-sonnet-4-5\",\n         \"claude-sonnet-4\",\n         \"claude-haiku-4-5\",\n-        \"claude-3-5-haiku\",\n+        \"gemini-3.5-flash\",\n         \"gemini-3.1-pro\",\n-        \"gemini-3-pro\",\n         \"gemini-3-flash\",\n         \"minimax-m2.7\",\n         \"minimax-m2.5\",\n-        \"minimax-m2.5-free\",\n-        \"minimax-m2.1\",\n+        \"minimax-m3-free\",\n+        \"glm-5.1\",\n         \"glm-5\",\n-        \"glm-4.7\",\n-        \"glm-4.6\",\n-        \"kimi-k2-thinking\",\n-        \"kimi-k2\",\n-        \"qwen3-coder\",\n+        \"deepseek-v4-pro\",\n+        \"deepseek-v4-flash\",\n+        \"deepseek-v4-flash-free\",\n+        \"qwen3.6-plus\",\n+        \"qwen3.6-plus-free\",\n+        \"qwen3.5-plus\",\n+        \"grok-build-0.1\",\n         \"big-pickle\",\n+        \"mimo-v2.5-free\",\n+        \"north-mini-code-free\",\n+        \"nemotron-3-ultra-free\",\n     ],\n     \"opencode-go\": [\n         \"kimi-k2.6\",","@@ -1782,6 +1795,17 @@ def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool:\n # away from the model's native vendor). None are currently defined.\n _BORROWED_MODEL_PROVIDERS: frozenset[str] = frozenset()\n \n+# Providers whose live /v1/models endpoint is the authoritative catalog, so the\n+# curated list is a discovery-only fallback. For these, the picker merges\n+# live-first (live entries lead, curated-only entries append). Every OTHER\n+# provider keeps curated-first (commit 658ac1d86, #46309) so a deliberately\n+# surfaced newest model stays at the top even when the live API lags. OpenCode\n+# Zen / Go re-expose dozens of upstream vendors and rotate them frequently, so\n+# their stale curated entries must not pollute the top of the picker. (#49129)\n+_LIVE_FIRST_PICKER_PROVIDERS: frozenset[str] = frozenset(\n+    {\"opencode-zen\", \"opencode-go\"}\n+)\n+\n \n def _resolve_static_model_alias(\n     name_lower: str,","@@ -2420,15 +2444,22 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)\n                     # Merge static curated list with live API results so\n                     # models that the live endpoint omits (stale cache,\n                     # partial rollout) still appear in the picker.\n-                    # Curated entries come first so deliberately-surfaced\n-                    # newest models (e.g. kimi-k2.7-code, #46309) stay at\n-                    # the top of the picker; live-only entries are appended\n-                    # afterwards for discovery.  (#46850)\n+                    #\n+                    # Single providers (kimi, zai) use curated-first\n+                    # (commit 658ac1d86) to surface newest models even when live\n+                    # API lags (#46309). OpenCode Zen / Go are different: their\n+                    # live API is the authoritative catalog, so they merge\n+                    # live-first — live entries lead and stale curated entries\n+                    # no longer pollute the top of the picker. (#49129)\n                     curated = list(_PROVIDER_MODELS.get(normalized, []))\n                     if curated:\n-                        merged = list(curated)\n-                        merged_lower = {m.lower() for m in curated}\n-                        for m in live:\n+                        if normalized in _LIVE_FIRST_PICKER_PROVIDERS:\n+                            primary, secondary = live, curated\n+                        else:\n+                            primary, secondary = curated, live\n+                        merged = list(primary)\n+                        merged_lower = {m.lower() for m in primary}\n+                        for m in secondary:\n                             if m.lower() not in merged_lower:\n                                 merged.append(m)\n                                 merged_lower.add(m.lower())"]},{"file":"scripts/release.py","hunks":["@@ -1713,6 +1713,8 @@\n     \"[redacted:email]\": \"qingshan89\",  # PR #46895 co-author (desktop remote artifact download)\n     \"[redacted:email]\": \"AJ\",  # PR #44510 co-author (desktop named-profile boot loop)\n     \"[redacted:email]\": \"steveonjava\",  # PR #29669 (redact secrets in kanban tool payloads)\n+    \"[redacted:email]\": \"Afnath-max\",  # PR #49129 salvage (opencode-zen catalog refresh + uncapped/live-first picker)\n+    \"[redacted:email]\": \"ipriyaaanshu\",  # PR #51488 salvage (clear stale base_url on gateway model switches; #25107)\n }\n \n "]},{"file":"tests/gateway/test_model_command_flat_string_config.py","hunks":["@@ -102,7 +102,7 @@ async def test_model_global_persists_when_config_has_flat_string_model(tmp_path,\n     )\n     assert written[\"model\"][\"default\"] == \"gpt-5.5\"\n     assert written[\"model\"][\"provider\"] == \"openrouter\"\n-    assert written[\"model\"][\"base_url\"] == \"https://openrouter.ai/api/v1\"\n+    assert \"base_url\" not in written[\"model\"]\n \n \n @pytest.mark.asyncio"]},{"file":"tests/gateway/test_model_picker_persist.py","hunks":["@@ -171,7 +171,7 @@ async def test_picker_tap_persists_by_default(tmp_path, monkeypatch, seed_model)\n     )\n     assert written[\"model\"][\"default\"] == \"gpt-5.5\"\n     assert written[\"model\"][\"provider\"] == \"openrouter\"\n-    assert written[\"model\"][\"base_url\"] == \"https://openrouter.ai/api/v1\"\n+    assert \"base_url\" not in written[\"model\"]\n     assert \"api_key\" not in written[\"model\"]\n     assert \"api_mode\" not in written[\"model\"]\n "]},{"file":"tests/hermes_cli/test_opencode_zen_model_limit.py","hunks":["@@ -0,0 +1,51 @@\n+\"\"\"Regression tests for OpenCode Zen model picker limits.\"\"\"\n+\n+import os\n+from unittest.mock import patch\n+\n+import hermes_cli.providers as providers_mod\n+from hermes_cli.model_switch import list_authenticated_providers\n+\n+\n+def test_opencode_zen_lists_all_models_while_other_providers_remain_capped(monkeypatch):\n+    \"\"\"OpenCode Zen is an aggregator product, so the picker must expose its full catalog.\"\"\"\n+    zen_models = [f\"zen-model-{i}\" for i in range(57)]\n+    deepseek_models = [f\"deepseek-model-{i}\" for i in range(57)]\n+\n+    monkeypatch.setattr(\n+        \"agent.models_dev.PROVIDER_TO_MODELS_DEV\",\n+        {\n+            \"opencode-zen\": \"opencode\",\n+            \"deepseek\": \"deepseek\",\n+        },\n+    )\n+    monkeypatch.setattr(\n+        \"agent.models_dev.fetch_models_dev\",\n+        lambda: {\"opencode\": {}, \"deepseek\": {}},\n+    )\n+    monkeypatch.setattr(providers_mod, \"HERMES_OVERLAYS\", {})\n+    monkeypatch.setattr(\n+        \"hermes_cli.models.cached_provider_model_ids\",\n+        lambda provider: {\n+            \"opencode-zen\": zen_models,\n+            \"deepseek\": deepseek_models,\n+        }.get(provider, []),\n+    )\n+\n+    with patch.dict(\n+        os.environ,\n+        {\n+            \"OPENCODE_ZEN_API_KEY\": \"test-zen-key\",\n+            \"DEEPSEEK_API_KEY\": \"test-deepseek-key\",\n+        },\n+        clear=False,\n+    ):\n+        providers = list_authenticated_providers(max_models=50)\n+\n+    opencode_zen = next(p for p in providers if p[\"slug\"] == \"opencode-zen\")\n+    deepseek = next(p for p in providers if p[\"slug\"] == \"deepseek\")\n+\n+    assert opencode_zen[\"models\"] == zen_models\n+    assert opencode_zen[\"total_models\"] == len(zen_models)\n+    assert deepseek[\"models\"] == deepseek_models[:50]\n+    assert deepseek[\"total_models\"] == len(deepseek_models)"]},{"file":"tests/hermes_cli/test_provider_live_curated_merge.py","hunks":["@@ -1,13 +1,23 @@\n \"\"\"Tests for live+curated merge in the generic profile-based provider path.\n \n-Guards the fix for #46850: when a provider's live /v1/models endpoint\n-returns a stale or incomplete list, the static curated models from\n-``_PROVIDER_MODELS`` must still appear in the merged result.\n+Guards two contracts:\n+\n+* #46850 — when a provider's live /v1/models endpoint returns a stale or\n+  incomplete list, the static curated models from ``_PROVIDER_MODELS`` must\n+  still appear in the merged result (nothing is dropped).\n+* #46309 / #49129 — merge *order* is per-provider. Single providers\n+  (kimi, zai) stay **curated-first** so a deliberately surfaced newest model\n+  leads even when the live API lags. ``_LIVE_FIRST_PICKER_PROVIDERS``\n+  (OpenCode Zen / Go) flip to **live-first** because their live API is the\n+  authoritative catalog and stale curated entries must not lead the picker.\n \"\"\"\n \n from unittest.mock import MagicMock, patch\n \n-from hermes_cli.models import _PROVIDER_MODELS, provider_model_ids\n+from hermes_cli.models import (\n+    _LIVE_FIRST_PICKER_PROVIDERS,\n+    provider_model_ids,\n+)\n \n \n class TestGenericProviderLiveCuratedMerge:","@@ -22,46 +32,84 @@ def _make_profile(self, models=None):\n         p.fallback_models = None\n         return p\n \n-    def test_live_models_merged_with_curated(self):\n-        \"\"\"Curated models come first; live-only models are appended.\"\"\"\n-        live = [\"glm-5.2\", \"glm-5.1\", \"glm-5\"]\n-        curated = _PROVIDER_MODELS[\"zai\"]  # includes glm-5.1, glm-5, glm-4.5, etc.\n+    def test_curated_first_for_single_provider(self):\n+        \"\"\"Single providers (zai) stay curated-first; live-only appended.\"\"\"\n+        assert \"zai\" not in _LIVE_FIRST_PICKER_PROVIDERS\n+        curated = [\"glm-5.2\", \"glm-5.1\", \"glm-5\"]  # authoritative-intent order\n+        # Live API lags AND surfaces a brand-new model not yet curated.\n+        live = [\"glm-5\", \"glm-6-preview\"]\n         profile = self._make_profile(live)\n \n         with (\n             patch(\"providers.get_provider_profile\", return_value=profile),\n-            patch(\"hermes_cli.auth.resolve_api_key_provider_credentials\", return_value={\"api_key\": \"k\", \"base_url\": \"\"}),\n+            patch(\n+                \"hermes_cli.auth.resolve_api_key_provider_credentials\",\n+                return_value={\"api_key\": \"k\", \"base_url\": \"\"},\n+            ),\n+            patch.dict(\"hermes_cli.models._PROVIDER_MODELS\", {\"zai\": curated}),\n         ):\n             result = provider_model_ids(\"zai\")\n \n-        # Curated entries first, in catalog order (keeps newest curated models\n-        # like glm-5.2 at the top of the picker — see #46309).\n-        assert result[: len(curated)] == list(curated)\n-        assert result[0] == \"glm-5.2\"\n-        # Models present in both live and curated are not duplicated.\n-        assert result.count(\"glm-5.2\") == 1\n-        assert result.count(\"glm-5.1\") == 1\n-        # Curated-only entries are part of the result (e.g. glm-4.5).\n-        result_lower = [m.lower() for m in result]\n-        assert \"glm-4.5\" in result_lower\n-        assert \"glm-4.5-flash\" in result_lower\n-\n-    def test_no_duplicate_models(self):\n-        \"\"\"Models appearing in both live and curated are not duplicated.\"\"\"\n-        live = [\"glm-5.1\", \"glm-5\"]\n-        curated = [\"glm-5.1\", \"glm-5\", \"glm-4.5\"]\n+        # Curated entries lead (commit 658ac1d86, #46309).\n+        assert result[: len(curated)] == curated\n+        # Live-only entries (glm-6-preview) still surface, appended afterwards.\n+        assert \"glm-6-preview\" in result\n+        assert result.index(\"glm-6-preview\") >= len(curated)\n+        # No duplicates for models present in both.\n+        assert result.count(\"glm-5\") == 1\n+\n+    def test_live_first_for_opencode_zen(self):\n+        \"\"\"OpenCode Zen flips to live-first; curated-only models appended.\"\"\"\n+        assert \"opencode-zen\" in _LIVE_FIRST_PICKER_PROVIDERS\n+        live = [\"nemotron-3-ultra-free\", \"gpt-5.5\", \"claude-fable-5\"]\n+        curated = [\"gpt-5.5\", \"claude-fable-5\", \"big-pickle\"]\n         profile = self._make_profile(live)\n \n         with (\n             patch(\"providers.get_provider_profile\", return_value=profile),\n-            patch(\"hermes_cli.auth.resolve_api_key_provider_credentials\", return_value={\"api_key\": \"k\", \"base_url\": \"\"}),\n-            patch.dict(\"hermes_cli.models._PROVIDER_MODELS\", {\"zai\": curated}),\n+            patch(\n+                \"hermes_cli.auth.resolve_api_key_provider_credentials\",\n+                return_value={\"api_key\": \"k\", \"base_url\": \"\"},\n+            ),\n+            patch.dict(\"hermes_cli.models._PROVIDER_MODELS\", {\"opencode-zen\": curated}),\n         ):\n-            result = provider_model_ids(\"zai\")\n+            result = provider_model_ids(\"opencode-zen\")\n+\n+        # Live entries lead (authoritative aggregator catalog).\n+        assert result[: len(live)] == list(live)\n+        assert result[0] == \"nemotron-3-ultra-free\"\n+        # Curated-only entries (big-pickle) appended for discovery.\n+        assert \"big-pickle\" in result\n+        assert result.index(\"big-pickle\") >= len(live)\n+        # No duplicates.\n+        assert result.count(\"gpt-5.5\") == 1\n+\n+    def test_no_models_dropped_either_direction(self):\n+        \"\"\"Every live AND curated model survives the merge for both modes.\"\"\"\n+        live = [\"a\", \"b\"]\n+        # zai = curated-first\n+        with (\n+            patch(\"providers.get_provider_profile\", return_value=self._make_profile(live)),\n+            patch(\n+                \"hermes_cli.auth.resolve_api_key_provider_credentials\",\n+                return_value={\"api_key\": \"k\", \"base_url\": \"\"},\n+            ),\n+            patch.dict(\"hermes_cli.models._PROVIDER_MODELS\", {\"zai\": [\"c\", \"b\"]}),\n+        ):\n+            zai_result = set(provider_model_ids(\"zai\"))\n+        assert {\"a\", \"b\", \"c\"} <= zai_result\n \n-        assert result.count(\"glm-5.1\") == 1\n-        assert result.count(\"glm-5\") == 1\n-        assert result == [\"glm-5.1\", \"glm-5\", \"glm-4.5\"]\n+        # opencode-zen = live-first\n+        with (\n+            patch(\"providers.get_provider_profile\", return_value=self._make_profile(live)),\n+            patch(\n+                \"hermes_cli.auth.resolve_api_key_provider_credentials\",\n+                return_value={\"api_key\": \"k\", \"base_url\": \"\"},\n+            ),\n+            patch.dict(\"hermes_cli.models._PROVIDER_MODELS\", {\"opencode-zen\": [\"c\", \"b\"]}),\n+        ):\n+            zen_result = set(provider_model_ids(\"opencode-zen\"))\n+        assert {\"a\", \"b\", \"c\"} <= zen_result\n \n     def test_case_insensitive_dedup(self):\n         \"\"\"Dedup is case-insensitive but preserves first occurrence casing.\"\"\"","@@ -71,129 +119,13 @@ def test_case_insensitive_dedup(self):\n \n         with (\n             patch(\"providers.get_provider_profile\", return_value=profile),\n-            patch(\"hermes_cli.auth.resolve_api_key_provider_credentials\", return_value={\"api_key\": \"k\", \"base_url\": \"\"}),\n+            patch(\n+                \"hermes_cli.auth.resolve_api_key_provider_credentials\",\n+                return_value={\"api_key\": \"k\", \"base_url\": \"\"},\n+            ),\n             patch.dict(\"hermes_cli.models._PROVIDER_MODELS\", {\"zai\": curated}),\n         ):\n             result = provider_model_ids(\"zai\")\n \n-        # Curated-first: curated casing wins for models present in both.\n+        # zai is curated-first: curated casing wins for models present in both.\n         assert result == [\"glm-5.1\", \"GLM-5\", \"glm-4.5\"]\n-\n-    def test_empty_curated_returns_live_only(self):\n-        \"\"\"When no curated list exists, live is returned as-is.\"\"\"\n-        live = [\"model-a\", \"model-b\"]\n-        profile = self._make_profile(live)\n-\n-        with (\n-            patch(\"providers.get_provider_profile\", return_value=profile),\n-            patch(\"hermes_cli.auth.resolve_api_key_provider_credentials\", return_value={\"api_key\": \"k\", \"base_url\": \"\"}),\n-            patch.dict(\"hermes_cli.models._PROVIDER_MODELS\", {\"zai\": []}),\n-        ):\n-            result = provider_model_ids(\"zai\")\n-\n-        assert result == [\"model-a\", \"model-b\"]\n-\n-    def test_live_empty_falls_back_to_curated(self):\n-        \"\"\"When live returns nothing, curated static list is used.\n-\n-        ZAI is in _MODELS_DEV_PREFERRED so the fallback path merges with\n-        models.dev.  We mock _merge_with_models_dev to isolate the test.\n-        \"\"\"\n-        curated = [\"glm-5.1\", \"glm-5\", \"glm-4.5\"]\n-        profile = self._make_profile([])\n-\n-        with (\n-            patch(\"providers.get_provider_profile\", return_value=profile),\n-            patch(\"hermes_cli.auth.resolve_api_key_provider_credentials\", return_value={\"api_key\": \"k\", \"base_url\": \"\"}),\n-            patch.dict(\"hermes_cli.models._PROVIDER_MODELS\", {\"zai\": curated}),\n-            patch(\"hermes_cli.models._merge_with_models_dev\", return_value=curated),\n-        ):\n-            result = provider_model_ids(\"zai\")\n-\n-        assert result == curated\n-\n-\n-class TestValidateRequestedModelCuratedFallback:\n-    \"\"\"validate_requested_model falls back to curated catalog when live API omits model.\"\"\"\n-\n-    def test_model_in_curated_but_not_live_is_accepted(self):\n-        \"\"\"When live /v1/models omits a model that exists in the curated\n-        catalog, validate_requested_model should accept it with a note.\n-\n-        Patches the real ``_PROVIDER_MODELS`` source (not the function under\n-        test) so the curated-catalog fallback is genuinely exercised.\n-        \"\"\"\n-        from hermes_cli.models import validate_requested_model\n-\n-        # Live API returns only glm-5.1, but curated has glm-5.2\n-        live_models = [\"glm-5.1\"]\n-        curated = [\"glm-5.2\", \"glm-5.1\", \"glm-5\", \"glm-4.5\"]\n-\n-        with (\n-            patch(\"hermes_cli.models.fetch_api_models\", return_value=live_models),\n-            patch.dict(\"hermes_cli.models._PROVIDER_MODELS\", {\"zai\": curated}),\n-        ):\n-            result = validate_requested_model(\"glm-5.2\", \"zai\", api_key=\"dummy\")\n-\n-        assert result[\"accepted\"] is True\n-        assert result[\"recognized\"] is True\n-        assert result[\"message\"] is not None\n-        assert \"curated catalog\" in result[\"message\"]\n-\n-    def test_model_not_in_curated_nor_live_is_rejected(self):\n-        \"\"\"When a model is in neither live nor curated, it should be rejected.\"\"\"\n-        from hermes_cli.models import validate_requested_model\n-\n-        live_models = [\"glm-5.1\"]\n-        curated = [\"glm-5.1\", \"glm-5\", \"glm-4.5\"]\n-\n-        with (\n-            patch(\"hermes_cli.models.fetch_api_models\", return_value=live_models),\n-            patch.dict(\"hermes_cli.models._PROVIDER_MODELS\", {\"zai\": curated}),\n-        ):\n-            result = validate_requested_model(\"nonexistent-model\", \"zai\", api_key=\"dummy\")\n-\n-        assert result[\"accepted\"] is False\n-\n-    def test_model_in_live_is_accepted_without_curated_check(self):\n-        \"\"\"When the model is in the live API, it should be accepted directly.\"\"\"\n-        from hermes_cli.models import validate_requested_model\n-\n-        live_models = [\"glm-5.1\", \"glm-5\"]\n-\n-        with patch(\"hermes_cli.models.fetch_api_models\", return_value=live_models):\n-            result = validate_requested_model(\"glm-5.1\", \"zai\", api_key=\"dummy\")\n-\n-        assert result[\"accepted\"] is True\n-        assert result[\"recognized\"] is True\n-        assert result[\"message\"] is None\n-\n-    def test_curated_fallback_is_scoped_to_the_current_provider(self):\n-        \"\"\"The curated fallback must not leak models across providers.\n-\n-        A model that lives in some OTHER provider's catalog (or only on an\n-        aggregator like OpenRouter) must still be rejected when the current\n-        provider neither lists it live nor ships it in its OWN curated\n-        catalog.  The fallback keys on ``_provider_keys(normalized)``, so\n-        catalog membership is checked per-provider, never globally.\n-        \"\"\"\n-        from hermes_cli.models import validate_requested_model\n-\n-        # `some-other-model` is known to a DIFFERENT provider, not to zai.\n-        # zai's live listing also omits it.  It must be rejected.\n-        live_models = [\"glm-5.1\"]\n-\n-        with (\n-            patch(\"hermes_cli.models.fetch_api_models\", return_value=live_models),\n-            patch.dict(\n-                \"hermes_cli.models._PROVIDER_MODELS\",\n-                {\"zai\": [\"glm-5.2\", \"glm-5.1\"], \"openrouter\": [\"some-other-model\"]},\n-            ),\n-        ):\n-            result = validate_requested_model(\"some-other-model\", \"zai\", api_key=\"dummy\")\n-\n-        assert result[\"accepted\"] is False, (\n-            \"A model only present in another provider's catalog must not be \"\n-            \"accepted on this provider via the curated fallback.\"\n-        )\n-\n"]}]},{"kind":"pr","number":54148,"title":"fix(cli): pass target_model to resolve_runtime_provider in _ensure_runtime_credentials","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":54365,"title":"fix: pass target_model to resolve_runtime_provider in _ensure_runtime_credentials (#54147)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54148","number":54148}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":54540,"title":"fix(cli): pass target_model to resolve_runtime_provider in _ensure_runtime_credentials (Fixes #54147)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#54148","number":54148},{"kind":"pr","value":"#54147","number":54147},{"kind":"pr","value":"#54365","number":54365},{"kind":"pr","value":"#54148","number":54148}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":56249,"title":"fix(gateway): don't persist base_url for non-custom providers on /model switch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f6deabca0","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":59407,"title":"fix(agent): prevent stale moa://local base_url from surviving model s…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6eb39c2bb","number":0},{"kind":"pr","value":"#56158","number":56158},{"kind":"pr","value":"#47828","number":47828}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":60970,"title":"fix(cli,gateway): sync base_url/api_mode on global model switch persist","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"398634eed","number":0},{"kind":"commit","value":"e7f208fd7","number":0},{"kind":"pr","value":"#66563","number":66563},{"kind":"pr","value":"#25106","number":25106},{"kind":"pr","value":"#25107","number":25107}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":65609,"title":"fix(cli): resolve runtime using overridden model","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"90f0a690ace1","number":0},{"kind":"pr","value":"#65587","number":65587},{"kind":"pr","value":"#65609","number":65609},{"kind":"pr","value":"#65609","number":65609},{"kind":"pr","value":"#65609","number":65609},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#65587","number":65587},{"kind":"pr","value":"#65609","number":65609}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":66563,"title":"fix(model-switch): route reasoning models to Responses API on switch + persist api_mode globally","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/slash_commands.py","hermes_cli/model_switch.py","hermes_cli/providers.py","tests/gateway/test_25107_stale_base_url_api_mode.py","tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py","tests/hermes_cli/test_model_switch_openai_api_mode.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -7979,6 +7979,14 @@ def _apply_model_switch_result(self, result, persist_global: bool) -> None:\n             save_config_value(\"model.default\", result.new_model)\n             if result.provider_changed:\n                 save_config_value(\"model.provider\", result.target_provider)\n+            # base_url/api_mode were previously never persisted here, so a\n+            # global switch left the OLD provider's endpoint/wire-protocol in\n+            # config.yaml. result.base_url/api_mode are always freshly\n+            # resolved for the target provider (see model_switch.py), so sync\n+            # them every time; None clears a value the new provider doesn't\n+            # need (#25106).\n+            save_config_value(\"model.base_url\", result.base_url or None)\n+            save_config_value(\"model.api_mode\", result.api_mode or None)\n             _cprint(\"    Saved to config.yaml (--global)\")\n         else:\n             _cprint(\"    (session only — add --global to persist)\")","@@ -8291,6 +8299,10 @@ def _handle_model_switch(self, cmd_original: str):\n             save_config_value(\"model.default\", result.new_model)\n             if result.provider_changed:\n                 save_config_value(\"model.provider\", result.target_provider)\n+            # See _apply_model_switch_result above for why base_url/api_mode\n+            # must be synced on every global switch (#25106).\n+            save_config_value(\"model.base_url\", result.base_url or None)\n+            save_config_value(\"model.api_mode\", result.api_mode or None)\n             _cprint(\"    Saved to config.yaml\")\n         else:\n             _cprint(\"    (session only — add --global to persist)\")"]},{"file":"gateway/slash_commands.py","hunks":["@@ -1723,9 +1723,24 @@ async def _on_model_selected_scoped(\n                                     _persist_cfg[\"model\"] = _persist_model_cfg\n                                 _persist_model_cfg[\"default\"] = result.new_model\n                                 _persist_model_cfg[\"provider\"] = result.target_provider\n+                                # Named providers always resolve base_url/api_mode fresh,\n+                                # so any leftover is cleared unconditionally below. Custom\n+                                # providers have no registry entry to re-derive from, so\n+                                # they need an explicit set-or-clear here — the previous\n+                                # lone `if result.base_url:` left a stale base_url behind\n+                                # when switching to a custom provider whose resolver\n+                                # returned an empty base_url (#25107).\n+                                _is_custom_target = str(result.target_provider or \"\").strip().lower() == \"custom\"\n                                 if result.base_url:\n                                     _persist_model_cfg[\"base_url\"] = result.base_url\n-                                if str(result.target_provider or \"\").strip().lower() != \"custom\":\n+                                elif _is_custom_target:\n+                                    _persist_model_cfg.pop(\"base_url\", None)\n+                                if _is_custom_target:\n+                                    if result.api_mode:\n+                                        _persist_model_cfg[\"api_mode\"] = result.api_mode\n+                                    else:\n+                                        _persist_model_cfg.pop(\"api_mode\", None)\n+                                else:\n                                     clear_model_endpoint_credentials(_persist_model_cfg, clear_base_url=True)\n                                 from hermes_cli.config import save_config\n                                 save_config(_persist_cfg)","@@ -1988,9 +2003,19 @@ async def _finish_switch() -> str:\n                         cfg[\"model\"] = model_cfg\n                     model_cfg[\"default\"] = result.new_model\n                     model_cfg[\"provider\"] = result.target_provider\n+                    # See the picker handler above for why custom providers need an\n+                    # explicit set-or-clear instead of the old lone truthy check (#25107).\n+                    _is_custom_target = str(result.target_provider or \"\").strip().lower() == \"custom\"\n                     if result.base_url:\n                         model_cfg[\"base_url\"] = result.base_url\n-                    if str(result.target_provider or \"\").strip().lower() != \"custom\":\n+                    elif _is_custom_target:\n+                        model_cfg.pop(\"base_url\", None)\n+                    if _is_custom_target:\n+                        if result.api_mode:\n+                            model_cfg[\"api_mode\"] = result.api_mode\n+                        else:\n+                            model_cfg.pop(\"api_mode\", None)\n+                    else:\n                         clear_model_endpoint_credentials(model_cfg, clear_base_url=True)\n                     from hermes_cli.config import save_config\n                     save_config(cfg)"]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -30,6 +30,7 @@\n     custom_provider_slug,\n     determine_api_mode,\n     get_label,\n+    host_mandated_api_mode,\n     is_aggregator,\n     resolve_provider_full,\n )","@@ -1255,6 +1256,21 @@ def switch_model(\n             if not api_key:\n                 [redacted:secret-assignment] = \"no-key-required\"\n \n+    # --- Resolve api_mode from the final (provider, base_url) before validation ---\n+    # Two cases this closes, both surfaced when the switched model's reasoning\n+    # is actually applied (post the reasoning-unification refactor):\n+    #   1. api_mode empty (e.g. alias cleared it above) → fill from the endpoint.\n+    #   2. api_mode carried a STALE value from the previous session state\n+    #      (e.g. a same-provider /model switch to gpt-5.x on api.openai.com that\n+    #      kept the prior openrouter/chat_completions mode). A host that mandates\n+    #      one wire protocol must override the stale value — otherwise the request\n+    #      goes out on chat_completions and OpenAI 400s on tools+reasoning_effort.\n+    _mandated_mode = host_mandated_api_mode(base_url)\n+    if _mandated_mode is not None:\n+        api_mode = _mandated_mode\n+    elif not api_mode:\n+        api_mode = determine_api_mode(target_provider, base_url)\n+\n     # --- Normalize model name for target provider ---\n     new_model = normalize_model_for_provider(new_model, target_provider)\n "]},{"file":"hermes_cli/providers.py","hunks":["@@ -549,45 +549,61 @@ def is_routing_aggregator(provider: str) -> bool:\n     return is_aggregator(provider_norm)\n \n \n+def host_mandated_api_mode(base_url: str = \"\") -> Optional[str]:\n+    \"\"\"Return the wire protocol a specific endpoint *requires*, or None.\n+\n+    Some hosts only accept one API mode and reject the others outright:\n+      - api.openai.com only accepts the Responses API for its (reasoning)\n+        models when tools + reasoning are in play (chat/completions 400s).\n+      - api.anthropic.com / ``…/anthropic`` suffixes speak native Messages.\n+      - Kimi's ``/coding`` endpoint speaks native Messages.\n+      - AWS Bedrock runtime hosts speak Converse.\n+\n+    These are *mandatory* — a session carrying a stale api_mode (e.g. a\n+    /model switch that kept the previous provider's ``chat_completions``)\n+    must be overridden to the host's required mode, not merely filled in\n+    when empty. Generic / unknown endpoints return None so an explicitly\n+    configured api_mode on them is never clobbered.\n+    \"\"\"\n+    if not base_url:\n+        return None\n+    url_lower = base_url.rstrip(\"/\").lower()\n+    hostname = base_url_hostname(base_url)\n+    # Exact-hostname matching only — never bare substring — so lookalike hosts\n+    # (api.openai.com.attacker.test) and path-segment spoofs\n+    # (proxy.test/api.openai.com/v1) are NOT treated as the real endpoint. (#32243)\n+    if hostname == \"api.kimi.com\" and \"/coding\" in url_lower:\n+        return \"anthropic_messages\"\n+    if hostname == \"api.anthropic.com\" or url_lower.endswith(\"/anthropic\"):\n+        return \"anthropic_messages\"\n+    if hostname == \"api.openai.com\":\n+        return \"codex_responses\"\n+    if hostname.startswith(\"bedrock-runtime.\") and base_url_host_matches(base_url, \"amazonaws.com\"):\n+        return \"bedrock_converse\"\n+    return None\n+\n+\n def determine_api_mode(provider: str, base_url: str = \"\") -> str:\n     \"\"\"Determine the API mode (wire protocol) for a provider/endpoint.\n \n     Resolution order:\n-      1. Known provider → transport → TRANSPORT_TO_API_MODE.\n-      2. URL heuristics for unknown / custom providers.\n-      3. Default: 'chat_completions'.\n+      1. Host-mandated mode (special endpoints that only accept one protocol).\n+      2. Known provider → transport → TRANSPORT_TO_API_MODE.\n+      3. Direct provider checks (bedrock).\n+      4. Default: 'chat_completions'.\n     \"\"\"\n+    mandated = host_mandated_api_mode(base_url)\n+    if mandated is not None:\n+        return mandated\n+\n     pdef = get_provider(provider)\n     if pdef is not None:\n-        # Even for known providers, check URL heuristics for special endpoints\n-        # (e.g. kimi /coding endpoint needs anthropic_messages even on 'custom')\n-        if base_url:\n-            url_lower = base_url.rstrip(\"/\").lower()\n-            if \"api.kimi.com/coding\" in url_lower:\n-                return \"anthropic_messages\"\n-            if url_lower.endswith(\"/anthropic\") or \"api.anthropic.com\" in url_lower:\n-                return \"anthropic_messages\"\n-            if \"api.openai.com\" in url_lower:\n-                return \"codex_responses\"\n         return TRANSPORT_TO_API_MODE.get(pdef.transport, \"chat_completions\")\n \n     # Direct provider checks for providers not in HERMES_OVERLAYS\n     if provider == \"bedrock\":\n         return \"bedrock_converse\"\n \n-    # URL-based heuristics for custom / unknown providers\n-    if base_url:\n-        url_lower = base_url.rstrip(\"/\").lower()\n-        hostname = base_url_hostname(base_url)\n-        if url_lower.endswith(\"/anthropic\") or hostname == \"api.anthropic.com\":\n-            return \"anthropic_messages\"\n-        if hostname == \"api.kimi.com\" and \"/coding\" in url_lower:\n-            return \"anthropic_messages\"\n-        if hostname == \"api.openai.com\":\n-            return \"codex_responses\"\n-        if hostname.startswith(\"bedrock-runtime.\") and base_url_host_matches(base_url, \"amazonaws.com\"):\n-            return \"bedrock_converse\"\n-\n     return \"chat_completions\"\n \n "]},{"file":"tests/gateway/test_25107_stale_base_url_api_mode.py","hunks":["@@ -0,0 +1,194 @@\n+\"\"\"Regression tests for #25107: gateway /model switch left a stale\n+``base_url``/never persisted ``api_mode`` in config.yaml when switching to a\n+custom provider whose resolver returned an empty ``base_url``.\n+\n+Root cause: both the picker-tap path (``_on_model_selected`` in\n+``gateway/slash_commands.py``) and the typed ``/model X --global`` path\n+(``_finish_switch``) guarded the persist block with two *independent*\n+``if``s:\n+\n+    if result.base_url:\n+        model_cfg[\"base_url\"] = result.base_url\n+    if target_provider != \"custom\":\n+        clear_model_endpoint_credentials(model_cfg, clear_base_url=True)\n+\n+For a NAMED provider the second ``if`` always clears any stale value, so the\n+bug was invisible there. But for a ``custom`` provider with an empty\n+resolved ``base_url``, NEITHER branch fires: the old base_url survives\n+untouched, and ``api_mode`` was never written to ``model_cfg`` at all in\n+either branch (only present in the in-memory session override).\n+\n+Fix: an explicit set-or-clear for both fields when the target provider is\n+custom, so a genuine switch always leaves config.yaml coherent with the\n+resolved result.\n+\"\"\"\n+\n+import types\n+\n+import yaml\n+import pytest\n+\n+from gateway.config import Platform\n+from gateway.platforms.base import MessageEvent, MessageType\n+from gateway.run import GatewayRunner\n+from gateway.session import SessionSource\n+\n+\n+class _FakePickerAdapter:\n+    def __init__(self):\n+        self.captured_callback = None\n+\n+    async def send_model_picker(self, *, on_model_selected, **kwargs):\n+        self.captured_callback = on_model_selected\n+        return types.SimpleNamespace(success=True)\n+\n+\n+def _make_runner(adapter=None):\n+    runner = object.__new__(GatewayRunner)\n+    runner.adapters = {Platform.TELEGRAM: adapter} if adapter else {}\n+    runner._voice_mode = {}\n+    runner._session_model_overrides = {}\n+    runner._running_agents = {}\n+    return runner\n+\n+\n+def _make_event(text):\n+    return MessageEvent(\n+        text=text,\n+        message_type=MessageType.TEXT,\n+        source=SessionSource(platform=Platform.TELEGRAM, chat_id=\"12345\", chat_type=\"dm\"),\n+    )\n+\n+\n+def _fake_switch_result(*, base_url=\"\", api_mode=\"\"):\n+    from hermes_cli.model_switch import ModelSwitchResult\n+\n+    return ModelSwitchResult(\n+        success=True,\n+        new_model=\"local-llama\",\n+        target_provider=\"custom\",\n+        provider_changed=True,\n+        api_key=\"[redacted:secret-assignment]\",\n+        base_url=base_url,\n+        api_mode=api_mode,\n+        provider_label=\"Custom\",\n+        is_global=True,\n+    )\n+\n+\n+def _setup_isolated_home(tmp_path, monkeypatch, model_yaml_value, *, base_url=\"\", api_mode=\"\"):\n+    import gateway.run as gateway_run\n+\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    cfg_path = hermes_home / \"config.yaml\"\n+    cfg_path.write_text(\n+        yaml.safe_dump({\"model\": model_yaml_value, \"providers\": {}}),\n+        encoding=\"utf-8\",\n+    )\n+\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", hermes_home)\n+    monkeypatch.setattr(\"agent.models_dev.fetch_models_dev\", lambda: {})\n+    monkeypatch.setattr(\n+        \"hermes_cli.model_switch.list_picker_providers\",\n+        lambda **kw: [{\"slug\": \"custom\", \"name\": \"Custom\", \"models\": [\"local-llama\"]}],\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.model_switch.switch_model\",\n+        lambda **kw: _fake_switch_result(base_url=base_url, api_mode=api_mode),\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.model_switch.resolve_display_context_length\",\n+        lambda *a, **k: 8192,\n+    )\n+    monkeypatch.setattr(\"hermes_constants.get_hermes_home\", lambda: hermes_home)\n+    monkeypatch.setattr(\"hermes_cli.config.get_hermes_home\", lambda: hermes_home)\n+    return cfg_path\n+\n+\n+_STALE_MODEL_CFG = {\n+    \"default\": \"old-custom-model\",\n+    \"provider\": \"custom\",\n+    \"base_url\": \"https://old-stale-endpoint.example/v1\",\n+    \"api_key\": \"sk-stale\",\n+    \"api_mode\": \"anthropic_messages\",\n+}\n+\n+\n+# ---------------------------------------------------------------------------\n+# Typed `/model X --global` path (_finish_switch)\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_typed_switch_to_custom_clears_stale_base_url_and_api_mode(tmp_path, monkeypatch):\n+    \"\"\"Switching to a custom provider whose resolver returns no base_url/\n+    api_mode must clear the previous custom endpoint's leftovers, not keep\n+    routing at the old host/protocol (#25107).\"\"\"\n+    cfg_path = _setup_isolated_home(tmp_path, monkeypatch, dict(_STALE_MODEL_CFG))\n+\n+    result = await _make_runner()._handle_model_command(\n+        _make_event(\"/model local-llama --provider custom --global\")\n+    )\n+\n+    assert result is not None\n+    written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n+    assert written[\"model\"][\"default\"] == \"local-llama\"\n+    assert \"base_url\" not in written[\"model\"], (\n+        \"stale base_url from the old custom endpoint must be cleared\"\n+    )\n+    assert \"api_mode\" not in written[\"model\"], (\n+        \"stale api_mode from the old custom endpoint must be cleared\"\n+    )\n+\n+\n[redacted:email]\n+async def test_typed_switch_to_custom_persists_resolved_base_url_and_api_mode(tmp_path, monkeypatch):\n+    \"\"\"The normal case: a custom-provider switch that DOES resolve a fresh\n+    base_url/api_mode must persist both (api_mode was never written here\n+    before the fix).\"\"\"\n+    cfg_path = _setup_isolated_home(\n+        tmp_path,\n+        monkeypatch,\n+        dict(_STALE_MODEL_CFG),\n+        base_url=\"https://new-endpoint.example/v1\",\n+        api_mode=\"anthropic_messages\",\n+    )\n+\n+    result = await _make_runner()._handle_model_command(\n+        _make_event(\"/model local-llama --provider custom --global\")\n+    )\n+\n+    assert result is not None\n+    written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n+    assert written[\"model\"][\"base_url\"] == \"https://new-endpoint.example/v1\"\n+    assert written[\"model\"][\"api_mode\"] == \"anthropic_messages\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Picker-tap path (_on_model_selected)\n+# ---------------------------------------------------------------------------\n+\n+\n+async def _drive_picker(runner, event):\n+    sent = await runner._handle_model_command(event)\n+    assert sent is None\n+    adapter = runner.adapters[Platform.TELEGRAM]\n+    assert adapter.captured_callback is not None, \"picker callback was not wired\"\n+    return await adapter.captured_callback(\"12345\", \"local-llama\", \"custom\")\n+\n+\n[redacted:email]\n+async def test_picker_tap_to_custom_clears_stale_base_url_and_api_mode(tmp_path, monkeypatch):\n+    \"\"\"Same bug, picker-tap path: tapping a custom-provider model with an\n+    empty resolved base_url must clear the previous custom endpoint (#25107).\n+    \"\"\"\n+    adapter = _FakePickerAdapter()\n+    cfg_path = _setup_isolated_home(tmp_path, monkeypatch, dict(_STALE_MODEL_CFG))\n+\n+    confirmation = await _drive_picker(_make_runner(adapter), _make_event(\"/model\"))\n+\n+    assert confirmation is not None\n+    written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n+    assert \"base_url\" not in written[\"model\"]\n+    assert \"api_mode\" not in written[\"model\"]"]},{"file":"tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py","hunks":["@@ -0,0 +1,160 @@\n+\"\"\"Regression tests for #25106: CLI `/model <name> --global` never persisted\n+``model.base_url``/``model.api_mode`` to config.yaml, so a global provider\n+switch left the PREVIOUS provider's endpoint/wire-protocol on disk. The next\n+`hermes` launch re-read the stale base_url and routed the new model at the\n+old host.\n+\n+Both ``_handle_model_switch`` (typed ``/model <name>``) and\n+``_apply_model_switch_result`` (interactive picker) shared the same gap: the\n+persistence block wrote ``model.default``/``model.provider`` but never\n+touched ``base_url``/``api_mode`` at all. Fix: sync both on every global\n+switch, clearing to ``None`` when the resolved result doesn't need them —\n+mirroring the already-correct ``tui_gateway/server.py:_persist_model_switch``\n+pattern (fixed for #48305).\n+\"\"\"\n+\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+from hermes_cli.model_switch import ModelSwitchResult\n+\n+\n+def _make_result(*, base_url=\"https://api.minimax.io/v1\", api_mode=\"chat_completions\", provider_changed=True):\n+    return ModelSwitchResult(\n+        success=True,\n+        new_model=\"MiniMax-M3\",\n+        target_provider=\"custom:minimax\",\n+        provider_changed=provider_changed,\n+        api_key=\"[redacted:secret-assignment]\",\n+        base_url=base_url,\n+        api_mode=api_mode,\n+        warning_message=\"\",\n+        provider_label=\"MiniMax (custom)\",\n+        resolved_via_alias=False,\n+        capabilities=None,\n+        model_info=None,\n+        is_global=True,\n+    )\n+\n+\n+class _StubCLI:\n+    \"\"\"Minimum attrs/methods `_handle_model_switch` reads or calls on self.\"\"\"\n+\n+    agent = None\n+    model = \"old-model\"\n+    provider = \"copilot\"\n+    requested_provider = \"copilot\"\n+    api_key = \"[redacted:secret-assignment]\"\n+    base_url = \"https://api.githubcopilot.com\"\n+    api_mode = \"chat_completions\"\n+    _explicit_api_key = \"\"\n+    _explicit_base_url = \"\"\n+    conversation_history = []\n+    _pending_model_switch_note = \"\"\n+\n+    def _confirm_expensive_model_switch(self, result) -> bool:\n+        return True\n+\n+    def _open_model_picker(self, *a, **k):\n+        raise AssertionError(\"picker should not open when a model name is given\")\n+\n+\n+def _run_switch(monkeypatch, result, cmd=\"/model MiniMax-M3 --global\"):\n+    import cli as cli_mod\n+\n+    monkeypatch.setattr(cli_mod, \"_cprint\", lambda *a, **k: None)\n+    saved: dict[str, object] = {}\n+\n+    def _fake_save(key, value):\n+        saved[key] = value\n+\n+    monkeypatch.setattr(cli_mod, \"save_config_value\", _fake_save)\n+    monkeypatch.setattr(\"hermes_cli.model_switch.switch_model\", lambda **kw: result)\n+    monkeypatch.setattr(\n+        \"hermes_cli.inventory.load_picker_context\",\n+        lambda: (_ for _ in ()).throw(RuntimeError(\"no picker context in test\")),\n+    )\n+    cli_mod.HermesCLI._handle_model_switch(_StubCLI(), cmd)\n+    return saved\n+\n+\n+def test_global_switch_persists_base_url_and_api_mode(monkeypatch):\n+    \"\"\"The core #25106 fix: a --global switch to a new provider/endpoint must\n+    write the newly resolved base_url and api_mode, not just default/provider.\"\"\"\n+    saved = _run_switch(monkeypatch, _make_result())\n+\n+    assert saved[\"model.default\"] == \"MiniMax-M3\"\n+    assert saved[\"model.provider\"] == \"custom:minimax\"\n+    assert saved[\"model.base_url\"] == \"https://api.minimax.io/v1\"\n+    assert saved[\"model.api_mode\"] == \"chat_completions\"\n+\n+\n+def test_global_switch_clears_base_url_and_api_mode_when_unresolved(monkeypatch):\n+    \"\"\"When the resolver returns no base_url/api_mode for the new provider\n+    (e.g. a named provider needing neither), any previous value must be\n+    cleared (None) rather than silently left in config.yaml.\"\"\"\n+    result = _make_result(base_url=\"\", api_mode=\"\")\n+    saved = _run_switch(monkeypatch, result)\n+\n+    assert saved[\"model.base_url\"] is None\n+    assert saved[\"model.api_mode\"] is None\n+\n+\n+def test_session_only_switch_does_not_touch_config(monkeypatch):\n+    \"\"\"--session must not call save_config_value at all — persistence stays\n+    entirely in-memory.\"\"\"\n+    import cli as cli_mod\n+\n+    monkeypatch.setattr(cli_mod, \"_cprint\", lambda *a, **k: None)\n+    save_calls = []\n+    monkeypatch.setattr(cli_mod, \"save_config_value\", lambda *a, **k: save_calls.append(a))\n+    monkeypatch.setattr(\"hermes_cli.model_switch.switch_model\", lambda **kw: _make_result())\n+    monkeypatch.setattr(\n+        \"hermes_cli.inventory.load_picker_context\",\n+        lambda: (_ for _ in ()).throw(RuntimeError(\"no picker context in test\")),\n+    )\n+\n+    cli_mod.HermesCLI._handle_model_switch(_StubCLI(), \"/model MiniMax-M3 --session\")\n+\n+    assert save_calls == []\n+\n+\n+def _run_apply(monkeypatch, result, persist_global=True):\n+    \"\"\"Drives `_apply_model_switch_result` directly — the interactive-picker\n+    sibling of `_handle_model_switch`. Unlike the tests above, and unlike\n+    `test_apply_model_switch_result_context.py` (which only ever calls this\n+    method with `persist_global=False`), this exercises the `persist_global=True`\n+    branch that actually writes to config.yaml.\"\"\"\n+    import cli as cli_mod\n+\n+    monkeypatch.setattr(cli_mod, \"_cprint\", lambda *a, **k: None)\n+    saved: dict[str, object] = {}\n+\n+    def _fake_save(key, value):\n+        saved[key] = value\n+\n+    monkeypatch.setattr(cli_mod, \"save_config_value\", _fake_save)\n+    cli_mod.HermesCLI._apply_model_switch_result(_StubCLI(), result, persist_global)\n+    return saved\n+\n+\n+def test_picker_global_switch_persists_base_url_and_api_mode(monkeypatch):\n+    \"\"\"Picker-path counterpart of `test_global_switch_persists_base_url_and_api_mode`:\n+    `_apply_model_switch_result(..., persist_global=True)` must sync base_url/api_mode\n+    too, not just default/provider.\"\"\"\n+    saved = _run_apply(monkeypatch, _make_result())\n+\n+    assert saved[\"model.default\"] == \"MiniMax-M3\"\n+    assert saved[\"model.provider\"] == \"custom:minimax\"\n+    assert saved[\"model.base_url\"] == \"https://api.minimax.io/v1\"\n+    assert saved[\"model.api_mode\"] == \"chat_completions\"\n+\n+\n+def test_picker_global_switch_clears_base_url_and_api_mode_when_unresolved(monkeypatch):\n+    \"\"\"Picker-path counterpart of `test_global_switch_clears_base_url_and_api_mode_when_unresolved`.\"\"\"\n+    result = _make_result(base_url=\"\", api_mode=\"\")\n+    saved = _run_apply(monkeypatch, result)\n+\n+    assert saved[\"model.base_url\"] is None\n+    assert saved[\"model.api_mode\"] is None"]},{"file":"tests/hermes_cli/test_model_switch_openai_api_mode.py","hunks":["@@ -0,0 +1,113 @@\n+\"\"\"Regression tests for OpenAI-direct api_mode recomputation during /model switch.\n+\n+api.openai.com only accepts the Responses API (codex_responses) for its\n+reasoning models when tools + reasoning are in play — /v1/chat/completions\n+returns HTTP 400 (\"Function tools with reasoning_effort are not supported\").\n+\n+When a session switches to a GPT-5.x model on api.openai.com while carrying a\n+stale ``chat_completions`` api_mode from the previous provider (e.g. an\n+openrouter default), the switch must override the stale value with the\n+host-mandated ``codex_responses``. Filling only when empty (the earlier fix)\n+was insufficient: the carried value was a *non-empty but wrong* mode.\n+\n+This surfaced after the reasoning-unification refactor made the switched\n+model's reasoning effort actually apply, turning a silently-dropped\n+reasoning_effort into a live one that OpenAI 400s on the chat_completions path.\n+\"\"\"\n+\n+from unittest.mock import patch\n+\n+from hermes_cli.model_switch import switch_model\n+\n+_MOCK_VALIDATION = {\n+    \"accepted\": True,\n+    \"persist\": True,\n+    \"recognized\": True,\n+    \"message\": None,\n+}\n+\n+\n+def _run_openai_switch(\n+    raw_input: str,\n+    current_provider: str = \"openrouter\",\n+    current_model: str = \"anthropic/claude-opus-4.8\",\n+    explicit_provider: str = \"openai-api\",\n+    runtime_api_mode: str = \"chat_completions\",\n+    runtime_base_url: str = \"https://api.openai.com/v1\",\n+):\n+    \"\"\"Run switch_model with OpenAI-direct mocks and return the result.\"\"\"\n+    with (\n+        patch(\"hermes_cli.model_switch.resolve_alias\", return_value=None),\n+        patch(\"hermes_cli.model_switch.list_provider_models\", return_value=[]),\n+        patch(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            return_value={\n+                \"api_key\": \"sk-test\",\n+                \"base_url\": runtime_base_url,\n+                \"api_mode\": runtime_api_mode,\n+            },\n+        ),\n+        patch(\n+            \"hermes_cli.models.validate_requested_model\",\n+            return_value=_MOCK_VALIDATION,\n+        ),\n+        patch(\"hermes_cli.model_switch.get_model_info\", return_value=None),\n+        patch(\"hermes_cli.model_switch.get_model_capabilities\", return_value=None),\n+        patch(\"hermes_cli.models.detect_provider_for_model\", return_value=None),\n+    ):\n+        return switch_model(\n+            raw_input=raw_input,\n+            current_provider=current_provider,\n+            current_model=current_model,\n+            explicit_provider=explicit_provider,\n+        )\n+\n+\n+def test_stale_chat_completions_overridden_on_openai_direct():\n+    \"\"\"The incident: stale chat_completions on api.openai.com → codex_responses.\n+\n+    Switching from an openrouter/chat_completions session to gpt-5.6-sol on\n+    api.openai.com must flip the wire protocol to the Responses API so tools +\n+    reasoning are preserved, instead of 400ing on chat/completions.\n+    \"\"\"\n+    result = _run_openai_switch(\n+        raw_input=\"gpt-5.6-sol\",\n+        current_provider=\"openrouter\",\n+        current_model=\"anthropic/claude-opus-4.8\",\n+        explicit_provider=\"openai-api\",\n+        runtime_api_mode=\"chat_completions\",  # stale value carried over\n+    )\n+\n+    assert result.success, f\"switch_model failed: {result.error_message}\"\n+    assert result.target_provider == \"openai-api\"\n+    assert result.new_model == \"gpt-5.6-sol\"\n+    assert result.api_mode == \"codex_responses\"\n+\n+\n+def test_empty_api_mode_filled_on_openai_direct():\n+    \"\"\"An empty runtime api_mode on api.openai.com resolves to codex_responses.\"\"\"\n+    result = _run_openai_switch(\n+        raw_input=\"gpt-5.6-sol\",\n+        runtime_api_mode=\"\",  # empty — the earlier fill-if-empty subcase\n+    )\n+\n+    assert result.success, f\"switch_model failed: {result.error_message}\"\n+    assert result.api_mode == \"codex_responses\"\n+\n+\n+def test_generic_endpoint_keeps_explicit_api_mode():\n+    \"\"\"A generic (non-host-mandated) endpoint must NOT have its api_mode clobbered.\n+\n+    Only hosts that mandate one wire protocol override a carried value; a\n+    generic OpenAI-compatible relay returns None from host_mandated_api_mode,\n+    so the switch path leaves the resolver's api_mode untouched.\n+    \"\"\"\n+    from hermes_cli.providers import host_mandated_api_mode\n+\n+    assert host_mandated_api_mode(\"https://generic.example.com/v1\") is None\n+    # Lookalike / path-spoof hosts must also NOT be treated as mandated (#32243).\n+    assert host_mandated_api_mode(\"https://api.openai.com.attacker.test/v1\") is None\n+    assert host_mandated_api_mode(\"https://proxy.test/api.openai.com/v1\") is None\n+    # The real endpoints are mandated.\n+    assert host_mandated_api_mode(\"https://api.openai.com/v1\") == \"codex_responses\"\n+    assert host_mandated_api_mode(\"https://api.anthropic.com\") == \"anthropic_messages\"\n"]}]},{"kind":"pr","number":68403,"title":"fix(profiles): count global + external skills in _count_skills","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"98b0d653954d","number":0},{"kind":"pr","value":"#68403","number":68403},{"kind":"pr","value":"#68403","number":68403},{"kind":"pr","value":"#75798","number":75798},{"kind":"pr","value":"#68403","number":68403},{"kind":"pr","value":"#68403","number":68403},{"kind":"pr","value":"#75798","number":75798},{"kind":"pr","value":"#68403","number":68403},{"kind":"pr","value":"#75798","number":75798},{"kind":"pr","value":"#68403","number":68403},{"kind":"pr","value":"#68403","number":68403},{"kind":"pr","value":"#75798","number":75798},{"kind":"pr","value":"#68403","number":68403},{"kind":"pr","value":"#75798","number":75798},{"kind":"pr","value":"#68403","number":68403},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#68403","number":68403},{"kind":"pr","value":"#75798","number":75798}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":68763,"title":"fix(cron): pass target_model to resolve_runtime_provider to prevent base_url mismatch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"786df3ca6cf3fa063391491caa63d7ffad17e286","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":68846,"title":"fix(cron): pass target_model to resolve_runtime_provider to prevent base_url mismatch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"786df3ca6cf3fa063391491caa63d7ffad17e286","number":0},{"kind":"pr","value":"#68403","number":68403}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":69818,"title":"fix(cli): persist provider on global model switch","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/hermes_cli/test_25106_global_switch_persists_base_url_api_mode.py"]},{"kind":"pr","number":105869,"title":"fix(cli): resolve Bedrock wire from the session model at CLI startup","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"0d07248d9","number":0},{"kind":"commit","value":"284d06cab","number":0},{"kind":"pr","value":"#50292","number":50292}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":113563,"title":"fix(cli): resolve runtime using overridden model (#65587, salvage #65609)","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}