{"repo":"NousResearch/hermes-agent","public_id":"48305","display_label":"TUI model switching corrupts config persistence","summary":"These issues and PRs concern bugs where switching models in the TUI dashboard incorrectly overwrites provider attribution and destroys config values like model_slots and model_fallback. Proposed fixes involve targeted config writes, atomic updates, and guards against custom provider misattribution.","subsystem":"dashboard","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":51043,"merged_at":"2026-06-24T14:12:56+00:00"},"resolution":{"state":"resolved","by":"pr-51043"},"freshness":{"state":"verified","last_verified_at":"2026-06-24T14:12:56+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":48305,"pr_number":51043,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-24T14:12:56+00:00"}],"members":[{"kind":"issue","number":48305,"title":"[TUI] Model switch via dashboard --tui overwrites model.provider to wrong aggregator and destroys model_slots + model_fallback","url":null,"state":"closed"},{"kind":"pr","number":48351,"title":"fix(model-switch): prevent custom provider misattribution in TUI model picker","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":48391,"title":"fix(tui): use targeted save_config_value for model persistence (#48305)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":51043,"title":"fix(tui): preserve config on model switch — atomic writes + custom-provider guard (#48305)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/model_switch.py","hermes_cli/models.py","scripts/release.py","tests/hermes_cli/test_models.py","tests/test_tui_gateway_server.py","tui_gateway/server.py"],"fix_hunks":[{"file":"hermes_cli/model_switch.py","hunks":["@@ -1057,8 +1057,10 @@ def switch_model(\n \n         # --- Step e: detect_provider_for_model() as last resort ---\n         _base = current_base_url or \"\"\n-        is_custom = current_provider in {\"custom\", \"local\"} or (\n-            \"localhost\" in _base or \"127.0.0.1\" in _base\n+        is_custom = (\n+            current_provider in {\"custom\", \"local\"}\n+            or current_provider.startswith(\"custom:\")\n+            or (\"localhost\" in _base or \"127.0.0.1\" in _base)\n         )\n \n         if ("]},{"file":"hermes_cli/models.py","hunks":["@@ -1882,6 +1882,14 @@ def detect_static_provider_for_model(\n         return None\n \n     # --- Step 1: check static provider catalogs for a direct match ---\n+    # If the current provider is a custom endpoint (custom or custom:*), never\n+    # auto-switch away from it based on a static catalog match — the user\n+    # explicitly configured their own endpoint and the same model name may be\n+    # served there (#48305).\n+    _is_custom_current = (\n+        current_provider == \"custom\"\n+        or current_provider.startswith(\"custom:\")\n+    )\n     for pid, models in _PROVIDER_MODELS.items():\n         if (\n             pid in current_keys","@@ -1889,6 +1897,8 @@ def detect_static_provider_for_model(\n             or pid in _BORROWED_MODEL_PROVIDERS\n         ):\n             continue\n+        if _is_custom_current:\n+            continue\n         if any(name_lower == m.lower() for m in models):\n             return (pid, name)\n "]},{"file":"scripts/release.py","hunks":["@@ -1673,6 +1673,7 @@\n     \"[redacted:email]\": \"AMIK-coorporations\",  # PR #40578 (Urdu README) co-author\n     \"[redacted:email]\": \"AMIK-coorporations\",  # PR #40578 (Urdu README)\n     \"[redacted:email]\": \"kyssta-exe\",  # PR #44282 (Windows dashboard re-exec)\n+    \"30467832+Elshayib@users.noreply.github.com\": \"Elshayib\",  # PR #48351 (custom-provider misattribution guard; #48305)\n     \"[redacted:email]\": \"loongfay\",  # PR #43508 (Yuanbao wechat forward msg)\n     \"[redacted:email]\": \"BROCCOLO1D\",  # PR #42733 (lazy-parse docker env config)\n     \"[redacted:email]\": \"underthestars-zhy\",  # PR #46907 co-author (Photon Spectrum project ids)"]},{"file":"tests/hermes_cli/test_models.py","hunks":["@@ -301,6 +301,26 @@ def test_aggregator_not_suggested(self):\n         assert result is not None\n         assert result[0] not in {\"nous\",}  # nous has claude models but shouldn't be suggested\n \n+    def test_custom_provider_not_overridden_by_static_catalog(self):\n+        \"\"\"When current provider is custom:*, a static-catalog match must NOT\n+        override it — otherwise a model served by the user's own endpoint gets\n+        misattributed to a native provider, rewriting model.provider (#48305).\n+\n+        `gpt-5.4` is in the static openai catalog; with current=custom:foo,\n+        detection must return None instead of switching to openai.\n+        \"\"\"\n+        assert detect_provider_for_model(\"gpt-5.4\", \"custom:foo\") is None\n+\n+    def test_bare_custom_provider_not_overridden_by_static_catalog(self):\n+        \"\"\"Same protection for the bare 'custom' provider.\"\"\"\n+        assert detect_provider_for_model(\"gpt-5.4\", \"custom\") is None\n+\n+    def test_non_custom_provider_detection_unaffected(self):\n+        \"\"\"The custom-provider guard must NOT change detection for non-custom\n+        current providers — a static-catalog model still routes normally.\"\"\"\n+        result = detect_provider_for_model(\"gpt-5.4\", \"openrouter\")\n+        assert result is not None and result[0] == \"openai\"\n+\n \n class TestIsNousFreeTier:\n     \"\"\"Tests for is_nous_free_tier — account tier detection.\"\"\""]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -3266,7 +3266,7 @@ def switch_model(self, **kwargs):\n         warning_message=\"\",\n     )\n     seen = {}\n-    saved = {}\n+    saved_values = {}\n \n     def _switch_model(**kwargs):\n         seen.update(kwargs)","@@ -3276,7 +3276,9 @@ def _switch_model(**kwargs):\n     monkeypatch.setattr(\"hermes_cli.model_switch.switch_model\", _switch_model)\n     monkeypatch.setattr(server, \"_restart_slash_worker\", lambda sid, session: None)\n     monkeypatch.setattr(server, \"_emit\", lambda *args, **kwargs: None)\n-    monkeypatch.setattr(\"hermes_cli.config.save_config\", lambda cfg: saved.update(cfg))\n+    # _persist_model_switch uses targeted save_config_value writes (#48305) so it\n+    # preserves sibling model.* keys instead of rewriting the whole block.\n+    monkeypatch.setattr(\"cli.save_config_value\", lambda key, value: saved_values.__setitem__(key, value) or True)\n \n     resp = server.handle_request(\n         {","@@ -3292,9 +3294,9 @@ def _switch_model(**kwargs):\n \n     assert resp[\"result\"][\"value\"] == \"anthropic/claude-sonnet-4.6\"\n     assert seen[\"is_global\"] is True\n-    assert saved[\"model\"][\"default\"] == \"anthropic/claude-sonnet-4.6\"\n-    assert saved[\"model\"][\"provider\"] == \"anthropic\"\n-    assert saved[\"model\"][\"base_url\"] == \"https://api.anthropic.com\"\n+    assert saved_values[\"model.default\"] == \"anthropic/claude-sonnet-4.6\"\n+    assert saved_values[\"model.provider\"] == \"anthropic\"\n+    assert saved_values[\"model.base_url\"] == \"https://api.anthropic.com\"\n \n \n def test_config_set_model_explicit_provider_skips_broken_default_init(monkeypatch):","@@ -7988,3 +7990,73 @@ def _boom():\n     # Field omitted, but the rest of the payload is intact.\n     assert \"active_subagents\" not in usage\n     assert usage[\"model\"] == \"x\"\n+\n+\n+def test_persist_model_switch_preserves_sibling_model_keys(tmp_path, monkeypatch):\n+    \"\"\"#48305: switching models from the TUI must NOT destroy sibling keys under\n+    `model:` (model_slots, model_fallback, etc.). _persist_model_switch now uses\n+    targeted save_config_value writes instead of rewriting the whole block.\"\"\"\n+    import types\n+    import yaml\n+    import cli\n+\n+    cfg_path = tmp_path / \"config.yaml\"\n+    cfg_path.write_text(\n+        \"model:\\n\"\n+        \"  default: old-model\\n\"\n+        \"  provider: openai\\n\"\n+        \"  model_slots:\\n\"\n+        \"    fast: gpt-5-mini\\n\"\n+        \"  model_fallback:\\n\"\n+        \"    - claude-haiku\\n\"\n+        \"agent:\\n\"\n+        \"  system_prompt: keepme\\n\"\n+    )\n+    # save_config_value() resolves the config path from cli._hermes_home, which\n+    # is captured at import time — patch it directly (set_hermes_home_override\n+    # does NOT affect this snapshot).\n+    monkeypatch.setattr(cli, \"_hermes_home\", tmp_path)\n+\n+    result = types.SimpleNamespace(\n+        new_model=\"new-model\", target_provider=\"anthropic\", base_url=None\n+    )\n+    server._persist_model_switch(result)\n+    saved = yaml.safe_load(cfg_path.read_text())\n+\n+    # The switched fields updated...\n+    assert saved[\"model\"][\"default\"] == \"new-model\"\n+    assert saved[\"model\"][\"provider\"] == \"anthropic\"\n+    # ...and the sibling keys SURVIVED (the bug was that they got wiped).\n+    assert saved[\"model\"][\"model_slots\"] == {\"fast\": \"gpt-5-mini\"}\n+    assert saved[\"model\"][\"model_fallback\"] == [\"claude-haiku\"]\n+    assert saved[\"agent\"][\"system_prompt\"] == \"keepme\"\n+\n+\n+def test_persist_model_switch_clears_stale_base_url(tmp_path, monkeypatch):\n+    \"\"\"#48305: switching from a custom endpoint (which set model.base_url) to a\n+    provider with no base_url must CLEAR the stale base_url, not leave it\n+    pointing at the old host.\"\"\"\n+    import types\n+    import yaml\n+    import cli\n+\n+    cfg_path = tmp_path / \"config.yaml\"\n+    cfg_path.write_text(\n+        \"model:\\n\"\n+        \"  default: local-model\\n\"\n+        \"  provider: custom:mylocal\\n\"\n+        \"  base_url: http://localhost:1234/v1\\n\"\n+    )\n+    monkeypatch.setattr(cli, \"_hermes_home\", tmp_path)\n+\n+    # Switch to a native provider with no base_url.\n+    result = types.SimpleNamespace(\n+        new_model=\"claude-haiku\", target_provider=\"anthropic\", base_url=None\n+    )\n+    server._persist_model_switch(result)\n+    saved = yaml.safe_load(cfg_path.read_text())\n+\n+    assert saved[\"model\"][\"default\"] == \"claude-haiku\"\n+    assert saved[\"model\"][\"provider\"] == \"anthropic\"\n+    # Stale custom base_url must be cleared (null coalesces to absent on read).\n+    assert not saved[\"model\"].get(\"base_url\"), saved[\"model\"].get(\"base_url\")"]},{"file":"tui_gateway/server.py","hunks":["@@ -2301,21 +2301,23 @@ def _restart_slash_worker(sid: str, session: dict):\n \n \n def _persist_model_switch(result) -> None:\n-    from hermes_cli.config import save_config\n-\n-    cfg = _load_cfg()\n-    model_cfg = cfg.get(\"model\")\n-    if not isinstance(model_cfg, dict):\n-        model_cfg = {}\n-        cfg[\"model\"] = model_cfg\n-\n-    model_cfg[\"default\"] = result.new_model\n-    model_cfg[\"provider\"] = result.target_provider\n+    # Use targeted, atomic key writes (comment/ordering-preserving) instead of\n+    # rewriting the whole `model:` block. A full-block rewrite via save_config()\n+    # destroys sibling keys the user set under `model:` — `model_slots`,\n+    # `model_fallback`, etc. — when switching models from the TUI (#48305).\n+    from cli import save_config_value\n+\n+    save_config_value(\"model.default\", result.new_model)\n+    save_config_value(\"model.provider\", result.target_provider)\n     if result.base_url:\n-        model_cfg[\"base_url\"] = result.base_url\n+        save_config_value(\"model.base_url\", result.base_url)\n     else:\n-        model_cfg.pop(\"base_url\", None)\n-    save_config(cfg)\n+        # Clear any stale base_url when switching to a provider that doesn't use\n+        # one (e.g. custom endpoint -> native provider). Reads coalesce null to\n+        # absent (`model_cfg.get(\"base_url\") or \"\"`), so a null is equivalent to\n+        # removal without needing a key-delete. Leaving the old value would\n+        # route the new model at the previous custom host (#48305).\n+        save_config_value(\"model.base_url\", None)\n \n \n def _apply_model_switch(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}