diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 2ac20e7348..f9b5db29b3 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -936,6 +936,15 @@ def detect_provider_for_model( if not name: return None + # The current provider's LIVE catalog outranks every static guess: a model it already serves + # (Codex early-access ids, Portal-only slugs, Ollama Cloud models absent from _PROVIDER_MODELS) + # must never re-route the session to another vendor or to metered OpenRouter. + from hermes_cli.models_detect import current_provider_catalog_match + + served = current_provider_catalog_match(name, current_provider) + if served is not None: + return (current_provider, served) if served != name else None + static_match = detect_static_provider_for_model(name, current_provider) if static_match: return static_match diff --git a/hermes_cli/models_detect.py b/hermes_cli/models_detect.py new file mode 100644 index 0000000000..9bc9d0ae1d --- /dev/null +++ b/hermes_cli/models_detect.py @@ -0,0 +1,39 @@ +"""Live-catalog guard for ``detect_provider_for_model``. + +Split out of ``hermes_cli.models``. The detection ladder there consults static catalogs, then the +OpenRouter catalog. Providers whose static list lags their live catalog (Codex accounts with +early-access models, Nous Portal, Ollama Cloud) have no static entry to stop the ladder, so a bare +name the CURRENT provider already serves fell through to OpenRouter and the session was silently +rebuilt on a metered aggregator (#97487, WolframRvnwlf's $100 Astra incident). +""" + +from __future__ import annotations + +from typing import Optional + +# Providers where the ladder's own OpenRouter step is the answer, or where there is no catalog to +# consult; ``custom`` endpoints are never auto-switched away from (handled upstream). +_SKIP = frozenset({"", "auto", "openrouter", "custom"}) + + +def current_provider_catalog_match(model_name: str, current_provider: str) -> Optional[str]: + """Return the current provider's own spelling of *model_name* when its live (disk-cached) + catalog serves it — exact id, or the bare part after ``vendor/`` — else ``None``. + + Goes through :func:`hermes_cli.models.cached_provider_model_ids` (1h TTL, stale-while- + revalidate) so a model switch does not block on a cold ``/v1/models`` round-trip in the + common case; a fetch failure yields an empty catalog and the ladder continues unchanged.""" + from hermes_cli.models import cached_provider_model_ids, normalize_provider + + provider = (current_provider or "").strip().lower() + if provider in _SKIP or provider.startswith("custom:") or normalize_provider(provider) in _SKIP: + return None + wanted = (model_name or "").strip().lower() + if not wanted: + return None + try: + catalog = cached_provider_model_ids(provider) + except Exception: + return None + return next((mid for mid in catalog if mid.lower() == wanted), None) or next( + (mid for mid in catalog if "/" in mid and mid.split("/", 1)[1].lower() == wanted), None) diff --git a/tests/hermes_cli/test_models_detect_live_catalog.py b/tests/hermes_cli/test_models_detect_live_catalog.py new file mode 100644 index 0000000000..5a18ee7080 --- /dev/null +++ b/tests/hermes_cli/test_models_detect_live_catalog.py @@ -0,0 +1,40 @@ +"""``detect_provider_for_model`` must not re-route a model the CURRENT provider already serves. + +Regression for the OpenRouter hijack (#97487, the ``/model gpt-6-astra`` incident): a bare name +absent from the static ``_PROVIDER_MODELS`` list but present in the current provider's live catalog +fell through to the OpenRouter lookup and silently rebuilt the session on a metered aggregator. +""" + +from __future__ import annotations + +import pytest + +from hermes_cli import models + + +@pytest.fixture +def live_catalog(monkeypatch): + """Pin the disk-cached live catalog per provider and make the OpenRouter lookup loud.""" + catalogs: dict[str, list[str]] = {} + monkeypatch.setattr(models, "cached_provider_model_ids", lambda provider, **_: list(catalogs.get(provider, []))) + monkeypatch.setattr(models, "_find_openrouter_slug", lambda name: f"vendor/{name}") + return catalogs + + +class TestCurrentProviderCatalogWins: + @pytest.mark.parametrize("provider,model", [ + ("openai-codex", "gpt-6-astra"), # early-access id, static Codex list lags + ("nous", "some-portal-only-model"), # Portal serves it, static snapshot doesn't + ("ollama-cloud", "glm-5.3-flash"), # static list points at zai; OpenRouter has zai/… + ]) + def test_served_model_stays_on_current_provider(self, live_catalog, provider, model): + live_catalog[provider] = [model] + assert models.detect_provider_for_model(model, provider) is None + + def test_bare_name_resolves_to_current_providers_full_slug(self, live_catalog): + live_catalog["nous"] = ["zai/glm-5.3-flash"] + assert models.detect_provider_for_model("glm-5.3-flash", "nous") == ("nous", "zai/glm-5.3-flash") + + def test_unserved_model_still_walks_the_ladder(self, live_catalog): + live_catalog["nous"] = ["hermes-4-405b"] + assert models.detect_provider_for_model("no-such-model", "nous") == ("openrouter", "vendor/no-such-model")