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

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
This commit is contained in:
Teknium 2026-06-27 04:13:44 -07:00 committed by GitHub
parent 7ee0b68973
commit 5ab4136631
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 171 additions and 0 deletions

View file

@ -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)

View file

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