From 4033f3fc5ff6dd16a0983bd6db248f6db24bfcc6 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sun, 16 Aug 2026 03:08:48 +0800 Subject: [PATCH] fix(models): honor vendor/model prefix and dict model.aliases in provider detection (#87189) --- hermes_cli/model_switch.py | 34 +++-- hermes_cli/models.py | 68 +++++++++ .../test_model_prefix_routing_87189.py | 130 ++++++++++++++++++ 3 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 tests/hermes_cli/test_model_prefix_routing_87189.py diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 6a13ce7b36..2156884ecf 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -562,10 +562,12 @@ def _load_direct_aliases() -> dict[str, DirectAlias]: neither is set the key is resolved from the alias HOST, never from the previously active provider (#83612). - Also reads ``model.aliases`` (set by ``hermes config set model.aliases.xxx``) - and converts simple string entries (``ds-flash: deepseek/deepseek-v4-flash``) - into DirectAlias objects. The provider is parsed from the ``provider/`` - prefix in the value; if no slash, the current provider is used. + Also reads ``model.aliases`` (set by ``hermes config set model.aliases.xxx`` + or hand-written). String entries (``ds-flash: deepseek/deepseek-v4-flash``) + are converted into DirectAlias objects with the provider parsed from the + ``provider/`` prefix in the value; if no slash, the current provider is + used. Dict entries use the same shape as ``model_aliases:`` (``model``, + ``provider``, ``base_url`` keys). """ merged = dict(_BUILTIN_DIRECT_ALIASES) try: @@ -588,18 +590,34 @@ def _load_direct_aliases() -> dict[str, DirectAlias]: key_env=str(entry.get("key_env", "") or "").strip(), ) - # --- model.aliases (string-based format, from config set) --- + # --- model.aliases (from config set / hand-written config) --- model_section = cfg.get("model", {}) if isinstance(model_section, dict): simple_aliases = model_section.get("aliases") if isinstance(simple_aliases, dict): current_provider = model_section.get("provider", "") for name, value in simple_aliases.items(): + key = name.strip().lower() + if not key or key in merged: + continue # don't override explicit model_aliases entries + if isinstance(value, dict): + # Dict form mirrors the ``model_aliases:`` shape: + # localqwen: {model: qwen3.5:4b, provider: custom}. + # Hand-written configs already use it; honoring it + # here keeps aliases with an explicit provider from + # being silently dropped (#87189). + model = str(value.get("model") or "").strip() + if not model: + continue + provider = str(value.get("provider") or "").strip() + merged[key] = DirectAlias( + model=model, + provider=provider or current_provider or "custom", + base_url=str(value.get("base_url") or "").strip(), + ) + continue if not isinstance(value, str) or not value.strip(): continue - key = name.strip().lower() - if key in merged: - continue # don't override explicit model_aliases entries val = value.strip() if "/" in val: provider, model = val.split("/", 1) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 8dc5b0bff8..487266a663 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -3614,6 +3614,65 @@ def detect_static_provider_for_model( return None +def _configured_provider_ids() -> set[str]: + """Provider ids defined in the user's config ``providers:`` block. + + Includes both top-level ids (``ollama``, ``nous``) and ``custom:*`` + profile ids. Returns an empty set when config is unreadable — callers + treat that as "no user-defined providers" and fall through to built-in + catalogs only. + """ + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + providers = cfg.get("providers") + if not isinstance(providers, dict): + return set() + ids: set[str] = set() + for pid in providers: + key = str(pid).strip().lower() + if key: + ids.add(key) + return ids + except Exception: + return set() + + +def _resolve_provider_prefix(model_name: str) -> Optional[tuple[str, str]]: + """Resolve an explicit ``vendor/model`` prefix to a known 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. + """ + if "/" not in model_name: + return None + vendor, model = model_name.split("/", 1) + vendor = vendor.strip().lower() + model = model.strip() + if not vendor or not model: + return None + configured = _configured_provider_ids() + # 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) + + def detect_provider_for_model( model_name: str, current_provider: str, @@ -3650,6 +3709,15 @@ 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 --- + # 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). + prefix_match = _resolve_provider_prefix(name) + if prefix_match is not None: + return prefix_match + return None diff --git a/tests/hermes_cli/test_model_prefix_routing_87189.py b/tests/hermes_cli/test_model_prefix_routing_87189.py new file mode 100644 index 0000000000..4287dfb543 --- /dev/null +++ b/tests/hermes_cli/test_model_prefix_routing_87189.py @@ -0,0 +1,130 @@ +"""Regression tests for vendor-prefix model routing and dict model.aliases (#87189). + +``--model nous/deepseek-v4-pro`` / ``--model ollama/qwen3.5:4b`` used to fall +through provider auto-detection and be sent to the configured default provider +(api.anthropic.com) with the prefixed name intact, producing HTTP 404. Dict +entries under ``model.aliases`` (``localqwen: {model: ..., provider: ...}``) +were silently dropped because only string values were parsed. +""" + +import hermes_cli.models as models +import hermes_cli.model_switch as model_switch + + +class TestVendorPrefixRouting: + """detect_provider_for_model honors an explicit ``vendor/model`` prefix.""" + + def test_builtin_provider_prefix_routes_to_provider(self, monkeypatch): + monkeypatch.setattr(models, "_find_openrouter_slug", lambda _name: None) + 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): + 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_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) + monkeypatch.setattr(models, "_configured_provider_ids", lambda: {"ollama"}) + assert models._PROVIDER_ALIASES.get("ollama") == "custom" # precondition + 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): + monkeypatch.setattr(models, "_find_openrouter_slug", lambda _name: None) + monkeypatch.setattr(models, "_configured_provider_ids", lambda: set()) + 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()) + assert models.detect_provider_for_model("notaprovider/foo-model", "anthropic") is None + + def test_openrouter_slug_still_wins_over_prefix_routing(self, monkeypatch): + """Aggregator-native slugs keep their existing OpenRouter routing.""" + monkeypatch.setattr( + models, "_find_openrouter_slug", lambda _name: "deepseek/deepseek-chat" + ) + monkeypatch.setattr(models, "_configured_provider_ids", lambda: set()) + detected = models.detect_provider_for_model("deepseek/deepseek-chat", "anthropic") + assert detected == ("openrouter", "deepseek/deepseek-chat") + + def test_bare_model_detection_unchanged(self, monkeypatch): + monkeypatch.setattr(models, "_find_openrouter_slug", lambda _name: None) + detected = models.detect_provider_for_model("deepseek-chat", "anthropic") + assert detected == ("deepseek", "deepseek-chat") + + +class TestDictModelAliases: + """``model.aliases`` accepts dict entries with an explicit provider.""" + + def _load_with(self, monkeypatch, cfg): + monkeypatch.setattr("hermes_cli.config.load_config", lambda: cfg) + return model_switch._load_direct_aliases() + + def test_dict_entry_with_explicit_provider(self, monkeypatch): + cfg = { + "model": { + "aliases": { + "localqwen": {"model": "qwen3.5:4b", "provider": "custom"}, + }, + }, + } + aliases = self._load_with(monkeypatch, cfg) + da = aliases["localqwen"] + assert (da.model, da.provider) == ("qwen3.5:4b", "custom") + + def test_dict_entry_with_base_url(self, monkeypatch): + cfg = { + "model": { + "aliases": { + "qwen": { + "model": "qwen3.5:4b", + "provider": "ollama", + "base_url": "http://localhost:11434/v1", + }, + }, + }, + } + aliases = self._load_with(monkeypatch, cfg) + da = aliases["qwen"] + assert (da.model, da.provider, da.base_url) == ( + "qwen3.5:4b", "ollama", "http://localhost:11434/v1", + ) + + def test_dict_entry_without_provider_uses_model_provider(self, monkeypatch): + cfg = { + "model": { + "provider": "openrouter", + "aliases": {"bare": {"model": "some-model"}}, + }, + } + aliases = self._load_with(monkeypatch, cfg) + da = aliases["bare"] + assert (da.model, da.provider) == ("some-model", "openrouter") + + def test_string_entries_still_parse(self, monkeypatch): + cfg = { + "model": { + "aliases": {"ds-flash": "deepseek/deepseek-v4-flash"}, + }, + } + aliases = self._load_with(monkeypatch, cfg) + da = aliases["ds-flash"] + assert (da.model, da.provider) == ("deepseek-v4-flash", "deepseek") + + def test_model_aliases_block_keeps_priority_over_model_aliases(self, monkeypatch): + cfg = { + "model_aliases": { + "shared": {"model": "from-top-block", "provider": "custom"}, + }, + "model": { + "aliases": {"shared": {"model": "from-nested", "provider": "ollama"}}, + }, + } + aliases = self._load_with(monkeypatch, cfg) + assert aliases["shared"].model == "from-top-block"