feat(models): remote model catalog manifest for OpenRouter + Nous Portal (#16033)

OpenRouter and Nous Portal curated picker lists now resolve via a JSON
manifest served by the docs site, falling back to the in-repo snapshot
when unreachable. Lets us update model lists without shipping a release.

Live URL: https://hermes-agent.nousresearch.com/docs/api/model-catalog.json
(source at website/static/api/model-catalog.json; auto-deploys via the
existing deploy-site.yml GitHub Pages pipeline on every merge to main).

Schema (v1) carries id + optional description + free-form metadata at
manifest, provider, and model levels. Pricing and context length stay
live-fetched via existing machinery (/v1/models endpoints, models.dev).

Config (new model_catalog section, default enabled):
  model_catalog.url       master manifest URL
  model_catalog.ttl_hours disk cache TTL (default 24h)
  model_catalog.providers.<name>.url   optional per-provider override

Fetch pipeline: in-process cache -> disk cache (fresh < TTL) -> HTTP
fetch -> disk-cache-on-failure fallback -> in-repo snapshot as last
resort. Never raises to callers; at worst returns the bundled list.

Changes:
- website/static/api/model-catalog.json    initial manifest (35 OR + 31 Nous)
- scripts/build_model_catalog.py           regenerator from in-repo lists
- hermes_cli/model_catalog.py              fetch + validate + cache module
- hermes_cli/models.py                     fetch_openrouter_models() +
                                           new get_curated_nous_model_ids()
- hermes_cli/main.py, hermes_cli/auth.py   Nous flows use the helper
- hermes_cli/config.py                     model_catalog defaults
- website/docs/reference/model-catalog.md  + sidebars.ts
- tests/hermes_cli/test_model_catalog.py   21 tests (validation, fetch
                                           success/failure, accessors,
                                           disabled, overrides, integration)
This commit is contained in:
Teknium
2026-04-26 05:46:43 -07:00
committed by GitHub
parent d09ab8ff13
commit 855366909f
10 changed files with 1124 additions and 5 deletions

View File

@@ -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 = []

View File

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

View File

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

329
hermes_cli/model_catalog.py Normal file
View File

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

View File

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

95
scripts/build_model_catalog.py Executable file
View File

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

View File

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

View File

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

View File

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

View File

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