From f82d2f13019daf45c493f91cc49d6070f13143a6 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sun, 16 Aug 2026 03:35:52 +0800 Subject: [PATCH] fix(models): scope prefix routing to user-configured providers only --- hermes_cli/models.py | 39 +++++++++++-------- .../test_model_prefix_routing_87189.py | 32 +++++++++++---- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 487266a663..926bd1b844 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -3640,14 +3640,21 @@ def _configured_provider_ids() -> set[str]: def _resolve_provider_prefix(model_name: str) -> Optional[tuple[str, str]]: - """Resolve an explicit ``vendor/model`` prefix to a known provider. + """Resolve an explicit ``vendor/model`` prefix to a configured provider. ``nous/deepseek-v4-pro`` or ``ollama/qwen3.5:4b`` should route to the named provider instead of falling back to the configured default (which - silently sends non-default models to the wrong endpoint, #87189). The - vendor counts as known when it is a built-in provider id/alias or a key - in the user's ``providers:`` config block. The returned model is the - suffix with the prefix stripped — the provider's API expects the bare id. + silently sends non-default models to the wrong endpoint, #87189). + + Only vendors the user actually defined in their ``providers:`` config + block (by raw name or alias) are routed here. Built-in vendor prefixes + (``google/gemini-2.5-flash``, ``deepseek/deepseek-chat``) deliberately + stay on the existing catalog / OpenRouter-slug / default-provider path: + those slug forms are aggregator-native, and rerouting them to the vendor + provider would change established provider-switch behavior (see + ``TestDenormalizeProviderSwitch`` in tests/hermes_cli/test_web_server.py). + The returned model is the suffix with the prefix stripped — the target + provider's API expects the bare id. """ if "/" not in model_name: return None @@ -3657,20 +3664,17 @@ def _resolve_provider_prefix(model_name: str) -> Optional[tuple[str, str]]: if not vendor or not model: return None configured = _configured_provider_ids() + if not configured: + return None # A provider block the user explicitly named (``ollama:``) wins over the # built-in alias table, which may canonicalize the same name elsewhere # (``ollama`` → ``custom``) and route to the wrong endpoint. if vendor in configured: return (vendor, model) canonical = _PROVIDER_ALIASES.get(vendor, vendor) - known = ( - canonical in _PROVIDER_LABELS - or canonical in _PROVIDER_MODELS - or canonical in configured - ) - if not known: - return None - return (canonical, model) + if canonical in configured: + return (canonical, model) + return None def detect_provider_for_model( @@ -3709,11 +3713,12 @@ def detect_provider_for_model( return ("openrouter", or_slug) return None # already on openrouter with matching name - # --- Step 3: explicit ``vendor/model`` prefix naming a provider --- + # --- Step 3: explicit ``vendor/model`` prefix naming a configured provider --- # Checked after the OpenRouter slug lookup so aggregator-native slugs - # (e.g. ``deepseek/deepseek-chat``) keep their existing routing; this - # step only catches names no catalog serves, which previously fell back - # to the configured default provider and 404'd (#87189). + # (e.g. ``deepseek/deepseek-chat``) keep their existing routing; only + # vendors the user defined in their ``providers:`` block route here, + # so catalog/default behavior for built-in vendor prefixes is unchanged + # (#87189). prefix_match = _resolve_provider_prefix(name) if prefix_match is not None: return prefix_match diff --git a/tests/hermes_cli/test_model_prefix_routing_87189.py b/tests/hermes_cli/test_model_prefix_routing_87189.py index 4287dfb543..c4802d1910 100644 --- a/tests/hermes_cli/test_model_prefix_routing_87189.py +++ b/tests/hermes_cli/test_model_prefix_routing_87189.py @@ -12,19 +12,37 @@ import hermes_cli.model_switch as model_switch class TestVendorPrefixRouting: - """detect_provider_for_model honors an explicit ``vendor/model`` prefix.""" + """detect_provider_for_model honors a ``vendor/model`` prefix for + providers the user actually configured in their ``providers:`` block.""" - def test_builtin_provider_prefix_routes_to_provider(self, monkeypatch): + def test_configured_provider_prefix_routes_to_provider(self, monkeypatch): monkeypatch.setattr(models, "_find_openrouter_slug", lambda _name: None) + monkeypatch.setattr(models, "_configured_provider_ids", lambda: {"nous"}) detected = models.detect_provider_for_model("nous/deepseek-v4-pro", "anthropic") assert detected == ("nous", "deepseek-v4-pro") - def test_configured_provider_prefix_routes_to_provider(self, monkeypatch): + def test_local_provider_prefix_routes_to_provider(self, monkeypatch): monkeypatch.setattr(models, "_find_openrouter_slug", lambda _name: None) monkeypatch.setattr(models, "_configured_provider_ids", lambda: {"ollama"}) detected = models.detect_provider_for_model("ollama/qwen3.5:4b", "anthropic") assert detected == ("ollama", "qwen3.5:4b") + def test_unconfigured_builtin_vendor_prefix_not_rerouted(self, monkeypatch): + """Built-in vendor slugs keep catalog/default routing. + + ``google/gemini-2.5-flash`` is aggregator-native: the web config + field expects it to switch to OpenRouter, not to the Gemini provider + (``TestDenormalizeProviderSwitch`` in test_web_server.py). With no + user-configured provider for the vendor, prefix routing must stay + out of the way even when the models.dev catalog is unavailable. + """ + monkeypatch.setattr(models, "_find_openrouter_slug", lambda _name: None) + monkeypatch.setattr(models, "_configured_provider_ids", lambda: set()) + detected = models.detect_provider_for_model( + "google/gemini-2.5-flash", "ollama-local" + ) + assert detected is None + def test_configured_provider_wins_over_alias_canonicalization(self, monkeypatch): """A user-named ``ollama`` block must not be rewritten to ``custom``.""" monkeypatch.setattr(models, "_find_openrouter_slug", lambda _name: None) @@ -33,15 +51,15 @@ class TestVendorPrefixRouting: detected = models.detect_provider_for_model("ollama/qwen3.5:4b", "anthropic") assert detected == ("ollama", "qwen3.5:4b") - def test_provider_alias_prefix_canonicalized(self, monkeypatch): + def test_provider_alias_prefix_canonicalized_when_configured(self, monkeypatch): monkeypatch.setattr(models, "_find_openrouter_slug", lambda _name: None) - monkeypatch.setattr(models, "_configured_provider_ids", lambda: set()) + monkeypatch.setattr(models, "_configured_provider_ids", lambda: {"zai"}) detected = models.detect_provider_for_model("glm/glm-4.7", "anthropic") assert detected == ("zai", "glm-4.7") def test_unknown_vendor_prefix_still_unmatched(self, monkeypatch): monkeypatch.setattr(models, "_find_openrouter_slug", lambda _name: None) - monkeypatch.setattr(models, "_configured_provider_ids", lambda: set()) + monkeypatch.setattr(models, "_configured_provider_ids", lambda: {"ollama"}) assert models.detect_provider_for_model("notaprovider/foo-model", "anthropic") is None def test_openrouter_slug_still_wins_over_prefix_routing(self, monkeypatch): @@ -49,7 +67,7 @@ class TestVendorPrefixRouting: monkeypatch.setattr( models, "_find_openrouter_slug", lambda _name: "deepseek/deepseek-chat" ) - monkeypatch.setattr(models, "_configured_provider_ids", lambda: set()) + monkeypatch.setattr(models, "_configured_provider_ids", lambda: {"deepseek"}) detected = models.detect_provider_for_model("deepseek/deepseek-chat", "anthropic") assert detected == ("openrouter", "deepseek/deepseek-chat")