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:
Teknium
2026-09-02 06:04:25 -07:00
parent 11f932c935
commit c2954c8934
8 changed files with 147 additions and 13 deletions

View File

@@ -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).

View File

@@ -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

View File

@@ -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),
)

View File

@@ -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()

View File

@@ -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": []},
}

View File

@@ -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."""

View File

@@ -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-*"))

View File

@@ -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