From 5ab4136631df6ec47662e92013aa1614c6c355a3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 27 Jun 2026 04:13:44 -0700 Subject: [PATCH] fix(webui): switch provider when Config-page model field changes (#53583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard Config tab's Model field is a flat string with no provider info. _denormalize_config_from_web only updated model.default and kept the stale provider, so picking an OpenRouter model while the default provider was ollama-local left provider=ollama-local and every call 404'd. When the model string actually changes, infer the serving provider — curated catalog first, then a vendor/model-slug heuristic for non-aggregator providers — and route the switch through the existing _normalize_main_model_assignment / _apply_main_model_assignment chokepoints so stale base_url/api_mode/api_key are cleared on a provider change and preserved on a same-provider re-pick. Saving an unchanged model never re-detects, so unrelated config saves keep an explicit provider. Closes #14058 --- hermes_cli/web_server.py | 75 ++++++++++++++++++++++ tests/hermes_cli/test_web_server.py | 96 +++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 49cd5808031..308e5f697b8 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4270,6 +4270,56 @@ def _apply_model_assignment_sync( +def _infer_provider_on_model_change(model_val: str, prev_provider: str) -> tuple[str, str]: + """Infer which provider serves ``model_val`` when the flat Config-page Model + field changes, given the previously-saved ``prev_provider``. + + Returns ``(provider, model)``; ``provider`` is empty when no switch is + warranted (leave the existing provider untouched). Two signals, in order: + + 1. Curated-catalog detection (``detect_provider_for_model``) — handles the + ~28 OpenRouter-curated models and direct provider-static catalogs. + 2. Vendor-slug heuristic — a ``vendor/model`` slug cannot belong to a + single-model / non-aggregator provider (e.g. ``ollama-local``). When the + current provider is not an aggregator that serves vendor-prefixed slugs, + route to an aggregator. ``_normalize_main_model_assignment`` (called by + the caller) keeps the user's current aggregator when they're already on + one, else falls back to openrouter — the same chokepoint logic as + ``POST /api/model/set``. + """ + name = (model_val or "").strip() + if not name: + return "", name + try: + from hermes_cli.models import ( + _AGGREGATOR_PROVIDERS, + detect_provider_for_model, + normalize_provider, + ) + except Exception: + return "", name + + try: + detected = detect_provider_for_model(name, prev_provider) + except Exception: + detected = None + if detected: + return detected[0], detected[1] + + # Vendor-prefixed slug under a non-aggregator provider → reassign. Use a + # sentinel "openrouter" here; _normalize_main_model_assignment resolves the + # real aggregator (keeps a current aggregator, else openrouter). + if "/" in name: + try: + cur_is_aggregator = normalize_provider(prev_provider) in _AGGREGATOR_PROVIDERS + except Exception: + cur_is_aggregator = False + if not cur_is_aggregator: + return "openrouter", name + + return "", name + + def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]: """Reverse _normalize_config_for_web before saving. @@ -4302,6 +4352,31 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]: disk_config = load_config() disk_model = disk_config.get("model") if isinstance(disk_model, dict): + prev_default = str(disk_model.get("default") or "").strip() + prev_provider = str(disk_model.get("provider") or "").strip() + # When the model name actually changed, re-detect which + # provider serves it. The Config-page Model field is a flat + # string with no provider info, so without this a user who + # picks an OpenRouter model while their default provider is + # ollama-local keeps the stale provider and 404s. Only fires + # on a real model change so saving unrelated config fields + # never overwrites an explicit provider. + if model_val != prev_default and prev_provider: + new_provider, resolved_model = _infer_provider_on_model_change( + model_val, prev_provider + ) + if new_provider and new_provider.strip().lower() != prev_provider.lower(): + # Route through the canonical assignment chokepoints so + # the model is normalized for the new provider and stale + # base_url/api_mode/api_key are cleared on the switch + # (and preserved on a same-provider re-pick). + norm_provider, norm_model = _normalize_main_model_assignment( + new_provider, resolved_model + ) + disk_model = _apply_main_model_assignment( + disk_model, norm_provider, norm_model + ) + model_val = norm_model # Preserve all subkeys, update default with the new value disk_model["default"] = model_val # Write context_length into the model dict (0 = remove/auto) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 6ca16a9abb9..2377661aa1d 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4121,6 +4121,102 @@ class TestModelContextLength: assert result["model"]["context_length"] == 32000 +class TestDenormalizeProviderSwitch: + """The flat Config-page Model field carries no provider info. When the + model string changes to one served by a different provider, the saved + provider must follow it (issue #14058).""" + + def test_vendor_slug_switches_off_non_aggregator_provider(self): + """ollama-local + a vendor/model slug → switch to openrouter and drop + the stale local base_url (the issue's exact repro).""" + from hermes_cli.web_server import _denormalize_config_from_web + from hermes_cli.config import save_config + + save_config({ + "model": { + "default": "llama3.2", + "provider": "ollama-local", + "base_url": "http://localhost:11434/v1", + "api_mode": "chat_completions", + } + }) + + result = _denormalize_config_from_web({"model": "google/gemini-2.5-flash"}) + model = result["model"] + assert model["provider"] == "openrouter" + assert model["default"] == "google/gemini-2.5-flash" + # The old ollama-local endpoint must not carry over to openrouter. + assert not model.get("base_url") + + def test_unchanged_model_preserves_provider_and_base_url(self): + """Saving with the model unchanged must never re-detect/overwrite the + provider — protects unrelated config saves and custom endpoints.""" + from hermes_cli.web_server import _denormalize_config_from_web + from hermes_cli.config import save_config + + save_config({ + "model": { + "default": "llama3.2", + "provider": "ollama-local", + "base_url": "http://localhost:11434/v1", + } + }) + + result = _denormalize_config_from_web({"model": "llama3.2"}) + model = result["model"] + assert model["provider"] == "ollama-local" + assert model["base_url"] == "http://localhost:11434/v1" + + def test_bare_model_name_change_keeps_local_provider(self): + """A bare (non-slug) model name gives no provider signal — leave the + existing provider alone rather than guessing.""" + from hermes_cli.web_server import _denormalize_config_from_web + from hermes_cli.config import save_config + + save_config({ + "model": { + "default": "llama3.2", + "provider": "ollama-local", + "base_url": "http://localhost:11434/v1", + } + }) + + result = _denormalize_config_from_web({"model": "qwen2.5"}) + model = result["model"] + assert model["provider"] == "ollama-local" + assert model["default"] == "qwen2.5" + + def test_same_aggregator_model_swap_keeps_provider(self): + """Swapping models within an aggregator must not change the provider.""" + from hermes_cli.web_server import _denormalize_config_from_web + from hermes_cli.config import save_config + + save_config({ + "model": {"default": "anthropic/claude-opus-4.6", "provider": "openrouter"} + }) + + result = _denormalize_config_from_web({"model": "google/gemini-2.5-flash"}) + model = result["model"] + assert model["provider"] == "openrouter" + assert model["default"] == "google/gemini-2.5-flash" + + def test_context_length_override_survives_provider_switch(self): + """An explicit context-length override must persist alongside a + provider switch.""" + from hermes_cli.web_server import _denormalize_config_from_web + from hermes_cli.config import save_config + + save_config({"model": {"default": "llama3.2", "provider": "ollama-local"}}) + + result = _denormalize_config_from_web({ + "model": "google/gemini-2.5-flash", + "model_context_length": 128000, + }) + model = result["model"] + assert model["provider"] == "openrouter" + assert model["context_length"] == 128000 + + class TestModelContextLengthSchema: """Tests for model_context_length placement in CONFIG_SCHEMA."""