diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 482e3c47a2..eeccbece98 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -4244,10 +4244,10 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: ) from hermes_cli.models import ( - _PROVIDER_MODELS, get_pricing_for_provider, + get_curated_nous_model_ids, get_pricing_for_provider, check_nous_free_tier, partition_nous_models_by_tier, ) - model_ids = _PROVIDER_MODELS.get("nous", []) + model_ids = get_curated_nous_model_ids() print() unavailable_models: list = [] diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3b5e24a376..4af2aff1de 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -959,6 +959,27 @@ DEFAULT_CONFIG = { "backup_count": 3, # Number of rotated backup files to keep }, + # Remotely-hosted model catalog manifest. When enabled, the CLI fetches + # curated model lists for OpenRouter and Nous Portal from this URL, + # falling back to the in-repo snapshot on network failure. Lets us + # update model picker lists without shipping a hermes-agent release. + # The default URL is served by the docs site GitHub Pages deploy. + "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": 24, + # Optional per-provider override URLs for third parties that want + # to self-host their own curation list using the same schema. + # Example: + # providers: + # openrouter: + # url: https://example.com/my-curation.json + "providers": {}, + }, + # Network settings — workarounds for connectivity issues. "network": { # Force IPv4 connections. On servers with broken or unreachable IPv6, diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2064b324f5..30dfee21e2 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2315,13 +2315,13 @@ def _model_flow_nous(config, current_model="", args=None): # The live /models endpoint returns hundreds of models; the curated list # shows only agentic models users recognize from OpenRouter. from hermes_cli.models import ( - _PROVIDER_MODELS, + get_curated_nous_model_ids, get_pricing_for_provider, check_nous_free_tier, partition_nous_models_by_tier, ) - model_ids = _PROVIDER_MODELS.get("nous", []) + model_ids = get_curated_nous_model_ids() if not model_ids: print("No curated models available for Nous Portal.") return diff --git a/hermes_cli/model_catalog.py b/hermes_cli/model_catalog.py new file mode 100644 index 0000000000..500910d57f --- /dev/null +++ b/hermes_cli/model_catalog.py @@ -0,0 +1,329 @@ +"""Remote model catalog fetcher. + +The Hermes docs site hosts a JSON manifest of curated models for providers +we want to update without shipping a release (currently OpenRouter and +Nous Portal). This module fetches, validates, and caches that manifest, +falling back to the in-repo hardcoded lists when the network is unavailable. + +Pipeline +-------- +1. ``get_catalog()`` — returns a parsed manifest dict. + - Checks in-process cache (invalidated by TTL). + - Reads disk cache at ``~/.hermes/cache/model_catalog.json``. + - Fetches the master URL if disk cache is stale or missing. + - On any fetch failure, keeps using the stale cache (or empty dict). + +2. ``get_curated_openrouter_models()`` / ``get_curated_nous_models()`` — + thin accessors returning the shapes existing callers expect. Each + falls back to the in-repo hardcoded list on any lookup failure. + +Schema (version 1) +------------------ +:: + + { + "version": 1, + "updated_at": "2026-04-25T22:00:00Z", + "metadata": {...}, # free-form + "providers": { + "openrouter": { + "metadata": {...}, # free-form + "models": [ + {"id": "vendor/model", "description": "recommended", + "metadata": {...}} # free-form, model-level + ] + }, + "nous": {...} + } + } + +Unknown fields are ignored — extra metadata can be added at either level +without bumping ``version``. ``version`` bumps are reserved for +breaking changes (renaming ``providers``, changing ``models`` shape). +""" + +from __future__ import annotations + +import json +import logging +import os +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +from hermes_cli import __version__ as _HERMES_VERSION + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DEFAULT_CATALOG_URL = ( + "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json" +) +DEFAULT_TTL_HOURS = 24 +DEFAULT_FETCH_TIMEOUT = 8.0 +SUPPORTED_SCHEMA_VERSION = 1 + +_HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}" + +# In-process cache to avoid repeated disk + parse work across multiple +# calls within the same session. Invalidated by TTL against the disk file's +# mtime, so calling code never has to think about this. +_catalog_cache: dict[str, Any] | None = None +_catalog_cache_source_mtime: float = 0.0 + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + + +def _load_catalog_config() -> dict[str, Any]: + """Load the ``model_catalog`` config block with defaults filled in.""" + try: + from hermes_cli.config import load_config + cfg = load_config() or {} + except Exception: + cfg = {} + + raw = cfg.get("model_catalog") + if not isinstance(raw, dict): + raw = {} + + 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), + "providers": raw.get("providers") if isinstance(raw.get("providers"), dict) else {}, + } + + +def _cache_path() -> Path: + """Return the disk cache path. Import lazily so tests can monkeypatch home.""" + from hermes_constants import get_hermes_home + return get_hermes_home() / "cache" / "model_catalog.json" + + +# --------------------------------------------------------------------------- +# Fetch + validate + cache +# --------------------------------------------------------------------------- + + +def _fetch_manifest(url: str, timeout: float) -> dict[str, Any] | None: + """HTTP GET the manifest URL and return a parsed dict, or None on failure.""" + try: + req = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": _HERMES_USER_AGENT, + }, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode()) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc: + logger.info("model catalog fetch failed (%s): %s", url, exc) + return None + except Exception as exc: # pragma: no cover — defensive + logger.info("model catalog fetch errored (%s): %s", url, exc) + return None + + if not _validate_manifest(data): + logger.info("model catalog at %s failed schema validation", url) + return None + + return data + + +def _validate_manifest(data: Any) -> bool: + """Return True when ``data`` matches the minimum manifest shape.""" + if not isinstance(data, dict): + return False + version = data.get("version") + if not isinstance(version, int) or version > SUPPORTED_SCHEMA_VERSION: + # Future schema version we don't understand — refuse rather than + # guess. Older schemas (version < 1) aren't supported either. + return False + providers = data.get("providers") + if not isinstance(providers, dict): + return False + for pname, pblock in providers.items(): + if not isinstance(pname, str) or not isinstance(pblock, dict): + return False + models = pblock.get("models") + if not isinstance(models, list): + return False + for m in models: + if not isinstance(m, dict): + return False + if not isinstance(m.get("id"), str) or not m["id"].strip(): + return False + return True + + +def _read_disk_cache() -> tuple[dict[str, Any] | None, float]: + """Return ``(data_or_none, mtime)``. mtime is 0 if file is missing.""" + path = _cache_path() + try: + mtime = path.stat().st_mtime + except (OSError, FileNotFoundError): + return (None, 0.0) + try: + with open(path) as fh: + data = json.load(fh) + except (OSError, json.JSONDecodeError): + return (None, 0.0) + if not _validate_manifest(data): + return (None, 0.0) + return (data, mtime) + + +def _write_disk_cache(data: dict[str, Any]) -> None: + path = _cache_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "w") as fh: + json.dump(data, fh, indent=2) + fh.write("\n") + os.replace(tmp, path) + except OSError as exc: + logger.info("model catalog cache write failed: %s", exc) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def get_catalog(*, force_refresh: bool = False) -> dict[str, Any]: + """Return the parsed model catalog manifest, or an empty dict on failure. + + Callers should treat a missing provider/model as "use the in-repo fallback" + — never raise from this function so the CLI keeps working offline. + """ + global _catalog_cache, _catalog_cache_source_mtime + + cfg = _load_catalog_config() + if not cfg["enabled"]: + return {} + + ttl_seconds = max(0.0, cfg["ttl_hours"] * 3600.0) + + disk_data, disk_mtime = _read_disk_cache() + now = time.time() + disk_fresh = disk_data is not None and (now - disk_mtime) < ttl_seconds + + # In-process cache hit: disk hasn't changed since we loaded it and still fresh. + if ( + not force_refresh + and _catalog_cache is not None + and disk_data is not None + and disk_mtime == _catalog_cache_source_mtime + and disk_fresh + ): + return _catalog_cache + + # Disk is fresh enough — use it without a network hit. + if not force_refresh and disk_fresh and disk_data is not None: + _catalog_cache = disk_data + _catalog_cache_source_mtime = disk_mtime + return disk_data + + # Need to (re)fetch. If it fails, fall back to any stale disk copy. + fetched = _fetch_manifest(cfg["url"], DEFAULT_FETCH_TIMEOUT) + if fetched is not None: + _write_disk_cache(fetched) + new_disk_data, new_mtime = _read_disk_cache() + if new_disk_data is not None: + _catalog_cache = new_disk_data + _catalog_cache_source_mtime = new_mtime + return new_disk_data + _catalog_cache = fetched + _catalog_cache_source_mtime = now + return fetched + + if disk_data is not None: + _catalog_cache = disk_data + _catalog_cache_source_mtime = disk_mtime + return disk_data + + return {} + + +def _fetch_provider_override(provider: str) -> dict[str, Any] | None: + """If ``model_catalog.providers..url`` is set, fetch that instead.""" + cfg = _load_catalog_config() + if not cfg["enabled"]: + return None + provider_cfg = cfg["providers"].get(provider) + if not isinstance(provider_cfg, dict): + return None + override_url = provider_cfg.get("url") + if not isinstance(override_url, str) or not override_url.strip(): + return None + # Override fetches skip the disk cache because they're usually + # third-party self-hosted. Re-request on every call but with a short + # timeout so they don't block the picker. + return _fetch_manifest(override_url.strip(), DEFAULT_FETCH_TIMEOUT) + + +def _get_provider_block(provider: str) -> dict[str, Any] | None: + """Return the provider's manifest block, respecting per-provider overrides.""" + override = _fetch_provider_override(provider) + if override is not None: + block = override.get("providers", {}).get(provider) + if isinstance(block, dict): + return block + + catalog = get_catalog() + if not catalog: + return None + block = catalog.get("providers", {}).get(provider) + return block if isinstance(block, dict) else None + + +def get_curated_openrouter_models() -> list[tuple[str, str]] | None: + """Return OpenRouter's curated ``[(id, description), ...]`` from the manifest. + + Returns ``None`` when the manifest is unavailable, so callers can fall + back to their hardcoded list. + """ + block = _get_provider_block("openrouter") + if not block: + return None + out: list[tuple[str, str]] = [] + for m in block.get("models", []): + mid = str(m.get("id") or "").strip() + if not mid: + continue + desc = str(m.get("description") or "") + out.append((mid, desc)) + return out or None + + +def get_curated_nous_models() -> list[str] | None: + """Return Nous Portal's curated list of model ids from the manifest. + + Returns ``None`` when the manifest is unavailable. + """ + block = _get_provider_block("nous") + if not block: + return None + out: list[str] = [] + for m in block.get("models", []): + mid = str(m.get("id") or "").strip() + if mid: + out.append(mid) + return out or None + + +def reset_cache() -> None: + """Clear the in-process cache. Used by tests and ``hermes model --refresh``.""" + global _catalog_cache, _catalog_cache_source_mtime + _catalog_cache = None + _catalog_cache_source_mtime = 0.0 diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 23ddc6f3ca..dbc1a1e2b6 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -876,7 +876,16 @@ def fetch_openrouter_models( if _openrouter_catalog_cache is not None and not force_refresh: return list(_openrouter_catalog_cache) - fallback = list(OPENROUTER_MODELS) + # Prefer the remotely-hosted catalog manifest; fall back to the in-repo + # snapshot when the manifest is unreachable. Both are curated lists that + # drive the picker; the OpenRouter live /v1/models filter (tool support, + # free pricing) is applied on top either way. + try: + from hermes_cli.model_catalog import get_curated_openrouter_models + remote = get_curated_openrouter_models() + except Exception: + remote = None + fallback = list(remote) if remote else list(OPENROUTER_MODELS) preferred_ids = [mid for mid, _ in fallback] try: @@ -929,6 +938,24 @@ def model_ids(*, force_refresh: bool = False) -> list[str]: return [mid for mid, _ in fetch_openrouter_models(force_refresh=force_refresh)] +def get_curated_nous_model_ids() -> list[str]: + """Return the curated Nous Portal model-id list. + + Prefers the remotely-hosted catalog manifest (published under + ``website/static/api/model-catalog.json``); falls back to the in-repo + snapshot in ``_PROVIDER_MODELS["nous"]`` when the manifest is + unreachable. Always returns a list (never None). + """ + try: + from hermes_cli.model_catalog import get_curated_nous_models + remote = get_curated_nous_models() + except Exception: + remote = None + if remote: + return list(remote) + return list(_PROVIDER_MODELS.get("nous", [])) + + def _ai_gateway_model_is_free(pricing: Any) -> bool: """Return True if an AI Gateway model has $0 input AND output pricing.""" if not isinstance(pricing, dict): diff --git a/scripts/build_model_catalog.py b/scripts/build_model_catalog.py new file mode 100755 index 0000000000..cd21c929e7 --- /dev/null +++ b/scripts/build_model_catalog.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Build the Hermes Model Catalog — a centralized JSON manifest of curated models. + +This script reads the in-repo hardcoded curated lists (``OPENROUTER_MODELS``, +``_PROVIDER_MODELS["nous"]``) and writes them to a JSON manifest that the +Hermes CLI fetches at runtime. Publishing the catalog through the docs site +lets maintainers update model lists without shipping a Hermes release. + +The runtime fetcher falls back to the same in-repo hardcoded lists if the +manifest is unreachable, so this script is a convenience for keeping the +manifest in sync — not a source of truth. + +Usage:: + + python scripts/build_model_catalog.py + +Output: ``website/static/api/model-catalog.json`` + +Live URL (after ``deploy-site.yml`` runs on merge to main): +``https://hermes-agent.nousresearch.com/docs/api/model-catalog.json`` +""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime, timezone + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO_ROOT) + +# Ensure HERMES_HOME is set for imports that touch it at module level. +os.environ.setdefault("HERMES_HOME", os.path.join(os.path.expanduser("~"), ".hermes")) + +from hermes_cli.models import OPENROUTER_MODELS, _PROVIDER_MODELS # noqa: E402 + +OUTPUT_PATH = os.path.join(REPO_ROOT, "website", "static", "api", "model-catalog.json") +CATALOG_VERSION = 1 + + +def build_catalog() -> dict: + return { + "version": CATALOG_VERSION, + "updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "metadata": { + "source": "hermes-agent repo", + "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog", + }, + "providers": { + "openrouter": { + "metadata": { + "display_name": "OpenRouter", + "note": ( + "Descriptions drive picker badges. Live /api/v1/models " + "filters curated ids by tool-calling support and free pricing." + ), + }, + "models": [ + {"id": mid, "description": desc} + for mid, desc in OPENROUTER_MODELS + ], + }, + "nous": { + "metadata": { + "display_name": "Nous Portal", + "note": ( + "Free-tier gating is determined live via Portal pricing " + "(partition_nous_models_by_tier), not this manifest." + ), + }, + "models": [ + {"id": mid} + for mid in _PROVIDER_MODELS.get("nous", []) + ], + }, + }, + } + + +def main() -> int: + catalog = build_catalog() + os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) + with open(OUTPUT_PATH, "w") as fh: + json.dump(catalog, fh, indent=2) + fh.write("\n") + + print(f"Wrote {OUTPUT_PATH}") + for provider, block in catalog["providers"].items(): + print(f" {provider}: {len(block['models'])} models") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/hermes_cli/test_model_catalog.py b/tests/hermes_cli/test_model_catalog.py new file mode 100644 index 0000000000..2b757ac79b --- /dev/null +++ b/tests/hermes_cli/test_model_catalog.py @@ -0,0 +1,284 @@ +"""Tests for hermes_cli.model_catalog — remote manifest fetch + cache + fallback.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + """Isolate HERMES_HOME + reset any module-level catalog cache per test.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + + # Force a fresh catalog module state for each test. + import importlib + from hermes_cli import model_catalog + importlib.reload(model_catalog) + yield home + model_catalog.reset_cache() + + +def _valid_manifest() -> dict: + return { + "version": 1, + "updated_at": "2026-04-25T22:00:00Z", + "metadata": {"source": "test"}, + "providers": { + "openrouter": { + "metadata": {"display_name": "OpenRouter"}, + "models": [ + {"id": "anthropic/claude-opus-4.7", "description": "recommended"}, + {"id": "openai/gpt-5.4", "description": ""}, + {"id": "openrouter/elephant-alpha", "description": "free"}, + ], + }, + "nous": { + "metadata": {"display_name": "Nous Portal"}, + "models": [ + {"id": "anthropic/claude-opus-4.7"}, + {"id": "moonshotai/kimi-k2.6"}, + ], + }, + }, + } + + +class TestValidation: + def test_accepts_well_formed_manifest(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + assert _validate_manifest(_valid_manifest()) is True + + def test_rejects_non_dict(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + assert _validate_manifest("string") is False + assert _validate_manifest([]) is False + assert _validate_manifest(None) is False + + def test_rejects_missing_version(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + m = _valid_manifest() + del m["version"] + assert _validate_manifest(m) is False + + def test_rejects_future_version(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + m = _valid_manifest() + m["version"] = 999 + assert _validate_manifest(m) is False + + def test_rejects_missing_providers(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + m = _valid_manifest() + del m["providers"] + assert _validate_manifest(m) is False + + def test_rejects_malformed_model_entry(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + m = _valid_manifest() + m["providers"]["openrouter"]["models"][0] = {"id": ""} # empty id + assert _validate_manifest(m) is False + + def test_rejects_non_string_model_id(self, isolated_home): + from hermes_cli.model_catalog import _validate_manifest + m = _valid_manifest() + m["providers"]["openrouter"]["models"][0] = {"id": 42} + assert _validate_manifest(m) is False + + +class TestFetchSuccess: + def test_fetch_and_cache_writes_disk(self, isolated_home): + from hermes_cli import model_catalog + manifest = _valid_manifest() + with patch.object( + model_catalog, "_fetch_manifest", return_value=manifest + ) as fetch: + result = model_catalog.get_catalog(force_refresh=True) + + assert result == manifest + assert fetch.called + + cache_file = model_catalog._cache_path() + assert cache_file.exists() + with open(cache_file) as fh: + assert json.load(fh) == manifest + + def test_second_call_uses_in_process_cache(self, isolated_home): + from hermes_cli import model_catalog + manifest = _valid_manifest() + with patch.object( + model_catalog, "_fetch_manifest", return_value=manifest + ) as fetch: + model_catalog.get_catalog(force_refresh=True) + model_catalog.get_catalog() # should not hit network again + assert fetch.call_count == 1 + + def test_force_refresh_always_refetches(self, isolated_home): + from hermes_cli import model_catalog + manifest = _valid_manifest() + with patch.object( + model_catalog, "_fetch_manifest", return_value=manifest + ) as fetch: + model_catalog.get_catalog(force_refresh=True) + model_catalog.get_catalog(force_refresh=True) + assert fetch.call_count == 2 + + +class TestFetchFailure: + def test_network_failure_returns_empty_when_no_cache(self, isolated_home): + from hermes_cli import model_catalog + with patch.object(model_catalog, "_fetch_manifest", return_value=None): + result = model_catalog.get_catalog(force_refresh=True) + assert result == {} + + def test_network_failure_falls_back_to_disk_cache(self, isolated_home): + from hermes_cli import model_catalog + # Prime disk cache with a fresh copy. + manifest = _valid_manifest() + with patch.object(model_catalog, "_fetch_manifest", return_value=manifest): + model_catalog.get_catalog(force_refresh=True) + + # Now wipe in-process cache and simulate network failure on refetch. + model_catalog.reset_cache() + with patch.object(model_catalog, "_fetch_manifest", return_value=None): + result = model_catalog.get_catalog(force_refresh=True) + + assert result == manifest + + def test_fetch_failure_falls_back_to_stale_cache(self, isolated_home): + from hermes_cli import model_catalog + manifest = _valid_manifest() + # Write stale cache directly (mtime in the past). + cache = model_catalog._cache_path() + cache.parent.mkdir(parents=True, exist_ok=True) + with open(cache, "w") as fh: + json.dump(manifest, fh) + old = time.time() - 30 * 24 * 3600 # 30 days ago + import os as _os + _os.utime(cache, (old, old)) + + with patch.object(model_catalog, "_fetch_manifest", return_value=None): + result = model_catalog.get_catalog() + + # Stale cache is better than nothing. + assert result == manifest + + +class TestCuratedAccessors: + def test_openrouter_returns_tuples(self, isolated_home): + from hermes_cli import model_catalog + with patch.object( + model_catalog, "_fetch_manifest", return_value=_valid_manifest() + ): + result = model_catalog.get_curated_openrouter_models() + assert result == [ + ("anthropic/claude-opus-4.7", "recommended"), + ("openai/gpt-5.4", ""), + ("openrouter/elephant-alpha", "free"), + ] + + def test_nous_returns_ids(self, isolated_home): + from hermes_cli import model_catalog + with patch.object( + model_catalog, "_fetch_manifest", return_value=_valid_manifest() + ): + result = model_catalog.get_curated_nous_models() + assert result == ["anthropic/claude-opus-4.7", "moonshotai/kimi-k2.6"] + + def test_openrouter_returns_none_when_catalog_empty(self, isolated_home): + from hermes_cli import model_catalog + with patch.object(model_catalog, "_fetch_manifest", return_value=None): + assert model_catalog.get_curated_openrouter_models() is None + + def test_nous_returns_none_when_catalog_empty(self, isolated_home): + from hermes_cli import model_catalog + with patch.object(model_catalog, "_fetch_manifest", return_value=None): + assert model_catalog.get_curated_nous_models() is None + + +class TestDisabled: + def test_disabled_config_short_circuits(self, isolated_home): + from hermes_cli import model_catalog + with patch.object( + model_catalog, + "_load_catalog_config", + return_value={ + "enabled": False, + "url": "http://ignored", + "ttl_hours": 24.0, + "providers": {}, + }, + ): + with patch.object(model_catalog, "_fetch_manifest") as fetch: + result = model_catalog.get_catalog() + assert result == {} + fetch.assert_not_called() + + +class TestProviderOverride: + def test_override_url_takes_precedence(self, isolated_home): + from hermes_cli import model_catalog + + override_payload = { + "version": 1, + "providers": { + "openrouter": { + "models": [ + {"id": "override/model", "description": "custom"}, + ] + } + }, + } + + def fake_fetch(url, timeout): + if "override" in url: + return override_payload + return _valid_manifest() + + with patch.object( + model_catalog, + "_load_catalog_config", + return_value={ + "enabled": True, + "url": "http://master", + "ttl_hours": 24.0, + "providers": {"openrouter": {"url": "http://override"}}, + }, + ): + with patch.object(model_catalog, "_fetch_manifest", side_effect=fake_fetch): + result = model_catalog.get_curated_openrouter_models() + + assert result == [("override/model", "custom")] + + +class TestIntegrationWithModelsModule: + """Exercise the fallback paths via the real callers in hermes_cli.models.""" + + def test_curated_nous_ids_falls_back_to_hardcoded_on_empty_catalog( + self, isolated_home + ): + from hermes_cli import model_catalog + from hermes_cli.models import get_curated_nous_model_ids, _PROVIDER_MODELS + + with patch.object(model_catalog, "_fetch_manifest", return_value=None): + result = get_curated_nous_model_ids() + + assert result == list(_PROVIDER_MODELS["nous"]) + + def test_curated_nous_ids_prefers_manifest(self, isolated_home): + from hermes_cli import model_catalog + from hermes_cli.models import get_curated_nous_model_ids + + with patch.object( + model_catalog, "_fetch_manifest", return_value=_valid_manifest() + ): + result = get_curated_nous_model_ids() + + assert result == ["anthropic/claude-opus-4.7", "moonshotai/kimi-k2.6"] diff --git a/website/docs/reference/model-catalog.md b/website/docs/reference/model-catalog.md new file mode 100644 index 0000000000..3393ffeebf --- /dev/null +++ b/website/docs/reference/model-catalog.md @@ -0,0 +1,103 @@ +--- +sidebar_position: 11 +title: Model Catalog +description: Remotely-hosted manifest driving curated model picker lists for OpenRouter and Nous Portal. +--- + +# Model Catalog + +Hermes fetches curated model lists for **OpenRouter** and **Nous Portal** from a JSON manifest hosted alongside the docs site. This lets maintainers update picker lists without shipping a new `hermes-agent` release. + +When the manifest is unreachable (offline, network blocked, hosting failure), Hermes silently falls back to the in-repo snapshot that ships with the CLI. The manifest never breaks the picker — worst case you see whatever list was bundled with your installed version. + +## Live manifest URL + +``` +https://hermes-agent.nousresearch.com/docs/api/model-catalog.json +``` + +Published on every merge to `main` via the existing `deploy-site.yml` GitHub Pages pipeline. The source of truth lives in the repo at `website/static/api/model-catalog.json`. + +## Schema + +```json +{ + "version": 1, + "updated_at": "2026-04-25T22:00:00Z", + "metadata": {}, + "providers": { + "openrouter": { + "metadata": {}, + "models": [ + {"id": "moonshotai/kimi-k2.6", "description": "recommended", "metadata": {}}, + {"id": "openai/gpt-5.4", "description": ""} + ] + }, + "nous": { + "metadata": {}, + "models": [ + {"id": "anthropic/claude-opus-4.7"}, + {"id": "moonshotai/kimi-k2.6"} + ] + } + } +} +``` + +Field notes: + +- **`version`** — integer schema version. Future schemas bump this; Hermes refuses manifests with versions it doesn't understand and falls back to the hardcoded snapshot. +- **`metadata`** — free-form dict at the manifest, provider, and model level. Any keys. Hermes ignores unknown fields, so you can annotate entries (`"tier": "paid"`, `"tags": [...]`, etc.) without coordinating a schema change. +- **`description`** — OpenRouter-only. Drives picker badge text (`"recommended"`, `"free"`, or empty). Nous Portal doesn't use this — free-tier gating is determined live from the Portal's pricing endpoint. +- **Pricing and context length** are NOT in the manifest. Those come from live provider APIs (`/v1/models` endpoints, models.dev) at fetch time. + +## Fetch behavior + +| When | What happens | +|---|---| +| `/model` or `hermes model` | Fetches if disk cache is stale, else uses cache | +| 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 | +| Manifest fails schema validation | Treated as unreachable | + +Cache location: `~/.hermes/cache/model_catalog.json`. + +## Config + +```yaml +model_catalog: + enabled: true + url: https://hermes-agent.nousresearch.com/docs/api/model-catalog.json + ttl_hours: 24 + providers: {} +``` + +Set `enabled: false` to disable remote fetch entirely and always use the in-repo snapshot. + +### Per-provider override URLs + +Third parties can self-host their own curation list using the same schema. Point a provider at a custom URL: + +```yaml +model_catalog: + providers: + openrouter: + url: https://example.com/my-openrouter-curation.json +``` + +The overriding manifest only needs to populate the provider block(s) it cares about. Other providers continue to resolve against the master URL. + +## Updating the manifest + +Maintainers: + +```bash +# Re-generate from the in-repo hardcoded lists (keeps manifest in sync after +# editing OPENROUTER_MODELS or _PROVIDER_MODELS["nous"] in hermes_cli/models.py). +python scripts/build_model_catalog.py +``` + +Then PR the resulting change to `website/static/api/model-catalog.json` to `main`. The docs site auto-deploys on merge and the new manifest is live within a few minutes. + +You can also hand-edit the JSON directly for fine-grained metadata changes that don't belong in the in-repo snapshot — the generator script is a convenience, not the single source of truth. diff --git a/website/sidebars.ts b/website/sidebars.ts index b3663e9da5..b654291810 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -613,6 +613,7 @@ const sidebars: SidebarsConfig = { 'reference/tools-reference', 'reference/toolsets-reference', 'reference/mcp-config-reference', + 'reference/model-catalog', 'reference/skills-catalog', 'reference/optional-skills-catalog', 'reference/faq', diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json new file mode 100644 index 0000000000..a2ef50a1e1 --- /dev/null +++ b/website/static/api/model-catalog.json @@ -0,0 +1,259 @@ +{ + "version": 1, + "updated_at": "2026-04-26T12:34:42Z", + "metadata": { + "source": "hermes-agent repo", + "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" + }, + "providers": { + "openrouter": { + "metadata": { + "display_name": "OpenRouter", + "note": "Descriptions drive picker badges. Live /api/v1/models filters curated ids by tool-calling support and free pricing." + }, + "models": [ + { + "id": "moonshotai/kimi-k2.6", + "description": "recommended" + }, + { + "id": "deepseek/deepseek-v4-pro", + "description": "" + }, + { + "id": "deepseek/deepseek-v4-flash", + "description": "" + }, + { + "id": "anthropic/claude-opus-4.7", + "description": "" + }, + { + "id": "anthropic/claude-opus-4.6", + "description": "" + }, + { + "id": "anthropic/claude-sonnet-4.6", + "description": "" + }, + { + "id": "qwen/qwen3.6-plus", + "description": "" + }, + { + "id": "anthropic/claude-sonnet-4.5", + "description": "" + }, + { + "id": "anthropic/claude-haiku-4.5", + "description": "" + }, + { + "id": "openrouter/elephant-alpha", + "description": "free" + }, + { + "id": "openai/gpt-5.5", + "description": "" + }, + { + "id": "openai/gpt-5.4-mini", + "description": "" + }, + { + "id": "xiaomi/mimo-v2.5-pro", + "description": "" + }, + { + "id": "xiaomi/mimo-v2.5", + "description": "" + }, + { + "id": "openai/gpt-5.3-codex", + "description": "" + }, + { + "id": "google/gemini-3-pro-image-preview", + "description": "" + }, + { + "id": "google/gemini-3-flash-preview", + "description": "" + }, + { + "id": "google/gemini-3.1-pro-preview", + "description": "" + }, + { + "id": "google/gemini-3.1-flash-lite-preview", + "description": "" + }, + { + "id": "qwen/qwen3.5-plus-02-15", + "description": "" + }, + { + "id": "qwen/qwen3.5-35b-a3b", + "description": "" + }, + { + "id": "stepfun/step-3.5-flash", + "description": "" + }, + { + "id": "minimax/minimax-m2.7", + "description": "" + }, + { + "id": "minimax/minimax-m2.5", + "description": "" + }, + { + "id": "minimax/minimax-m2.5:free", + "description": "free" + }, + { + "id": "z-ai/glm-5.1", + "description": "" + }, + { + "id": "z-ai/glm-5v-turbo", + "description": "" + }, + { + "id": "z-ai/glm-5-turbo", + "description": "" + }, + { + "id": "x-ai/grok-4.20", + "description": "" + }, + { + "id": "nvidia/nemotron-3-super-120b-a12b", + "description": "" + }, + { + "id": "nvidia/nemotron-3-super-120b-a12b:free", + "description": "free" + }, + { + "id": "arcee-ai/trinity-large-preview:free", + "description": "free" + }, + { + "id": "arcee-ai/trinity-large-thinking", + "description": "" + }, + { + "id": "openai/gpt-5.5-pro", + "description": "" + }, + { + "id": "openai/gpt-5.4-nano", + "description": "" + } + ] + }, + "nous": { + "metadata": { + "display_name": "Nous Portal", + "note": "Free-tier gating is determined live via Portal pricing (partition_nous_models_by_tier), not this manifest." + }, + "models": [ + { + "id": "moonshotai/kimi-k2.6" + }, + { + "id": "deepseek/deepseek-v4-pro" + }, + { + "id": "deepseek/deepseek-v4-flash" + }, + { + "id": "xiaomi/mimo-v2.5-pro" + }, + { + "id": "xiaomi/mimo-v2.5" + }, + { + "id": "anthropic/claude-opus-4.7" + }, + { + "id": "anthropic/claude-opus-4.6" + }, + { + "id": "anthropic/claude-sonnet-4.6" + }, + { + "id": "anthropic/claude-sonnet-4.5" + }, + { + "id": "anthropic/claude-haiku-4.5" + }, + { + "id": "openai/gpt-5.5" + }, + { + "id": "openai/gpt-5.4-mini" + }, + { + "id": "openai/gpt-5.3-codex" + }, + { + "id": "google/gemini-3-pro-preview" + }, + { + "id": "google/gemini-3-flash-preview" + }, + { + "id": "google/gemini-3.1-pro-preview" + }, + { + "id": "google/gemini-3.1-flash-lite-preview" + }, + { + "id": "qwen/qwen3.5-plus-02-15" + }, + { + "id": "qwen/qwen3.5-35b-a3b" + }, + { + "id": "stepfun/step-3.5-flash" + }, + { + "id": "minimax/minimax-m2.7" + }, + { + "id": "minimax/minimax-m2.5" + }, + { + "id": "minimax/minimax-m2.5:free" + }, + { + "id": "z-ai/glm-5.1" + }, + { + "id": "z-ai/glm-5v-turbo" + }, + { + "id": "z-ai/glm-5-turbo" + }, + { + "id": "x-ai/grok-4.20-beta" + }, + { + "id": "nvidia/nemotron-3-super-120b-a12b" + }, + { + "id": "arcee-ai/trinity-large-thinking" + }, + { + "id": "openai/gpt-5.5-pro" + }, + { + "id": "openai/gpt-5.4-nano" + } + ] + } + } +}