From a5ea9a6fd4d9e23fa99b27f796d23eba05a8a235 Mon Sep 17 00:00:00 2001 From: homelab-ha-agent Date: Wed, 1 Jul 2026 23:31:30 -0400 Subject: [PATCH] fix(model): don't misroute named custom providers to openrouter on save _normalize_main_model_assignment() (POST /api/model/set, the endpoint Desktop's Settings -> Model page uses to persist the main model slot) has a fallback for a specific analytics bug: an older session row with no billing_provider sends the model's bare vendor prefix as "provider" (e.g. "anthropic" from "anthropic/claude-opus-4.6"), so the code detects an unrecognized provider paired with a slash-bearing model and treats it as that stray-vendor-prefix case. Named custom providers are represented as "custom:" slugs everywhere else in the codebase (runtime_provider.py, model_switch.py), but _KNOWN_PROVIDER_NAMES only lists the bare "custom" bucket. So picking a named custom provider (e.g. "custom:litellm", a LiteLLM proxy fronting Ollama) together with a slash-bearing model ("ollama/glm-5.2") looked identical to the stray-vendor-prefix case and got silently rewritten to provider: openrouter in config.yaml on save -- reassigning the provider entirely, not just mangling the model id. Exclude anything starting with "custom" from the fallback, matching the guard the same function already applies later for the actual normalize_model_for_provider call. Co-Authored-By: Claude Sonnet 5 --- hermes_cli/web_server.py | 19 +++++++- .../test_normalize_main_model_assignment.py | 43 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_normalize_main_model_assignment.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 5b136bd820e6..d720111509e6 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1472,6 +1472,14 @@ def _normalize_main_model_assignment(provider: str, model: str) -> tuple[str, st known but ``poolside`` isn't) but the model is a vendor-prefixed aggregator slug, keep the user's CURRENT aggregator if they're on one, else fall back to openrouter. + + Named custom providers (``custom:litellm``, etc.) are excluded from + this fallback: ``_KNOWN_PROVIDER_NAMES`` only lists the bare + ``"custom"`` bucket, never a specific ``custom:`` slug, so + without this exclusion every named custom provider paired with a + slash-bearing model (e.g. ``ollama/glm-5.2`` behind a LiteLLM proxy) + looked exactly like the stray-vendor-prefix case above and got + silently reassigned to ``openrouter``. 2. Model-format normalization for the resolved provider via ``normalize_model_for_provider`` (e.g. ``anthropic/claude-opus-4.6`` on native anthropic → ``claude-opus-4-6``). @@ -1506,7 +1514,16 @@ def _normalize_main_model_assignment(provider: str, model: str) -> tuple[str, st if custom_provider is not None: return custom_provider.id, model_in - if canonical not in _KNOWN_PROVIDER_NAMES and "/" in model_in: + # A named custom provider that didn't resolve above (typo, config + # mismatch, entry missing from custom_providers/providers) must still + # not be treated as a stray vendor prefix -- it isn't a known Hermes + # provider/alias, but it also isn't the analytics-vendor case this + # fallback exists for. + if ( + canonical not in _KNOWN_PROVIDER_NAMES + and not canonical.startswith("custom") + and "/" in model_in + ): # Vendor prefix posing as a provider (analytics fallback). Resolve # against the user's current provider when it's an aggregator that # serves vendor-prefixed slugs; otherwise default to openrouter. diff --git a/tests/hermes_cli/test_normalize_main_model_assignment.py b/tests/hermes_cli/test_normalize_main_model_assignment.py new file mode 100644 index 000000000000..830875ca9863 --- /dev/null +++ b/tests/hermes_cli/test_normalize_main_model_assignment.py @@ -0,0 +1,43 @@ +"""Regression tests for ``_normalize_main_model_assignment`` (POST /api/model/set). + +Named custom providers are represented as ``custom:`` slugs everywhere +else in the codebase (``runtime_provider.py``, ``model_switch.py``), but +``_KNOWN_PROVIDER_NAMES`` only lists the bare ``"custom"`` bucket. Before this +fix, persisting a main-slot assignment for a named custom provider (e.g. a +LiteLLM proxy fronting Ollama, registered as ``custom:litellm``) together with +a slash-bearing model id (``ollama/glm-5.2``) was indistinguishable from the +"vendor prefix posing as a provider" analytics-fallback case, and got silently +rewritten to ``provider: openrouter`` in ``config.yaml`` -- reassigning the +provider entirely, not just mangling the model id. +""" + +from hermes_cli.web_server import _normalize_main_model_assignment + + +class TestNamedCustomProviderIsNotTreatedAsStrayVendorPrefix: + def test_named_custom_provider_slug_is_preserved(self): + assert _normalize_main_model_assignment("custom:litellm", "ollama/glm-5.2") == ( + "custom:litellm", + "ollama/glm-5.2", + ) + + def test_bare_custom_bucket_is_preserved(self): + assert _normalize_main_model_assignment("custom", "ollama/glm-5.2") == ( + "custom", + "ollama/glm-5.2", + ) + + +class TestStrayVendorPrefixFallbackStillWorks: + """The original bug this function fixes: an analytics row with no + ``billing_provider`` falls back to the model's vendor prefix as the + "provider" (e.g. ``provider="anthropic"`` from + ``modelVendor("anthropic/claude-opus-4.6")``). That must still resolve + to the native provider with its model normalized -- unaffected by the + ``custom:`` exclusion above. + """ + + def test_known_native_provider_still_normalizes_model(self): + assert _normalize_main_model_assignment( + "anthropic", "anthropic/claude-opus-4.6" + ) == ("anthropic", "claude-opus-4-6")