fix(models): a plugin profile's static_models stay with the home that registered it

sync_plugin_provider_catalog copied every profile's static_models into the process-wide
_PROVIDER_MODELS with setdefault: the first served home won forever, so a second home that
registers the same provider name with other models defaulted to the first home's model, and a
home without the plugin listed (and could switch to) a provider it does not have.

Nothing is copied any more. static_catalog(provider) answers from the built-in row, else from the
bound home's profile at lookup time; static_catalog_providers() lists built-ins plus this home's
plugin rows. The catalog readers (defaults, detection, pickers, setup) use them.
This commit is contained in:
Teknium committed 2026-10-26 00:32:11 -07:00
1 parent 99cf3a6358
commit 8d359c7824
7 files changed
+123 -30

No files matched your search

+2 -2
View File
@@ -731,8 +731,8 @@ def _api_key_provider_model_list(provider_id: str, pconfig, existing_key: str, k
``models._profile_live_catalog``; generic /models probe for unregistered providers).
Providers in ``_SPECIAL_MODEL_LISTS`` have their own resolution."""
from hermes_cli.config import get_env_value
from hermes_cli.models import _PROVIDER_MODELS, fetch_api_models, probe_profile_catalog
curated = _PROVIDER_MODELS.get(provider_id, [])
from hermes_cli.models import fetch_api_models, probe_profile_catalog, static_catalog
curated = static_catalog(provider_id)
api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "")
special = _SPECIAL_MODEL_LISTS.get(provider_id)
+2 -2
View File
@@ -802,9 +802,9 @@ def resolve_alias(raw_input: str, current_provider: str, user_providers: Optiona
# models.dev catalog merged with static _PROVIDER_MODELS entries it may be missing.
catalog = list_provider_models(current_provider)
try:
from hermes_cli.models import _PROVIDER_MODELS
from hermes_cli.models import static_catalog
seen = {m.lower() for m in catalog}
catalog.extend(m for m in _PROVIDER_MODELS.get(current_provider, []) if m.lower() not in seen)
catalog.extend(m for m in static_catalog(current_provider) if m.lower() not in seen)
except Exception:
pass
+4 -4
View File
@@ -1191,12 +1191,12 @@ def _build_curated_lists(current_provider: str, current_base_url: str, current_m
from hermes_cli.models import OPENROUTER_MODELS, _PROVIDER_MODELS, get_curated_nous_model_ids
curated: dict[str, list[str]] = dict(_PROVIDER_MODELS)
curated["openrouter"] = [mid for mid, _ in OPENROUTER_MODELS]
# Plugin profiles without a static row: their fallback_models are the curated floor, so the
# non-blocking GUI read (cold catalog cache) lists them instead of an empty provider row.
# Plugin profiles without a built-in row: this home's static_models, else fallback_models, are the
# curated floor, so the non-blocking GUI read (cold catalog cache) never lists an empty provider row.
from providers import list_providers
for _pp in list_providers():
if _pp.fallback_models and not curated.get(_pp.name):
curated[_pp.name] = list(_pp.fallback_models)
if not curated.get(_pp.name) and (rows := list(_pp.static_models or _pp.fallback_models)):
curated[_pp.name] = rows
# Remote manifest so new Portal models surface without a release; in-repo snapshot fallback.
curated["nous"] = get_curated_nous_model_ids()
if "ollama-cloud" not in curated:
+29 -11
View File
@@ -563,10 +563,28 @@ def recommended_nous_default_model() -> dict[str, Any]:
"free_tier": bool(free_tier)}
def static_catalog(provider: str) -> list[str]:
"""Curated ids for *provider*: its built-in row, else the bound home's profile ``static_models``.
Resolved per lookup, never copied into the shared table: two served homes may register the same
provider name with different models (or only one of them has the plugin)."""
if provider in _PROVIDER_MODELS:
return _PROVIDER_MODELS[provider]
from providers import get_provider_profile
profile = get_provider_profile(provider) if provider else None
return list(profile.static_models) if profile is not None else []
def static_catalog_providers() -> list[str]:
"""Every provider with a static catalog row in the bound home: built-ins, then plugin profiles."""
from providers import list_providers
plugin_rows = [p.name for p in list_providers() if p.static_models and p.name not in _PROVIDER_MODELS]
return [*_PROVIDER_MODELS, *dict.fromkeys(plugin_rows)]
def get_default_model_for_provider(provider: str) -> str:
"""Cost-safe default model for a provider, or "" — the NON-INTERACTIVE fallback when a provider
is configured but no model was ever selected."""
models = _PROVIDER_MODELS.get(provider, [])
models = static_catalog(provider)
if provider in _SILENT_DEFAULT_PROVIDERS:
preferred = get_preferred_silent_default_model(provider)
# Trust the preferred default even without a static catalog (OpenRouter's picker list is
@@ -946,7 +964,7 @@ def curated_models_for_provider(
return [(m, "") for m in live]
# Fallback to static catalog
models = _PROVIDER_MODELS.get(normalized, [])
models = static_catalog(normalized)
return [(m, "") for m in models]
@@ -958,7 +976,7 @@ def _provider_keys(provider: str) -> set[str]:
def _provider_catalog_names(provider: str) -> tuple[str, ...]:
"""Active picker models plus retired aliases recognized for detection."""
return tuple(_PROVIDER_MODELS.get(provider, [])) + _PROVIDER_RETIRED_ALIASES.get(provider, ())
return tuple(static_catalog(provider)) + _PROVIDER_RETIRED_ALIASES.get(provider, ())
def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool:
@@ -983,13 +1001,13 @@ def _resolve_static_model_alias(
def _match(provider: str) -> Optional[str]:
prefix = f"{identity.vendor}/{identity.family}" if provider in _AGGREGATOR_PROVIDERS else identity.family
prefix = prefix.lower()
return next((m for m in _PROVIDER_MODELS.get(provider, []) if m.lower().startswith(prefix)), None)
return next((m for m in static_catalog(provider) if m.lower().startswith(prefix)), None)
# Current provider first, then native vendors, then aggregators / borrow-list providers the user
# is already on — so `sonnet` resolves to anthropic before any re-exposing provider.
skip = current_keys | _AGGREGATOR_PROVIDERS | _BORROWED_MODEL_PROVIDERS
candidates = [
*current_keys, *(p for p in _PROVIDER_MODELS if p not in skip),
*current_keys, *(p for p in static_catalog_providers() if p not in skip),
*(p for p in _AGGREGATOR_PROVIDERS if p in current_keys),
*(p for p in _BORROWED_MODEL_PROVIDERS if p in current_keys)]
for provider in candidates:
@@ -1017,7 +1035,7 @@ def detect_static_provider_for_model(
# provider's default. Skip "custom" (no catalog) and "openrouter" (needs an explicit model).
resolved_provider = _PROVIDER_ALIASES.get(name_lower, name_lower)
if resolved_provider not in {"custom", "openrouter"}:
default_models = _PROVIDER_MODELS.get(resolved_provider, [])
default_models = static_catalog(resolved_provider)
if resolved_provider in _PROVIDER_LABELS and default_models and resolved_provider not in current_keys:
# Cost-safe default, not ``default_models[0]``: metered aggregators list most-capable-first,
# so [0] would silently escalate `/model nous` to the priciest flagship.
@@ -1042,7 +1060,7 @@ def _static_catalog_matches(name: str, current_provider: str):
# auto-switch TO them. A custom endpoint (custom / custom:*) is never auto-switched away
# from: the user configured it deliberately and may serve the same model name there.
if current_provider != "custom" and not current_provider.startswith("custom:"):
for pid in _PROVIDER_MODELS:
for pid in static_catalog_providers():
if pid in current_keys or pid in _AGGREGATOR_PROVIDERS or pid in _BORROWED_MODEL_PROVIDERS:
continue
if _model_in_provider_catalog(name_lower, {pid}):
@@ -1394,7 +1412,7 @@ def _stepfun_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]
live = _api_key_provider_live(normalized, force_refresh)
if not live:
return live
curated = list(_PROVIDER_MODELS.get(normalized, []))
curated = list(static_catalog(normalized))
return _merge_unique(live, curated, key=_model_dedup_key) if curated else live
@@ -1440,7 +1458,7 @@ def _openai_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]]
if not is_official_openai_host(base):
return live
live_lower = {m.lower() for m in live}
curated = list(_PROVIDER_MODELS.get(normalized, []))
curated = list(static_catalog(normalized))
# Curated order, only models the account has access to; an account serving none of them (rare)
# falls back to curated so the picker still offers sane defaults.
discovered = [m for m in curated if m.lower() in live_lower]
@@ -1596,7 +1614,7 @@ def merge_profile_catalog(normalized: str, profile, live: Optional[list[str]]) -
if not live:
rows = CuratedFallbackModels(profile.fallback_models) if profile.fallback_models else None
else:
curated = list(_PROVIDER_MODELS.get(normalized, [])) or list(profile.fallback_models or ())
curated = list(static_catalog(normalized)) or list(profile.fallback_models or ())
if not curated:
rows = live
else:
@@ -1707,7 +1725,7 @@ def _static_catalog(normalized: str, fetcher: Any) -> list[str]:
# model, flux-*, ahead of its chat models).
# A provider with a live fetcher that declined is serving a placeholder; one without any live
# source is serving its authoritative catalog.
curated_static = (CuratedFallbackModels if fetcher is not None else list)(_PROVIDER_MODELS.get(normalized, []))
curated_static = (CuratedFallbackModels if fetcher is not None else list)(static_catalog(normalized))
if normalized not in _MODELS_DEV_PREFERRED:
return _chat_catalog_rows(_drop_delisted_opencode_models(normalized, curated_static))
# models.dev keeps listing retired Zen ids too: filter after the merge, not before.
-3
View File
@@ -398,9 +398,6 @@ def sync_plugin_provider_catalog() -> int:
profiles = list_providers()
except Exception:
return 0
for pp in profiles:
if pp.static_models:
_PROVIDER_MODELS.setdefault(pp.name, list(pp.static_models))
pending = [pp for pp in profiles if _plugin_provider_enters_picker(pp)]
added = 0
while pending:
@@ -55,17 +55,13 @@ def test_picker_catalog_none_falls_through_to_the_profile_fallback(only_acme):
assert models.provider_model_ids("acme-cat") == ["acme-fallback"]
def test_static_models_register_a_core_static_catalog_row(only_acme, monkeypatch):
def test_static_models_act_as_the_providers_static_catalog_row(only_acme):
only_acme(_acme(static_models=("acme-small", "acme-large")))
rows = dict(static._PROVIDER_MODELS)
monkeypatch.setattr(static, "_PROVIDER_MODELS", rows)
monkeypatch.setattr(models, "_PROVIDER_MODELS", rows)
monkeypatch.setattr(static, "_plugin_provider_enters_picker", lambda pp: False)
static.sync_plugin_provider_catalog()
assert rows["acme-cat"] == ["acme-small", "acme-large"]
assert models.static_catalog("acme-cat") == ["acme-small", "acme-large"]
assert models.get_default_model_for_provider("acme-cat") == "acme-small"
assert "acme-cat" in models.static_catalog_providers()
assert "acme-cat" not in static._PROVIDER_MODELS # resolved per lookup, never copied into the shared table
def test_probe_models_hook_answers_its_endpoint_and_declines_others(only_acme, monkeypatch):
@@ -0,0 +1,82 @@
"""A plugin profile's ``static_models`` belong to the home that registered it.
One process serves many profile homes. Copying the first home's ``static_models`` into the shared
static catalog made a second home with the same provider name silently default to the first home's
model, and a home without the plugin list (and switch to) a provider it does not have.
"""
from __future__ import annotations
import sys
import textwrap
from pathlib import Path
import pytest
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
SLUG = "acme-static"
def _plugin(models: tuple) -> str:
return textwrap.dedent(f"""
from providers import register_provider
from providers.base import ProviderProfile
register_provider(ProviderProfile(
name="{SLUG}", display_name="Acme Static", auth_type="api_key", env_vars=("ACME_STATIC_KEY",),
base_url="https://acme-static.invalid/v1", static_models={models!r}))
""")
@pytest.fixture
def homes(tmp_path, monkeypatch):
import providers
from hermes_cli import auth_plugin_providers as app
from hermes_cli.auth import PROVIDER_REGISTRY
found = {}
for name, models in (("f", ("acme-f-only",)), ("g", ("acme-g-small", "acme-g-large")), ("a", None)):
home = found[name] = tmp_path / name
home.mkdir()
if models:
plugin = home / "plugins" / "model-providers" / SLUG
plugin.mkdir(parents=True)
(plugin / "__init__.py").write_text(_plugin(models), encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(found["a"]))
monkeypatch.setattr(providers, "_REGISTRY", dict(providers._REGISTRY))
monkeypatch.setattr(providers, "_ALIASES", dict(providers._ALIASES))
monkeypatch.setattr(providers, "_PROVIDER_LIST_CACHE", None)
monkeypatch.setattr(providers, "_HOME_LAYERS", {})
monkeypatch.setattr(app, "PLUGIN_MIRRORED_PROVIDERS", set(app.PLUGIN_MIRRORED_PROVIDERS))
rows = list(dict.items(PROVIDER_REGISTRY))
yield found
PROVIDER_REGISTRY.clear()
PROVIDER_REGISTRY.update(rows)
for mod in [m for m in sys.modules if m.startswith("_hermes_user_provider")]:
del sys.modules[mod]
def _bound(home: Path, fn):
token = set_hermes_home_override(home)
try:
return fn()
finally:
reset_hermes_home_override(token)
def _view():
from hermes_cli import models
from hermes_cli.models_catalog_static import sync_plugin_provider_catalog
sync_plugin_provider_catalog() # what every registration in this home runs
return (models.get_default_model_for_provider(SLUG), models.static_catalog(SLUG),
SLUG in models.static_catalog_providers())
def test_each_home_sees_only_its_own_static_models(homes):
assert _bound(homes["f"], _view) == ("acme-f-only", ["acme-f-only"], True)
assert _bound(homes["g"], _view) == ("acme-g-small", ["acme-g-small", "acme-g-large"], True)
assert _bound(homes["a"], _view) == ("", [], False)
assert _bound(homes["f"], _view) == ("acme-f-only", ["acme-f-only"], True)