From c2954c893400a93a826c31e4c641a2db342276b8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:04:25 -0700 Subject: [PATCH] feat(model-catalog): picker catalogs refresh every 20 minutes, gateway keeps them warm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- gateway/run.py | 33 ++++++++++++++ hermes_cli/config_defaults.py | 12 +++--- hermes_cli/config_migrations.py | 23 ++++++++++ hermes_cli/model_catalog.py | 52 ++++++++++++++++++++++- tests/hermes_cli/test_config.py | 6 ++- tests/hermes_cli/test_model_catalog.py | 24 +++++++++++ tests/tools/test_docker_config_migrate.py | 5 ++- website/docs/reference/model-catalog.md | 5 ++- 8 files changed, 147 insertions(+), 13 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 7f0fdfed9e..6dfeb8f9b8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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). diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 70709a52a6..b766a166e5 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -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 diff --git a/hermes_cli/config_migrations.py b/hermes_cli/config_migrations.py index 3357aaec16..cb075be302 100644 --- a/hermes_cli/config_migrations.py +++ b/hermes_cli/config_migrations.py @@ -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), ) diff --git a/hermes_cli/model_catalog.py b/hermes_cli/model_catalog.py index 5aa479fa87..1e869b746a 100644 --- a/hermes_cli/model_catalog.py +++ b/hermes_cli/model_catalog.py @@ -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..url`` is set, fetch that instead.""" cfg = _load_catalog_config() diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 366782d76a..0db5db5226 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -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": []}, } diff --git a/tests/hermes_cli/test_model_catalog.py b/tests/hermes_cli/test_model_catalog.py index b4d8e8a40a..3e9c1844ff 100644 --- a/tests/hermes_cli/test_model_catalog.py +++ b/tests/hermes_cli/test_model_catalog.py @@ -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.""" diff --git a/tests/tools/test_docker_config_migrate.py b/tests/tools/test_docker_config_migrate.py index 5a8ec0cd8c..4472f92198 100644 --- a/tests/tools/test_docker_config_migrate.py +++ b/tests/tools/test_docker_config_migrate.py @@ -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-*")) diff --git a/website/docs/reference/model-catalog.md b/website/docs/reference/model-catalog.md index 4769a720c8..b26a1399f0 100644 --- a/website/docs/reference/model-catalog.md +++ b/website/docs/reference/model-catalog.md @@ -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