mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
99cf3a6358
commit
8d359c7824
7 files changed
+123
-30
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user