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:<name>" 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 <noreply@anthropic.com>
This commit is contained in:
homelab-ha-agent 2026-07-01 23:31:30 -04:00 committed by kshitij
parent 5a55ce7dd5
commit a5ea9a6fd4
2 changed files with 61 additions and 1 deletions

View file

@ -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:<name>`` 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.

View file

@ -0,0 +1,43 @@
"""Regression tests for ``_normalize_main_model_assignment`` (POST /api/model/set).
Named custom providers are represented as ``custom:<name>`` 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")