NousResearch/hermes-agent
Web UI model picker provider switching corrected
Cluster 14058 · hermes cli. Data as of
Nothing open on GitHub. Fix merged in #53583 on 27 Jun 2026. Check it covers your case.
The short answer
0open issues
0open PRs
3items grouped
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #53583. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #53583. That is separate from the fix event. - Still open
- Nothing open on GitHub.
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.
Open issues0
Open PRs0
Fixes on record
-
#53583 fix(webui): switch provider when Config-page model field changes
files:
hermes_cli/web_server.py,tests/hermes_cli/test_web_server.pyShow the diff
-
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) -
tests/hermes_cli/test_web_server.py@@ -4121,6 +4121,102 @@ def test_denormalize_coerces_string_context_length(self): 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."""
-
1 PR closed without merging
From the close comments: done elsewhere 1
- #14076 fix(gateway): web UI model picker now switches provider when selecting non-default provider model · done elsewhere
Closed issues · 1
- Closed#14058fix(webui): model picker doesn't switch provider when selecting non-default provider model
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- hermes cli
- Merged PRs
- #53583
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 27 Jun 2026