mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 01:21:43 +00:00
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:
parent
d09ab8ff13
commit
855366909f
10 changed files with 1124 additions and 5 deletions
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"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue