{"repo":"NousResearch/hermes-agent","public_id":"47828","display_label":"Stale base_url retained when switching model provider","summary":"These items appear to concern one theme: when a model or provider is switched in place (e.g. via /mode), the previous provider's base_url may stay attached to the new model, with one report describing a 400 response. Several members are titled as fixes that clear or refuse the stale base_url, but their actual status is not established here.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60985,"merged_at":"2026-07-09T23:36:07+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-09T23:36:07+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":47828,"pr_number":60985,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-09T23:36:07+00:00"},{"issue_number":61296,"pr_number":60985,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-09T23:36:07+00:00"}],"members":[{"kind":"issue","number":47828,"title":"[Bug]: /mode in-place model switch keeps the OLD provider's endpoint — new model fired at previous provider's base_url → 400","url":null,"state":"closed"},{"kind":"pr","number":47934,"title":"fix(model): clear stale base URL on provider switch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a23d5073f","number":0},{"kind":"pr","value":"#47828","number":47828},{"kind":"pr","value":"#60931","number":60931}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":60931,"title":"fix(agent): switch_model no longer pairs new provider with stale base_url","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a23d5073f","number":0},{"kind":"pr","value":"#60985","number":60985},{"kind":"pr","value":"#47828","number":47828}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":60985,"title":"fix(agent): switch_model refuses stale base_url on provider change (#47828)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","tests/run_agent/test_switch_model_stale_base_url.py"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -1805,13 +1805,30 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo\n         # ── Swap core runtime fields ──\n         agent.model = new_model\n         agent.provider = new_provider\n-        # Use new base_url when provided; only fall back to current when the\n-        # new provider genuinely has no endpoint (e.g. native SDK providers).\n-        # Without this guard the old provider's URL (e.g. Ollama's localhost\n-        # address) would persist silently after switching to a cloud provider\n-        # that returns an empty base_url string.\n+        # Use the new base_url when provided. When it's empty AND the\n+        # provider is actually changing, do NOT fall back to the current\n+        # (old provider's) URL — that silently pairs the new provider label\n+        # with the previous provider's endpoint (e.g. new_provider=minimax\n+        # paired with the leftover api.githubcopilot.com URL), and every\n+        # request after the switch 400s at the wrong host. This mismatched\n+        # pair also gets snapshotted into _primary_runtime below, so it\n+        # keeps re-applying on every subsequent turn until a full restart.\n+        # Fail loud instead: the caller (model_switch.switch_model())\n+        # already resolves base_url for every real provider, so an empty\n+        # value here means resolution failed upstream, not that the\n+        # provider genuinely has none. Re-selecting the SAME provider with\n+        # an empty base_url (e.g. a credential-only refresh) is still fine\n+        # to keep the current URL. See #47828.\n+        old_norm_provider = (old_provider or \"\").strip().lower()\n+        new_norm_provider = (new_provider or \"\").strip().lower()\n         if base_url:\n             agent.base_url = base_url\n+        elif old_norm_provider != new_norm_provider:\n+            raise ValueError(\n+                f\"switch_model: no base_url resolved for provider \"\n+                f\"'{new_provider}' (switching from '{old_provider}'); \"\n+                \"refusing to keep the previous provider's endpoint\"\n+            )\n         agent.api_mode = api_mode\n         # Invalidate transport cache — new api_mode may need a different transport\n         if hasattr(agent, \"_transport_cache\"):"]},{"file":"tests/run_agent/test_switch_model_stale_base_url.py","hunks":["@@ -0,0 +1,87 @@\n+\"\"\"Regression tests for #47828: switch_model must not pair a new provider\n+label with the previous provider's base_url when the resolver returns no\n+new base_url for a genuine provider change.\n+\"\"\"\n+\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+from run_agent import AIAgent\n+from agent.context_compressor import ContextCompressor\n+\n+\n+def _make_agent_with_compressor(provider=\"copilot\", base_url=\"https://api.githubcopilot.com\") -> AIAgent:\n+    \"\"\"Build a minimal AIAgent with a context_compressor, skipping __init__.\"\"\"\n+    agent = AIAgent.__new__(AIAgent)\n+\n+    agent.model = \"claude-opus-4.8\"\n+    agent.provider = provider\n+    agent.base_url = base_url\n+    agent.api_key = \"[redacted:secret-assignment]\"\n+    agent.api_mode = \"chat_completions\"\n+    agent.client = MagicMock()\n+    agent.quiet_mode = True\n+    agent._config_context_length = None\n+\n+    compressor = ContextCompressor(\n+        model=agent.model,\n+        threshold_percent=0.50,\n+        base_url=base_url,\n+        api_key=\"[redacted:secret-assignment]\",\n+        provider=provider,\n+        quiet_mode=True,\n+        config_context_length=None,\n+    )\n+    agent.context_compressor = compressor\n+    agent._primary_runtime = {}\n+\n+    return agent\n+\n+\n+@patch(\"agent.model_metadata.get_model_context_length\", return_value=131_072)\n+def test_switch_model_rejects_stale_base_url_on_provider_change(mock_ctx_len):\n+    \"\"\"A provider change with no resolved base_url must fail loud instead of\n+    silently keeping the previous provider's endpoint (#47828).\"\"\"\n+    agent = _make_agent_with_compressor(provider=\"copilot\", base_url=\"https://api.githubcopilot.com\")\n+\n+    with pytest.raises(ValueError, match=\"no base_url resolved\"):\n+        agent.switch_model(\"MiniMax-M3\", \"custom:minimax\", api_key=\"[redacted:secret-assignment]\", base_url=\"\")\n+\n+    # Rollback must leave the agent fully on the old (provider, base_url) pair —\n+    # not a mismatched new-model/old-endpoint hybrid.\n+    assert agent.provider == \"copilot\"\n+    assert agent.base_url == \"https://api.githubcopilot.com\"\n+    assert agent.model == \"claude-opus-4.8\"\n+\n+\n+@patch(\"agent.model_metadata.get_model_context_length\", return_value=131_072)\n+def test_switch_model_allows_empty_base_url_for_same_provider(mock_ctx_len):\n+    \"\"\"Re-selecting the SAME provider (e.g. a credential-only refresh) with no\n+    new base_url must keep the current URL — this is not a provider change.\"\"\"\n+    agent = _make_agent_with_compressor(provider=\"openrouter\", base_url=\"https://openrouter.ai/api/v1\")\n+\n+    agent.switch_model(\"new-model\", \"openrouter\", api_key=\"[redacted:secret-assignment]\", base_url=\"\")\n+\n+    assert agent.provider == \"openrouter\"\n+    assert agent.base_url == \"https://openrouter.ai/api/v1\"\n+    assert agent.model == \"new-model\"\n+\n+\n+@patch(\"agent.model_metadata.get_model_context_length\", return_value=131_072)\n+def test_switch_model_applies_new_base_url_on_provider_change(mock_ctx_len):\n+    \"\"\"The normal, resolved-correctly path must still work: new provider +\n+    new base_url is applied as-is.\"\"\"\n+    agent = _make_agent_with_compressor(provider=\"copilot\", base_url=\"https://api.githubcopilot.com\")\n+\n+    agent.switch_model(\n+        \"MiniMax-M3\", \"custom:minimax\", api_key=\"[redacted:secret-assignment]\", base_url=\"https://api.minimax.io/v1\"\n+    )\n+\n+    assert agent.provider == \"custom:minimax\"\n+    assert agent.base_url == \"https://api.minimax.io/v1\"\n+    assert agent.model == \"MiniMax-M3\"\n+    # _primary_runtime must snapshot the coherent pair so it survives every\n+    # subsequent restore_primary_runtime() call across turns.\n+    assert agent._primary_runtime[\"provider\"] == \"custom:minimax\"\n+    assert agent._primary_runtime[\"base_url\"] == \"https://api.minimax.io/v1\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}