# NousResearch/hermes-agent · Web UI model picker provider switching corrected

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #53583. Verify it addresses your case.
- **Recommended:** read PR #53583 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-53583`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #14058: fix(webui): model picker doesn't switch provider when selecting non-default provider model

## Prior & competing PRs

- **#14076: fix(gateway): web UI model picker now switches provider when selecting non-default provider model**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#53583: fix(webui): switch provider when Config-page model field changes**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/web_server.py`, `tests/hermes_cli/test_web_server.py`
  - fix hunks:
  - `hermes_cli/web_server.py`

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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."""
     
    ```

## Fix status

- Merged to main in PR #53583 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-27`.
- Subsystem: `hermes_cli`
