fix(models): a model the current provider serves never re-routes to OpenRouter
detect_provider_for_model() walked static catalogs and then the OpenRouter catalog. Providers whose live catalog outruns the static list (Codex early-access ids, Nous Portal slugs, Ollama Cloud) had nothing to stop the ladder, so `/model gpt-6-astra` from a Codex session, or `/model glm-5.3-flash` from a Nous-sub-only session, silently rebuilt the agent on metered OpenRouter (or on a vendor the user has no key for). The current provider's disk-cached live catalog (cached_provider_model_ids, 1h TTL + SWR) is now consulted first: an exact or bare-name hit stays put and resolves to the provider's own spelling. Fixes the whole class at the shared choke point, covering /model, ACP, oneshot and the dashboard. Refs #97487 (sj-unit72 diagnosed the ollama-cloud instance of this).
This commit is contained in:
@@ -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
|
||||
|
||||
39
hermes_cli/models_detect.py
Normal file
39
hermes_cli/models_detect.py
Normal file
@@ -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)
|
||||
40
tests/hermes_cli/test_models_detect_live_catalog.py
Normal file
40
tests/hermes_cli/test_models_detect_live_catalog.py
Normal file
@@ -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")
|
||||
Reference in New Issue
Block a user