feat(model-catalog): picker catalogs refresh every 20 minutes, gateway keeps them warm
The /model picker's remote catalogs (curated manifest, OpenRouter live filter, Nous Portal recommendations) only refreshed when someone opened the picker on a stale cache, with a 1h TTL. A delisted model (tencent/hy3:free after the free promo ended) or a newly published one could sit stale for an hour after the manifest deploy, and indefinitely in a gateway nobody opened /model in. - model_catalog.ttl_minutes: 20 replaces ttl_hours: 1 as the default; an explicitly set legacy ttl_hours is still honoured. - model_catalog.refresh_catalogs() force-refreshes all three sources to disk; refresh_interval_seconds() exposes the cadence. - Gateway spawns a supervised _model_catalog_refresh_watcher that calls it off-thread every TTL window, so every surface on the machine reads a cache no older than 20 minutes. - Config migration v39→v40 drops the old ttl_hours: 1 default only. - Docs: reference/model-catalog.md updated.
This commit is contained in:
@@ -14551,6 +14551,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# Start background session expiry watcher to finalize expired sessions
|
||||
self._spawn_supervised(self._session_expiry_watcher, "session_expiry_watcher")
|
||||
|
||||
# Keep the /model picker's remote catalogs (curated manifest,
|
||||
# OpenRouter live list, Nous Portal recommendations) warm on disk so a
|
||||
# delisted or newly-published model reaches the picker within one TTL
|
||||
# window (model_catalog.ttl_minutes, default 20) without waiting for a
|
||||
# cold /model open to trigger the refresh.
|
||||
self._spawn_supervised(self._model_catalog_refresh_watcher, "model_catalog_refresh_watcher")
|
||||
|
||||
# Stall watchdog: pending inbound + stale agent activity → warn user
|
||||
# to /new (does not kill the turn; see agent.session_stall_timeout).
|
||||
self._spawn_supervised(self._session_stall_watcher, "session_stall_watcher")
|
||||
@@ -15674,6 +15681,32 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
|
||||
return sent
|
||||
|
||||
async def _model_catalog_refresh_watcher(self) -> None:
|
||||
"""Refresh the /model picker's remote catalogs every TTL window.
|
||||
|
||||
The picker itself only refreshes on a cold or stale open, so a
|
||||
gateway that nobody opens ``/model`` in keeps serving whatever was
|
||||
cached. This loop calls ``model_catalog.refresh_catalogs()`` (manifest
|
||||
+ OpenRouter live filter + Nous Portal recommendations) off-thread on
|
||||
the configured cadence (``model_catalog.ttl_minutes``, default 20) so
|
||||
the on-disk caches every surface reads are never older than one window.
|
||||
"""
|
||||
from hermes_cli.model_catalog import refresh_catalogs, refresh_interval_seconds
|
||||
|
||||
await asyncio.sleep(30) # let startup settle
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.to_thread(refresh_catalogs)
|
||||
except Exception as exc:
|
||||
logger.debug("Model catalog refresh failed: %s", exc)
|
||||
try:
|
||||
interval = refresh_interval_seconds()
|
||||
except Exception:
|
||||
interval = 1200.0
|
||||
deadline = time.monotonic() + interval
|
||||
while self._running and time.monotonic() < deadline:
|
||||
await asyncio.sleep(min(30.0, max(0.0, deadline - time.monotonic())))
|
||||
|
||||
async def _session_stall_watcher(self, interval: float = 30.0):
|
||||
"""Periodic pending-inbound + stale-activity stall watchdog (#72016).
|
||||
|
||||
|
||||
@@ -3116,10 +3116,12 @@ DEFAULT_CONFIG = {
|
||||
"model_catalog": {
|
||||
"enabled": True,
|
||||
"url": "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json",
|
||||
# Disk cache TTL in hours. Beyond this, the CLI refetches on the
|
||||
# next /model or `hermes model` invocation; network failures
|
||||
# silently fall back to the stale cache.
|
||||
"ttl_hours": 1,
|
||||
# Disk cache TTL in minutes. The gateway refreshes the catalogs on
|
||||
# this cadence in the background; the CLI refetches on the next
|
||||
# /model or `hermes model` invocation once the cache is older than
|
||||
# this. Network failures silently fall back to the stale cache.
|
||||
# (Legacy `ttl_hours` is still honoured when set explicitly.)
|
||||
"ttl_minutes": 20,
|
||||
# Optional per-provider override URLs for third parties that want
|
||||
# to self-host their own curation list using the same schema.
|
||||
# Example:
|
||||
@@ -4090,7 +4092,7 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 39,
|
||||
"_config_version": 40,
|
||||
}
|
||||
|
||||
# Optional environment variables that enhance functionality
|
||||
|
||||
@@ -863,6 +863,28 @@ def _migrate_to_39(results: Dict[str, Any], quiet: bool) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _migrate_to_40(results: Dict[str, Any], quiet: bool) -> None:
|
||||
# ── Version 39 → 40: model_catalog.ttl_hours → ttl_minutes (default 20) ──
|
||||
# The picker catalogs now refresh every 20 minutes (and the gateway
|
||||
# refreshes them in the background on that cadence). Only the OLD default
|
||||
# (ttl_hours: 1, written by the v25 migration) is dropped so the new
|
||||
# default applies; any other explicit ttl_hours is a deliberate choice
|
||||
# and stays honoured by the loader.
|
||||
_c = _cfg()
|
||||
read_raw_config = _c.read_raw_config
|
||||
_persist_migration = _c._persist_migration
|
||||
|
||||
config = read_raw_config()
|
||||
raw_mc = config.get("model_catalog")
|
||||
if isinstance(raw_mc, dict) and raw_mc.get("ttl_hours") == 1 and "ttl_minutes" not in raw_mc:
|
||||
del raw_mc["ttl_hours"]
|
||||
config["model_catalog"] = raw_mc
|
||||
_persist_migration(config)
|
||||
results["config_added"].append("model_catalog.ttl_hours 1 → ttl_minutes 20 (default)")
|
||||
if not quiet:
|
||||
print(" ✓ Model catalog now refreshes every 20 minutes (model_catalog.ttl_minutes)")
|
||||
|
||||
|
||||
#: Registry of (target_version, migration_fn), strictly ascending. The driver
|
||||
#: applies every entry whose target version is greater than the on-disk
|
||||
#: observe earlier steps' writes via read_raw_config() (filesystem state).
|
||||
@@ -890,6 +912,7 @@ MIGRATIONS: Tuple[Tuple[int, Callable[[Dict[str, Any], bool], None]], ...] = (
|
||||
(37, _migrate_to_37),
|
||||
(38, _migrate_to_38),
|
||||
(39, _migrate_to_39),
|
||||
(40, _migrate_to_40),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +74,10 @@ DEFAULT_CATALOG_URL = (
|
||||
DEFAULT_CATALOG_FALLBACK_URLS: tuple[str, ...] = (
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/static/api/model-catalog.json",
|
||||
)
|
||||
DEFAULT_TTL_HOURS = 1
|
||||
DEFAULT_TTL_MINUTES = 20
|
||||
# Legacy key. ``ttl_hours`` is honoured only when the user set it explicitly;
|
||||
# the shipped default is ``ttl_minutes`` above.
|
||||
DEFAULT_TTL_HOURS = DEFAULT_TTL_MINUTES / 60.0
|
||||
DEFAULT_FETCH_TIMEOUT = 8.0
|
||||
SUPPORTED_SCHEMA_VERSION = 1
|
||||
|
||||
@@ -104,10 +107,28 @@ def _load_catalog_config() -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
|
||||
# ``ttl_minutes`` is the shipped default (20). ``ttl_hours`` is the legacy
|
||||
# key: honoured when a user set it explicitly and ``ttl_minutes`` is still
|
||||
# at its default (load_config() deep-merges the default in, so "present"
|
||||
# alone doesn't mean "user-set"), so old customized configs keep their
|
||||
# chosen window.
|
||||
ttl_minutes = raw.get("ttl_minutes")
|
||||
try:
|
||||
ttl_minutes = float(ttl_minutes) if ttl_minutes not in (None, "") else DEFAULT_TTL_MINUTES
|
||||
except (TypeError, ValueError):
|
||||
ttl_minutes = DEFAULT_TTL_MINUTES
|
||||
if ttl_minutes == DEFAULT_TTL_MINUTES and raw.get("ttl_hours"):
|
||||
try:
|
||||
ttl_minutes = float(raw["ttl_hours"]) * 60.0
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if ttl_minutes <= 0:
|
||||
ttl_minutes = DEFAULT_TTL_MINUTES
|
||||
|
||||
return {
|
||||
"enabled": bool(raw.get("enabled", True)),
|
||||
"url": str(raw.get("url") or DEFAULT_CATALOG_URL),
|
||||
"ttl_hours": float(raw.get("ttl_hours") or DEFAULT_TTL_HOURS),
|
||||
"ttl_hours": ttl_minutes / 60.0,
|
||||
"providers": raw.get("providers") if isinstance(raw.get("providers"), dict) else {},
|
||||
}
|
||||
|
||||
@@ -330,6 +351,33 @@ def get_catalog(*, force_refresh: bool = False) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def refresh_interval_seconds() -> float:
|
||||
"""Return the configured catalog TTL in seconds (the gateway poll cadence)."""
|
||||
return max(60.0, _load_catalog_config()["ttl_hours"] * 3600.0)
|
||||
|
||||
|
||||
def refresh_catalogs() -> bool:
|
||||
"""Force-refresh every remote model catalog the picker reads from.
|
||||
|
||||
Fetches the curated manifest, the OpenRouter live list (tool-support /
|
||||
free-pricing filter) and the Nous Portal recommendations, writing each
|
||||
to its disk cache so the next ``/model`` open in ANY process on this
|
||||
machine sees the new lists. Blocking; run it off the event loop.
|
||||
Returns True when the manifest refresh succeeded.
|
||||
"""
|
||||
if not _load_catalog_config()["enabled"]:
|
||||
return False
|
||||
catalog = get_catalog(force_refresh=True)
|
||||
try:
|
||||
from hermes_cli.models import fetch_nous_recommended_models, fetch_openrouter_models
|
||||
|
||||
fetch_openrouter_models(force_refresh=True)
|
||||
fetch_nous_recommended_models(force_refresh=True)
|
||||
except Exception:
|
||||
logger.debug("provider catalog refresh failed", exc_info=True)
|
||||
return bool(catalog)
|
||||
|
||||
|
||||
def _fetch_provider_override(provider: str) -> dict[str, Any] | None:
|
||||
"""If ``model_catalog.providers.<name>.url`` is set, fetch that instead."""
|
||||
cfg = _load_catalog_config()
|
||||
|
||||
@@ -896,7 +896,9 @@ class TestConfigSupportFloor:
|
||||
},
|
||||
"memory": {"write_approval": True},
|
||||
"model": {"default": "openai/gpt-5.4", "provider": "openrouter"},
|
||||
"model_catalog": {"ttl_hours": 1},
|
||||
# v25 lowered the old 24h default to 1h; v40 drops that 1h default so
|
||||
# the shipped ttl_minutes (20) applies.
|
||||
"model_catalog": {},
|
||||
"plugins": {"enabled": []},
|
||||
"stt": {"provider": "local"},
|
||||
}
|
||||
@@ -915,7 +917,7 @@ class TestConfigSupportFloor:
|
||||
# default (opt-in) so the write invariant strips it from disk.
|
||||
"agent": {},
|
||||
"model": {"default": "anthropic/claude-fable-5", "provider": "nous"},
|
||||
"model_catalog": {"ttl_hours": 1},
|
||||
"model_catalog": {},
|
||||
"plugins": {"disabled": ["foo"], "enabled": []},
|
||||
}
|
||||
|
||||
|
||||
@@ -307,6 +307,30 @@ class TestProviderOverride:
|
||||
assert result == [("override/model", "custom")]
|
||||
|
||||
|
||||
class TestRefreshCadence:
|
||||
def test_default_ttl_is_twenty_minutes_and_legacy_hours_honoured(self):
|
||||
from hermes_cli import model_catalog
|
||||
|
||||
with patch("hermes_cli.config.load_config", return_value={"model_catalog": {"ttl_minutes": 20}}):
|
||||
assert model_catalog.refresh_interval_seconds() == 20 * 60
|
||||
# A user-set legacy ttl_hours still wins while ttl_minutes sits at its default.
|
||||
with patch("hermes_cli.config.load_config", return_value={"model_catalog": {"ttl_minutes": 20, "ttl_hours": 3}}):
|
||||
assert model_catalog.refresh_interval_seconds() == 3 * 3600
|
||||
|
||||
def test_refresh_catalogs_forces_every_source(self):
|
||||
from hermes_cli import model_catalog
|
||||
|
||||
with patch.object(model_catalog, "_load_catalog_config", return_value={
|
||||
"enabled": True, "url": "http://master", "ttl_hours": 1.0, "providers": {},
|
||||
}), patch.object(model_catalog, "get_catalog", return_value=_valid_manifest()) as gc, \
|
||||
patch("hermes_cli.models.fetch_openrouter_models") as orm, \
|
||||
patch("hermes_cli.models.fetch_nous_recommended_models") as nous:
|
||||
assert model_catalog.refresh_catalogs() is True
|
||||
gc.assert_called_once_with(force_refresh=True)
|
||||
orm.assert_called_once_with(force_refresh=True)
|
||||
nous.assert_called_once_with(force_refresh=True)
|
||||
|
||||
|
||||
class TestIntegrationWithModelsModule:
|
||||
"""Exercise the fallback paths via the real callers in hermes_cli.models."""
|
||||
|
||||
|
||||
@@ -63,9 +63,10 @@ def test_docker_config_migrate_backs_up_and_migrates_legacy_config(tmp_path: Pat
|
||||
assert "Migrating config schema 12 ->" in proc.stdout
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
|
||||
# v24→25 lowers the old default model_catalog TTL; v32→33 folds
|
||||
# v24→25 lowers the old default model_catalog TTL to 1h, v39→40 drops
|
||||
# that default so ttl_minutes (20) applies; v32→33 folds
|
||||
# max_async_children into max_concurrent_children.
|
||||
assert raw["model_catalog"]["ttl_hours"] == 1
|
||||
assert "ttl_hours" not in raw["model_catalog"]
|
||||
assert raw["delegation"] == {"max_concurrent_children": 8}
|
||||
assert list(tmp_path.glob("config.yaml.bak-*"))
|
||||
assert list(tmp_path.glob(".env.bak-*"))
|
||||
|
||||
@@ -59,6 +59,7 @@ Field notes:
|
||||
| When | What happens |
|
||||
|---|---|
|
||||
| `/model` or `hermes model` | Fetches if disk cache is stale, else uses cache |
|
||||
| Gateway running | Background refresh every `ttl_minutes` (default 20), so the picker never lags the published manifest by more than one window |
|
||||
| Disk cache fresh (< TTL) | No network hit |
|
||||
| Network failure with cache | Silent fallback to cache, one log line |
|
||||
| Network failure, no cache | Silent fallback to in-repo snapshot |
|
||||
@@ -72,11 +73,11 @@ Cache location: `~/.hermes/cache/model_catalog.json`.
|
||||
model_catalog:
|
||||
enabled: true
|
||||
url: https://hermes-agent.nousresearch.com/docs/api/model-catalog.json
|
||||
ttl_hours: 1
|
||||
ttl_minutes: 20
|
||||
providers: {}
|
||||
```
|
||||
|
||||
Set `enabled: false` to disable remote fetch entirely and always use the in-repo snapshot.
|
||||
Set `enabled: false` to disable remote fetch entirely and always use the in-repo snapshot (this also disables the gateway's background refresh). `ttl_minutes` sets both the cache lifetime and the gateway refresh cadence; the legacy `ttl_hours` key is still honoured if you set it explicitly.
|
||||
|
||||
### Per-provider override URLs
|
||||
|
||||
|
||||
Reference in New Issue
Block a user