fix(/model): surface Nous Portal models from remote catalog manifest (#23912)

The /model picker for Nous Portal users was returning the in-repo
_PROVIDER_MODELS["nous"] snapshot — which only updates on Hermes
releases — instead of the remote manifest published at
https://hermes-agent.nousresearch.com/docs/api/model-catalog.json.

OpenRouter already pulled from the manifest via fetch_openrouter_models;
"nous" was the only curated provider where the existing manifest
plumbing (get_curated_nous_model_ids → get_curated_nous_models) was
defined but not wired into the picker pipeline. Switch the curated
build in list_authenticated_providers to use it, with the same
graceful fallback to the in-repo snapshot when the manifest is
unreachable.

Test: tests/hermes_cli/test_model_catalog.py exercises the picker with
a patched manifest and asserts the manifest's nous list reaches
list_picker_providers. Falls-back-to-static path was already covered
by test_curated_nous_ids_falls_back_to_hardcoded_on_empty_catalog.
This commit is contained in:
Teknium 2026-05-11 10:15:30 -07:00 committed by GitHub
parent cc9e788c14
commit 8e2eb4b511
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 53 additions and 3 deletions

View file

@ -1079,6 +1079,7 @@ def list_authenticated_providers(
from hermes_cli.models import ( from hermes_cli.models import (
OPENROUTER_MODELS, _PROVIDER_MODELS, OPENROUTER_MODELS, _PROVIDER_MODELS,
_MODELS_DEV_PREFERRED, _merge_with_models_dev, provider_model_ids, _MODELS_DEV_PREFERRED, _merge_with_models_dev, provider_model_ids,
get_curated_nous_model_ids,
) )
results: List[dict] = [] results: List[dict] = []
@ -1160,9 +1161,12 @@ def list_authenticated_providers(
# Build curated model lists keyed by hermes provider ID # Build curated model lists keyed by hermes provider ID
curated: dict[str, list[str]] = dict(_PROVIDER_MODELS) curated: dict[str, list[str]] = dict(_PROVIDER_MODELS)
curated["openrouter"] = [mid for mid, _ in OPENROUTER_MODELS] curated["openrouter"] = [mid for mid, _ in OPENROUTER_MODELS]
# "nous" shares OpenRouter's curated list if not separately defined # "nous" pulls from the remote model-catalog manifest published at
if "nous" not in curated: # https://hermes-agent.nousresearch.com/docs/api/model-catalog.json so
curated["nous"] = curated["openrouter"] # newly added Portal models surface in the /model picker without
# requiring a Hermes release. Falls back to the in-repo
# _PROVIDER_MODELS["nous"] snapshot when the manifest is unreachable.
curated["nous"] = get_curated_nous_model_ids()
# Ollama Cloud uses dynamic discovery (no static curated list) # Ollama Cloud uses dynamic discovery (no static curated list)
if "ollama-cloud" not in curated: if "ollama-cloud" not in curated:
from hermes_cli.models import fetch_ollama_cloud_models from hermes_cli.models import fetch_ollama_cloud_models

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import time import time
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@ -282,3 +283,48 @@ class TestIntegrationWithModelsModule:
result = get_curated_nous_model_ids() result = get_curated_nous_model_ids()
assert result == ["anthropic/claude-opus-4.7", "moonshotai/kimi-k2.6"] assert result == ["anthropic/claude-opus-4.7", "moonshotai/kimi-k2.6"]
def test_picker_nous_row_uses_manifest(self, tmp_path, monkeypatch):
"""The /model picker must surface the manifest's nous list, not the
in-repo _PROVIDER_MODELS["nous"] snapshot. Regression: before this
fix, list_authenticated_providers() built the curated dict from
_PROVIDER_MODELS only so newly-added Portal models never reached
the slash-command picker until the next Hermes release.
"""
# We deliberately do NOT use the ``isolated_home`` fixture here:
# that fixture monkeypatches ``Path.home`` to ``tmp_path``, which
# trips the auth-store seat-belt in ``_auth_file_path()`` because
# ``HERMES_HOME / auth.json`` then resolves to the same path the
# seat-belt thinks is the "real" user store. Use the autouse
# ``_hermetic_environment`` HERMES_HOME directly instead.
import importlib
from hermes_cli import model_catalog
importlib.reload(model_catalog)
try:
from hermes_cli.model_switch import list_picker_providers
active_home = Path(os.environ["HERMES_HOME"])
(active_home / "auth.json").write_text(
json.dumps(
{
"providers": {"nous": {"access_token": "fake"}},
"credential_pool": {},
}
)
)
with patch.object(
model_catalog, "_fetch_manifest", return_value=_valid_manifest()
):
picker = list_picker_providers(
current_provider="nous", max_models=99
)
finally:
model_catalog.reset_cache()
nous_row = next((r for r in picker if r["slug"] == "nous"), None)
assert nous_row is not None, "nous row must appear when authed"
assert nous_row["models"] == [
"anthropic/claude-opus-4.7",
"moonshotai/kimi-k2.6",
]