{"repo":"NousResearch/hermes-agent","public_id":"14058","display_label":"Web UI model picker provider switching corrected","summary":"These changes address a bug where the web UI model picker did not switch the active provider when a non-default provider's model was selected. A related correction also ensures the provider switches properly when the model field changes on the Config page.","subsystem":"hermes_cli","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53583,"merged_at":"2026-06-27T11:13:44+00:00"},"resolution":{"state":"resolved","by":"pr-53583"},"freshness":{"state":"verified","last_verified_at":"2026-06-27T11:13:44+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":14058,"pr_number":53583,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-27T11:13:44+00:00"}],"members":[{"kind":"issue","number":14058,"title":"fix(webui): model picker doesn't switch provider when selecting non-default provider model","url":null,"state":"closed"},{"kind":"pr","number":14076,"title":"fix(gateway): web UI model picker now switches provider when selecting non-default provider model","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":53583,"title":"fix(webui): switch provider when Config-page model field changes","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/web_server.py","tests/hermes_cli/test_web_server.py"],"fix_hunks":[{"file":"hermes_cli/web_server.py","hunks":["@@ -4270,6 +4270,56 @@ def _apply_model_assignment_sync(\n \n \n \n+def _infer_provider_on_model_change(model_val: str, prev_provider: str) -> tuple[str, str]:\n+    \"\"\"Infer which provider serves ``model_val`` when the flat Config-page Model\n+    field changes, given the previously-saved ``prev_provider``.\n+\n+    Returns ``(provider, model)``; ``provider`` is empty when no switch is\n+    warranted (leave the existing provider untouched). Two signals, in order:\n+\n+    1. Curated-catalog detection (``detect_provider_for_model``) — handles the\n+       ~28 OpenRouter-curated models and direct provider-static catalogs.\n+    2. Vendor-slug heuristic — a ``vendor/model`` slug cannot belong to a\n+       single-model / non-aggregator provider (e.g. ``ollama-local``). When the\n+       current provider is not an aggregator that serves vendor-prefixed slugs,\n+       route to an aggregator. ``_normalize_main_model_assignment`` (called by\n+       the caller) keeps the user's current aggregator when they're already on\n+       one, else falls back to openrouter — the same chokepoint logic as\n+       ``POST /api/model/set``.\n+    \"\"\"\n+    name = (model_val or \"\").strip()\n+    if not name:\n+        return \"\", name\n+    try:\n+        from hermes_cli.models import (\n+            _AGGREGATOR_PROVIDERS,\n+            detect_provider_for_model,\n+            normalize_provider,\n+        )\n+    except Exception:\n+        return \"\", name\n+\n+    try:\n+        detected = detect_provider_for_model(name, prev_provider)\n+    except Exception:\n+        detected = None\n+    if detected:\n+        return detected[0], detected[1]\n+\n+    # Vendor-prefixed slug under a non-aggregator provider → reassign. Use a\n+    # sentinel \"openrouter\" here; _normalize_main_model_assignment resolves the\n+    # real aggregator (keeps a current aggregator, else openrouter).\n+    if \"/\" in name:\n+        try:\n+            cur_is_aggregator = normalize_provider(prev_provider) in _AGGREGATOR_PROVIDERS\n+        except Exception:\n+            cur_is_aggregator = False\n+        if not cur_is_aggregator:\n+            return \"openrouter\", name\n+\n+    return \"\", name\n+\n+\n def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:\n     \"\"\"Reverse _normalize_config_for_web before saving.\n ","@@ -4302,6 +4352,31 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:\n             disk_config = load_config()\n             disk_model = disk_config.get(\"model\")\n             if isinstance(disk_model, dict):\n+                prev_default = str(disk_model.get(\"default\") or \"\").strip()\n+                prev_provider = str(disk_model.get(\"provider\") or \"\").strip()\n+                # When the model name actually changed, re-detect which\n+                # provider serves it. The Config-page Model field is a flat\n+                # string with no provider info, so without this a user who\n+                # picks an OpenRouter model while their default provider is\n+                # ollama-local keeps the stale provider and 404s. Only fires\n+                # on a real model change so saving unrelated config fields\n+                # never overwrites an explicit provider.\n+                if model_val != prev_default and prev_provider:\n+                    new_provider, resolved_model = _infer_provider_on_model_change(\n+                        model_val, prev_provider\n+                    )\n+                    if new_provider and new_provider.strip().lower() != prev_provider.lower():\n+                        # Route through the canonical assignment chokepoints so\n+                        # the model is normalized for the new provider and stale\n+                        # base_url/api_mode/api_key are cleared on the switch\n+                        # (and preserved on a same-provider re-pick).\n+                        norm_provider, norm_model = _normalize_main_model_assignment(\n+                            new_provider, resolved_model\n+                        )\n+                        disk_model = _apply_main_model_assignment(\n+                            disk_model, norm_provider, norm_model\n+                        )\n+                        model_val = norm_model\n                 # Preserve all subkeys, update default with the new value\n                 disk_model[\"default\"] = model_val\n                 # Write context_length into the model dict (0 = remove/auto)"]},{"file":"tests/hermes_cli/test_web_server.py","hunks":["@@ -4121,6 +4121,102 @@ def test_denormalize_coerces_string_context_length(self):\n         assert result[\"model\"][\"context_length\"] == 32000\n \n \n+class TestDenormalizeProviderSwitch:\n+    \"\"\"The flat Config-page Model field carries no provider info. When the\n+    model string changes to one served by a different provider, the saved\n+    provider must follow it (issue #14058).\"\"\"\n+\n+    def test_vendor_slug_switches_off_non_aggregator_provider(self):\n+        \"\"\"ollama-local + a vendor/model slug → switch to openrouter and drop\n+        the stale local base_url (the issue's exact repro).\"\"\"\n+        from hermes_cli.web_server import _denormalize_config_from_web\n+        from hermes_cli.config import save_config\n+\n+        save_config({\n+            \"model\": {\n+                \"default\": \"llama3.2\",\n+                \"provider\": \"ollama-local\",\n+                \"base_url\": \"http://localhost:11434/v1\",\n+                \"api_mode\": \"chat_completions\",\n+            }\n+        })\n+\n+        result = _denormalize_config_from_web({\"model\": \"google/gemini-2.5-flash\"})\n+        model = result[\"model\"]\n+        assert model[\"provider\"] == \"openrouter\"\n+        assert model[\"default\"] == \"google/gemini-2.5-flash\"\n+        # The old ollama-local endpoint must not carry over to openrouter.\n+        assert not model.get(\"base_url\")\n+\n+    def test_unchanged_model_preserves_provider_and_base_url(self):\n+        \"\"\"Saving with the model unchanged must never re-detect/overwrite the\n+        provider — protects unrelated config saves and custom endpoints.\"\"\"\n+        from hermes_cli.web_server import _denormalize_config_from_web\n+        from hermes_cli.config import save_config\n+\n+        save_config({\n+            \"model\": {\n+                \"default\": \"llama3.2\",\n+                \"provider\": \"ollama-local\",\n+                \"base_url\": \"http://localhost:11434/v1\",\n+            }\n+        })\n+\n+        result = _denormalize_config_from_web({\"model\": \"llama3.2\"})\n+        model = result[\"model\"]\n+        assert model[\"provider\"] == \"ollama-local\"\n+        assert model[\"base_url\"] == \"http://localhost:11434/v1\"\n+\n+    def test_bare_model_name_change_keeps_local_provider(self):\n+        \"\"\"A bare (non-slug) model name gives no provider signal — leave the\n+        existing provider alone rather than guessing.\"\"\"\n+        from hermes_cli.web_server import _denormalize_config_from_web\n+        from hermes_cli.config import save_config\n+\n+        save_config({\n+            \"model\": {\n+                \"default\": \"llama3.2\",\n+                \"provider\": \"ollama-local\",\n+                \"base_url\": \"http://localhost:11434/v1\",\n+            }\n+        })\n+\n+        result = _denormalize_config_from_web({\"model\": \"qwen2.5\"})\n+        model = result[\"model\"]\n+        assert model[\"provider\"] == \"ollama-local\"\n+        assert model[\"default\"] == \"qwen2.5\"\n+\n+    def test_same_aggregator_model_swap_keeps_provider(self):\n+        \"\"\"Swapping models within an aggregator must not change the provider.\"\"\"\n+        from hermes_cli.web_server import _denormalize_config_from_web\n+        from hermes_cli.config import save_config\n+\n+        save_config({\n+            \"model\": {\"default\": \"anthropic/claude-opus-4.6\", \"provider\": \"openrouter\"}\n+        })\n+\n+        result = _denormalize_config_from_web({\"model\": \"google/gemini-2.5-flash\"})\n+        model = result[\"model\"]\n+        assert model[\"provider\"] == \"openrouter\"\n+        assert model[\"default\"] == \"google/gemini-2.5-flash\"\n+\n+    def test_context_length_override_survives_provider_switch(self):\n+        \"\"\"An explicit context-length override must persist alongside a\n+        provider switch.\"\"\"\n+        from hermes_cli.web_server import _denormalize_config_from_web\n+        from hermes_cli.config import save_config\n+\n+        save_config({\"model\": {\"default\": \"llama3.2\", \"provider\": \"ollama-local\"}})\n+\n+        result = _denormalize_config_from_web({\n+            \"model\": \"google/gemini-2.5-flash\",\n+            \"model_context_length\": 128000,\n+        })\n+        model = result[\"model\"]\n+        assert model[\"provider\"] == \"openrouter\"\n+        assert model[\"context_length\"] == 128000\n+\n+\n class TestModelContextLengthSchema:\n     \"\"\"Tests for model_context_length placement in CONFIG_SCHEMA.\"\"\"\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}