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:
@@ -4244,10 +4244,10 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
from hermes_cli.models import (
|
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,
|
check_nous_free_tier, partition_nous_models_by_tier,
|
||||||
)
|
)
|
||||||
model_ids = _PROVIDER_MODELS.get("nous", [])
|
model_ids = get_curated_nous_model_ids()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
unavailable_models: list = []
|
unavailable_models: list = []
|
||||||
|
|||||||
@@ -959,6 +959,27 @@ DEFAULT_CONFIG = {
|
|||||||
"backup_count": 3, # Number of rotated backup files to keep
|
"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 settings — workarounds for connectivity issues.
|
||||||
"network": {
|
"network": {
|
||||||
# Force IPv4 connections. On servers with broken or unreachable IPv6,
|
# Force IPv4 connections. On servers with broken or unreachable IPv6,
|
||||||
|
|||||||
@@ -2315,13 +2315,13 @@ def _model_flow_nous(config, current_model="", args=None):
|
|||||||
# The live /models endpoint returns hundreds of models; the curated list
|
# The live /models endpoint returns hundreds of models; the curated list
|
||||||
# shows only agentic models users recognize from OpenRouter.
|
# shows only agentic models users recognize from OpenRouter.
|
||||||
from hermes_cli.models import (
|
from hermes_cli.models import (
|
||||||
_PROVIDER_MODELS,
|
get_curated_nous_model_ids,
|
||||||
get_pricing_for_provider,
|
get_pricing_for_provider,
|
||||||
check_nous_free_tier,
|
check_nous_free_tier,
|
||||||
partition_nous_models_by_tier,
|
partition_nous_models_by_tier,
|
||||||
)
|
)
|
||||||
|
|
||||||
model_ids = _PROVIDER_MODELS.get("nous", [])
|
model_ids = get_curated_nous_model_ids()
|
||||||
if not model_ids:
|
if not model_ids:
|
||||||
print("No curated models available for Nous Portal.")
|
print("No curated models available for Nous Portal.")
|
||||||
return
|
return
|
||||||
|
|||||||
329
hermes_cli/model_catalog.py
Normal file
329
hermes_cli/model_catalog.py
Normal 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
|
||||||
@@ -876,7 +876,16 @@ def fetch_openrouter_models(
|
|||||||
if _openrouter_catalog_cache is not None and not force_refresh:
|
if _openrouter_catalog_cache is not None and not force_refresh:
|
||||||
return list(_openrouter_catalog_cache)
|
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]
|
preferred_ids = [mid for mid, _ in fallback]
|
||||||
|
|
||||||
try:
|
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)]
|
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:
|
def _ai_gateway_model_is_free(pricing: Any) -> bool:
|
||||||
"""Return True if an AI Gateway model has $0 input AND output pricing."""
|
"""Return True if an AI Gateway model has $0 input AND output pricing."""
|
||||||
if not isinstance(pricing, dict):
|
if not isinstance(pricing, dict):
|
||||||
|
|||||||
95
scripts/build_model_catalog.py
Executable file
95
scripts/build_model_catalog.py
Executable 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())
|
||||||
284
tests/hermes_cli/test_model_catalog.py
Normal file
284
tests/hermes_cli/test_model_catalog.py
Normal 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"]
|
||||||
103
website/docs/reference/model-catalog.md
Normal file
103
website/docs/reference/model-catalog.md
Normal 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.
|
||||||
@@ -613,6 +613,7 @@ const sidebars: SidebarsConfig = {
|
|||||||
'reference/tools-reference',
|
'reference/tools-reference',
|
||||||
'reference/toolsets-reference',
|
'reference/toolsets-reference',
|
||||||
'reference/mcp-config-reference',
|
'reference/mcp-config-reference',
|
||||||
|
'reference/model-catalog',
|
||||||
'reference/skills-catalog',
|
'reference/skills-catalog',
|
||||||
'reference/optional-skills-catalog',
|
'reference/optional-skills-catalog',
|
||||||
'reference/faq',
|
'reference/faq',
|
||||||
|
|||||||
259
website/static/api/model-catalog.json
Normal file
259
website/static/api/model-catalog.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user