diff --git a/hermes_cli/models.py b/hermes_cli/models.py index b939780b4e..9fd45c69ca 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -3325,6 +3325,30 @@ _AGGREGATOR_PROVIDERS = frozenset( {"nous", "openrouter", "ai-gateway", "copilot", "kilocode"} ) +# OpenRouter request-time routing variants (docs: guides/routing/model-variants). +# These suffixes are per-request routing modifiers valid on ANY model id — +# ":nitro" sorts the endpoint pool by throughput and admits priority-tier +# endpoints, ":floor" sorts by price and admits flex-tier endpoints, ":exacto" +# applies quality-first provider sorting, ":online" attaches the web plugin. +# They are never separate catalog entries: /models lists only the base id. +# NOT in this set: ":free", ":batch", ":thinking", ":extended" — those ARE +# distinct catalog SKUs that appear in /models when they exist, so absence +# from the listing is authoritative for them and the direct-membership check +# above handles the valid ones. +_OPENROUTER_VARIANT_SUFFIXES = frozenset({"nitro", "floor", "exacto", "online"}) + + +def _openrouter_variant_base(model_id: str) -> Optional[str]: + """Return the base model id when ``model_id`` carries a recognized + OpenRouter routing-variant suffix (e.g. ``x-ai/grok-4:nitro`` → + ``x-ai/grok-4``), else ``None``.""" + base, sep, suffix = (model_id or "").rpartition(":") + if not sep or not base: + return None + if suffix.lower() in _OPENROUTER_VARIANT_SUFFIXES: + return base + return None + # Subscription/OAuth providers whose catalogs RE-EXPOSE other vendors' models # would be listed here (tried only as a last resort for bare short-alias # resolution, after every native-vendor catalog, so they never hijack an alias @@ -6812,6 +6836,24 @@ def validate_requested_model( "recognized": True, "message": None, } + # OpenRouter routing variants (":nitro", ":floor", ...) are request-time + # modifiers, not catalog entries — /models lists only the base id. + # Validate the BASE against the listing but preserve the suffixed id, + # and do this BEFORE fuzzy auto-correction: get_close_matches would + # otherwise "correct" `model:nitro` → `model` and silently strip the + # user's routing opt-in. + _variant_base = ( + _openrouter_variant_base(requested_for_lookup) + if normalized == "openrouter" + else None + ) + if _variant_base is not None and _variant_base in set(api_models): + return { + "accepted": True, + "persist": True, + "recognized": True, + "message": None, + } else: # API responded but model is not listed. Accept anyway — # the user may have access to models not shown in the public @@ -6853,7 +6895,8 @@ def validate_requested_model( _openai_listing_is_authoritative = is_official_openai_host(base_url) if not _openai_listing_is_authoritative and _model_in_provider_catalog( - requested_for_lookup.lower(), _provider_keys(normalized) + (_variant_base or requested_for_lookup).lower(), + _provider_keys(normalized), ): return { "accepted": True, @@ -6936,6 +6979,21 @@ def validate_requested_model( "recognized": True, "message": None, } + # OpenRouter routing-variant suffixes: validate the base id against + # the catalog, keep the suffixed id (same rule as the live-listing + # path above — variants never appear as catalog entries). + if normalized == "openrouter": + _cat_variant_base = _openrouter_variant_base(requested_for_lookup) + if ( + _cat_variant_base is not None + and _cat_variant_base.lower() in catalog_lower + ): + return { + "accepted": True, + "persist": True, + "recognized": True, + "message": None, + } catalog_lower_list = list(catalog_lower.keys()) auto = get_close_matches( requested_for_lookup.lower(), catalog_lower_list, n=1, cutoff=0.9 diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 966eae73e7..51387a0bf0 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -594,3 +594,84 @@ class TestProbeApiModelsUserAgent: assert req.get_header("Authorization") is None + + +# -- validate — OpenRouter routing-variant suffixes (:nitro / :floor / ...) ---- + +class TestValidateOpenRouterVariantSuffixes: + """OpenRouter's `:nitro`, `:floor`, `:exacto`, `:online` are request-time + routing modifiers, not catalog models — /models lists only the base id. + Validation must accept `base:variant` when `base` is listed, preserve the + suffixed id (no auto-correct stripping the routing opt-in), and still + reject variants on unknown bases and unknown suffixes.""" + + _LISTING = [ + "~x-ai/grok-latest", + "x-ai/grok-4.6", + "deepseek/deepseek-v4-flash", + "thinkingmachines/inkling:free", + ] + + def _validate(self, model): + return _validate(model, "openrouter", api_models=self._LISTING) + + @pytest.mark.parametrize("suffix", ["nitro", "floor", "exacto", "online"]) + def test_variant_on_listed_base_accepted_unmodified(self, suffix): + result = self._validate(f"~x-ai/grok-latest:{suffix}") + assert result["accepted"] is True + assert result["recognized"] is True + assert result.get("corrected_model") is None + assert result["message"] is None + + def test_variant_not_fuzzy_corrected_to_base(self): + """The old failure mode: get_close_matches would 'fix' model:nitro + to the bare base id and silently drop the routing behavior.""" + result = self._validate("x-ai/grok-4.6:nitro") + assert result["accepted"] is True + assert result.get("corrected_model") is None + + def test_variant_on_unknown_base_rejected(self): + result = self._validate("x-ai/notreal-model:nitro") + assert result["accepted"] is False + + def test_unknown_suffix_keeps_old_behavior(self): + result = self._validate("x-ai/grok-4.6:bogus") + assert result["accepted"] is False + + def test_free_sku_still_direct_matched(self): + """`:free` SKUs ARE catalog entries; direct membership handles them.""" + result = self._validate("thinkingmachines/inkling:free") + assert result["accepted"] is True + assert result.get("corrected_model") is None + + def test_variant_uppercase_suffix_accepted(self): + result = self._validate("x-ai/grok-4.6:NITRO") + assert result["accepted"] is True + assert result.get("corrected_model") is None + + def test_non_openrouter_provider_unaffected(self): + """The variant carve-out is OpenRouter-only; other providers keep + their existing behavior for colon-suffixed names.""" + result = _validate( + "x-ai/grok-4.6:nitro", + "groq", + api_models=["x-ai/grok-4.6"], + ) + assert result.get("corrected_model") != "x-ai/grok-4.6:nitro" + + def test_static_catalog_fallback_accepts_variant(self): + """Gateway path: /models unreachable → static catalog validates the + base id and preserves the suffix.""" + with patch("hermes_cli.models.fetch_api_models", return_value=None), \ + patch( + "hermes_cli.models.provider_model_ids", + return_value=["x-ai/grok-4.6", "anthropic/claude-opus-4.6"], + ): + result = validate_requested_model( + "x-ai/grok-4.6:floor", + "openrouter", + base_url="https://openrouter.ai/api/v1", + ) + assert result["accepted"] is True + assert result["recognized"] is True + assert result.get("corrected_model") is None