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