/ hermes_cli / model_catalog.py
model_catalog.py
1 """Remote model catalog fetcher. 2 3 The Hermes docs site hosts a JSON manifest of curated models for providers 4 we want to update without shipping a release (currently OpenRouter and 5 Nous Portal). This module fetches, validates, and caches that manifest, 6 falling back to the in-repo hardcoded lists when the network is unavailable. 7 8 Pipeline 9 -------- 10 1. ``get_catalog()`` — returns a parsed manifest dict. 11 - Checks in-process cache (invalidated by TTL). 12 - Reads disk cache at ``~/.hermes/cache/model_catalog.json``. 13 - Fetches the master URL if disk cache is stale or missing. 14 - On any fetch failure, keeps using the stale cache (or empty dict). 15 16 2. ``get_curated_openrouter_models()`` / ``get_curated_nous_models()`` — 17 thin accessors returning the shapes existing callers expect. Each 18 falls back to the in-repo hardcoded list on any lookup failure. 19 20 Schema (version 1) 21 ------------------ 22 :: 23 24 { 25 "version": 1, 26 "updated_at": "2026-04-25T22:00:00Z", 27 "metadata": {...}, # free-form 28 "providers": { 29 "openrouter": { 30 "metadata": {...}, # free-form 31 "models": [ 32 {"id": "vendor/model", "description": "recommended", 33 "metadata": {...}} # free-form, model-level 34 ] 35 }, 36 "nous": {...} 37 } 38 } 39 40 Unknown fields are ignored — extra metadata can be added at either level 41 without bumping ``version``. ``version`` bumps are reserved for 42 breaking changes (renaming ``providers``, changing ``models`` shape). 43 """ 44 45 from __future__ import annotations 46 47 import json 48 import logging 49 import time 50 import urllib.error 51 import urllib.request 52 from pathlib import Path 53 from typing import Any 54 55 from hermes_cli import __version__ as _HERMES_VERSION 56 from utils import atomic_replace 57 58 logger = logging.getLogger(__name__) 59 60 # --------------------------------------------------------------------------- 61 # Constants 62 # --------------------------------------------------------------------------- 63 64 DEFAULT_CATALOG_URL = ( 65 "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json" 66 ) 67 DEFAULT_TTL_HOURS = 24 68 DEFAULT_FETCH_TIMEOUT = 8.0 69 SUPPORTED_SCHEMA_VERSION = 1 70 71 _HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}" 72 73 # In-process cache to avoid repeated disk + parse work across multiple 74 # calls within the same session. Invalidated by TTL against the disk file's 75 # mtime, so calling code never has to think about this. 76 _catalog_cache: dict[str, Any] | None = None 77 _catalog_cache_source_mtime: float = 0.0 78 79 80 # --------------------------------------------------------------------------- 81 # Config 82 # --------------------------------------------------------------------------- 83 84 85 def _load_catalog_config() -> dict[str, Any]: 86 """Load the ``model_catalog`` config block with defaults filled in.""" 87 try: 88 from hermes_cli.config import load_config 89 cfg = load_config() or {} 90 except Exception: 91 cfg = {} 92 93 raw = cfg.get("model_catalog") 94 if not isinstance(raw, dict): 95 raw = {} 96 97 return { 98 "enabled": bool(raw.get("enabled", True)), 99 "url": str(raw.get("url") or DEFAULT_CATALOG_URL), 100 "ttl_hours": float(raw.get("ttl_hours") or DEFAULT_TTL_HOURS), 101 "providers": raw.get("providers") if isinstance(raw.get("providers"), dict) else {}, 102 } 103 104 105 def _cache_path() -> Path: 106 """Return the disk cache path. Import lazily so tests can monkeypatch home.""" 107 from hermes_constants import get_hermes_home 108 return get_hermes_home() / "cache" / "model_catalog.json" 109 110 111 # --------------------------------------------------------------------------- 112 # Fetch + validate + cache 113 # --------------------------------------------------------------------------- 114 115 116 def _fetch_manifest(url: str, timeout: float) -> dict[str, Any] | None: 117 """HTTP GET the manifest URL and return a parsed dict, or None on failure.""" 118 try: 119 req = urllib.request.Request( 120 url, 121 headers={ 122 "Accept": "application/json", 123 "User-Agent": _HERMES_USER_AGENT, 124 }, 125 ) 126 with urllib.request.urlopen(req, timeout=timeout) as resp: 127 data = json.loads(resp.read().decode()) 128 except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc: 129 logger.info("model catalog fetch failed (%s): %s", url, exc) 130 return None 131 except Exception as exc: # pragma: no cover — defensive 132 logger.info("model catalog fetch errored (%s): %s", url, exc) 133 return None 134 135 if not _validate_manifest(data): 136 logger.info("model catalog at %s failed schema validation", url) 137 return None 138 139 return data 140 141 142 def _validate_manifest(data: Any) -> bool: 143 """Return True when ``data`` matches the minimum manifest shape.""" 144 if not isinstance(data, dict): 145 return False 146 version = data.get("version") 147 if not isinstance(version, int) or version > SUPPORTED_SCHEMA_VERSION: 148 # Future schema version we don't understand — refuse rather than 149 # guess. Older schemas (version < 1) aren't supported either. 150 return False 151 providers = data.get("providers") 152 if not isinstance(providers, dict): 153 return False 154 for pname, pblock in providers.items(): 155 if not isinstance(pname, str) or not isinstance(pblock, dict): 156 return False 157 models = pblock.get("models") 158 if not isinstance(models, list): 159 return False 160 for m in models: 161 if not isinstance(m, dict): 162 return False 163 if not isinstance(m.get("id"), str) or not m["id"].strip(): 164 return False 165 return True 166 167 168 def _read_disk_cache() -> tuple[dict[str, Any] | None, float]: 169 """Return ``(data_or_none, mtime)``. mtime is 0 if file is missing.""" 170 path = _cache_path() 171 try: 172 mtime = path.stat().st_mtime 173 except (OSError, FileNotFoundError): 174 return (None, 0.0) 175 try: 176 with open(path) as fh: 177 data = json.load(fh) 178 except (OSError, json.JSONDecodeError): 179 return (None, 0.0) 180 if not _validate_manifest(data): 181 return (None, 0.0) 182 return (data, mtime) 183 184 185 def _write_disk_cache(data: dict[str, Any]) -> None: 186 path = _cache_path() 187 try: 188 path.parent.mkdir(parents=True, exist_ok=True) 189 tmp = path.with_suffix(path.suffix + ".tmp") 190 with open(tmp, "w") as fh: 191 json.dump(data, fh, indent=2) 192 fh.write("\n") 193 atomic_replace(tmp, path) 194 except OSError as exc: 195 logger.info("model catalog cache write failed: %s", exc) 196 197 198 # --------------------------------------------------------------------------- 199 # Public API 200 # --------------------------------------------------------------------------- 201 202 203 def get_catalog(*, force_refresh: bool = False) -> dict[str, Any]: 204 """Return the parsed model catalog manifest, or an empty dict on failure. 205 206 Callers should treat a missing provider/model as "use the in-repo fallback" 207 — never raise from this function so the CLI keeps working offline. 208 """ 209 global _catalog_cache, _catalog_cache_source_mtime 210 211 cfg = _load_catalog_config() 212 if not cfg["enabled"]: 213 return {} 214 215 ttl_seconds = max(0.0, cfg["ttl_hours"] * 3600.0) 216 217 disk_data, disk_mtime = _read_disk_cache() 218 now = time.time() 219 disk_fresh = disk_data is not None and (now - disk_mtime) < ttl_seconds 220 221 # In-process cache hit: disk hasn't changed since we loaded it and still fresh. 222 if ( 223 not force_refresh 224 and _catalog_cache is not None 225 and disk_data is not None 226 and disk_mtime == _catalog_cache_source_mtime 227 and disk_fresh 228 ): 229 return _catalog_cache 230 231 # Disk is fresh enough — use it without a network hit. 232 if not force_refresh and disk_fresh and disk_data is not None: 233 _catalog_cache = disk_data 234 _catalog_cache_source_mtime = disk_mtime 235 return disk_data 236 237 # Need to (re)fetch. If it fails, fall back to any stale disk copy. 238 fetched = _fetch_manifest(cfg["url"], DEFAULT_FETCH_TIMEOUT) 239 if fetched is not None: 240 _write_disk_cache(fetched) 241 new_disk_data, new_mtime = _read_disk_cache() 242 if new_disk_data is not None: 243 _catalog_cache = new_disk_data 244 _catalog_cache_source_mtime = new_mtime 245 return new_disk_data 246 _catalog_cache = fetched 247 _catalog_cache_source_mtime = now 248 return fetched 249 250 if disk_data is not None: 251 _catalog_cache = disk_data 252 _catalog_cache_source_mtime = disk_mtime 253 return disk_data 254 255 return {} 256 257 258 def _fetch_provider_override(provider: str) -> dict[str, Any] | None: 259 """If ``model_catalog.providers.<name>.url`` is set, fetch that instead.""" 260 cfg = _load_catalog_config() 261 if not cfg["enabled"]: 262 return None 263 provider_cfg = cfg["providers"].get(provider) 264 if not isinstance(provider_cfg, dict): 265 return None 266 override_url = provider_cfg.get("url") 267 if not isinstance(override_url, str) or not override_url.strip(): 268 return None 269 # Override fetches skip the disk cache because they're usually 270 # third-party self-hosted. Re-request on every call but with a short 271 # timeout so they don't block the picker. 272 return _fetch_manifest(override_url.strip(), DEFAULT_FETCH_TIMEOUT) 273 274 275 def _get_provider_block(provider: str) -> dict[str, Any] | None: 276 """Return the provider's manifest block, respecting per-provider overrides.""" 277 override = _fetch_provider_override(provider) 278 if override is not None: 279 block = override.get("providers", {}).get(provider) 280 if isinstance(block, dict): 281 return block 282 283 catalog = get_catalog() 284 if not catalog: 285 return None 286 block = catalog.get("providers", {}).get(provider) 287 return block if isinstance(block, dict) else None 288 289 290 def get_curated_openrouter_models() -> list[tuple[str, str]] | None: 291 """Return OpenRouter's curated ``[(id, description), ...]`` from the manifest. 292 293 Returns ``None`` when the manifest is unavailable, so callers can fall 294 back to their hardcoded list. 295 """ 296 block = _get_provider_block("openrouter") 297 if not block: 298 return None 299 out: list[tuple[str, str]] = [] 300 for m in block.get("models", []): 301 mid = str(m.get("id") or "").strip() 302 if not mid: 303 continue 304 desc = str(m.get("description") or "") 305 out.append((mid, desc)) 306 return out or None 307 308 309 def get_curated_nous_models() -> list[str] | None: 310 """Return Nous Portal's curated list of model ids from the manifest. 311 312 Returns ``None`` when the manifest is unavailable. 313 """ 314 block = _get_provider_block("nous") 315 if not block: 316 return None 317 out: list[str] = [] 318 for m in block.get("models", []): 319 mid = str(m.get("id") or "").strip() 320 if mid: 321 out.append(mid) 322 return out or None 323 324 325 def reset_cache() -> None: 326 """Clear the in-process cache. Used by tests and ``hermes model --refresh``.""" 327 global _catalog_cache, _catalog_cache_source_mtime 328 _catalog_cache = None 329 _catalog_cache_source_mtime = 0.0