mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
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:
parent
7ee0b68973
commit
5ab4136631
2 changed files with 171 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue