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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #53583 fix(webui): switch provider when Config-page model field changes

    merged to main

    files: hermes_cli/web_server.py, tests/hermes_cli/test_web_server.py

    Show 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.00 means 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 Lab

At a glance

Subsystem
hermes cli
Merged PRs
#53583
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
27 Jun 2026

For your agent

The same facts, typed.