Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1724,6 +1724,8 @@ def cmd_setup(args):
def cmd_model(args):
"""Select default model β€” starts with provider selection, then model picker."""
_require_tty("model")
if bool(getattr(args, "refresh", False)):
_clear_model_catalog_cache()
select_provider_and_model(args=args)


Expand Down Expand Up @@ -1763,6 +1765,7 @@ def select_provider_and_model(args=None):
from hermes_cli.providers import resolve_provider_full

config = load_config()
force_model_catalog_refresh = bool(getattr(args, "refresh", False))
current_model = config.get("model")
if isinstance(current_model, dict):
current_model = current_model.get("default", "")
Expand Down Expand Up @@ -1973,7 +1976,12 @@ def _lookup_ref(name: str, provider_key: str, model: str) -> str:
elif selected_provider == "ai-gateway":
_model_flow_ai_gateway(config, current_model)
elif selected_provider == "nous":
_model_flow_nous(config, current_model, args=args)
_model_flow_nous(
config,
current_model,
args=args,
force_catalog_refresh=force_model_catalog_refresh,
)
elif selected_provider == "openai-codex":
_model_flow_openai_codex(config, current_model)
elif selected_provider == "qwen-oauth":
Expand Down Expand Up @@ -2584,7 +2592,13 @@ def _model_flow_ai_gateway(config, current_model=""):
print("No change.")


def _model_flow_nous(config, current_model="", args=None):
def _model_flow_nous(
config,
current_model="",
args=None,
*,
force_catalog_refresh: bool = False,
):
"""Nous Portal provider: ensure logged in, then pick model."""
from hermes_cli.auth import (
get_provider_auth_state,
Expand Down Expand Up @@ -2646,7 +2660,7 @@ def _model_flow_nous(config, current_model="", args=None):
partition_nous_models_by_tier,
)

model_ids = get_curated_nous_model_ids()
model_ids = get_curated_nous_model_ids(force_refresh=force_catalog_refresh)
if not model_ids:
print("No curated models available for Nous Portal.")
return
Expand Down Expand Up @@ -6109,6 +6123,8 @@ def _update_via_zip(args):
except Exception:
pass

_invalidate_update_cache()

print()
print("βœ“ Update complete!")
try:
Expand Down Expand Up @@ -6547,13 +6563,26 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None:
print(" Your local repo is updated, but your fork on GitHub may be behind.")


def _clear_model_catalog_cache() -> None:
"""Clear hosted model-catalog caches using the resolved Hermes home."""
try:
from hermes_cli.model_catalog import clear_disk_cache

clear_disk_cache()
except Exception as exc:
logger.debug("Model catalog cache invalidation failed: %s", exc)


def _invalidate_update_cache():
"""Delete the update-check cache for ALL profiles so no banner
reports a stale "commits behind" count after a successful update.
reports a stale "commits behind" count after a successful update, and
drop the hosted model catalog cache so ``hermes model`` sees the newly
bundled/remote catalog after ``hermes update``.

The git repo is shared across profiles β€” when one profile runs
``hermes update``, every profile is now current.
"""
_clear_model_catalog_cache()
homes = []
# Default profile home (Docker-aware β€” uses /opt/data in Docker)
from hermes_constants import get_default_hermes_root
Expand All @@ -6573,6 +6602,12 @@ def _invalidate_update_cache():
cache_file.unlink()
except Exception:
pass
try:
model_catalog_cache = home / "cache" / "model_catalog.json"
if model_catalog_cache.exists():
model_catalog_cache.unlink()
except Exception:
pass


def _load_installable_optional_extras(group: str = "all") -> list[str]:
Expand Down Expand Up @@ -9369,6 +9404,11 @@ def main():
action="store_true",
help="Disable TLS verification for Nous login (testing only)",
)
model_parser.add_argument(
"--refresh",
action="store_true",
help="Clear cached remote model catalogs and fetch fresh picker data",
)
model_parser.set_defaults(func=cmd_model)

# =========================================================================
Expand Down
46 changes: 37 additions & 9 deletions hermes_cli/model_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
- 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).
- On ordinary fetch failure, keeps using the stale cache (or empty dict).
- On explicit force-refresh failure, returns empty so callers fall back to
the bundled snapshot instead of reusing the stale disk cache.

2. ``get_curated_openrouter_models()`` / ``get_curated_nous_models()`` β€”
thin accessors returning the shapes existing callers expect. Each
Expand Down Expand Up @@ -234,7 +236,9 @@ def get_catalog(*, force_refresh: bool = False) -> dict[str, Any]:
_catalog_cache_source_mtime = disk_mtime
return disk_data

# Need to (re)fetch. If it fails, fall back to any stale disk copy.
# Need to (re)fetch. Ordinary offline use can keep a stale disk copy; an
# explicit force-refresh must not resurrect the same stale catalog the user
# is trying to bypass.
fetched = _fetch_manifest(cfg["url"], DEFAULT_FETCH_TIMEOUT)
if fetched is not None:
_write_disk_cache(fetched)
Expand All @@ -247,7 +251,7 @@ def get_catalog(*, force_refresh: bool = False) -> dict[str, Any]:
_catalog_cache_source_mtime = now
return fetched

if disk_data is not None:
if not force_refresh and disk_data is not None:
_catalog_cache = disk_data
_catalog_cache_source_mtime = disk_mtime
return disk_data
Expand All @@ -272,28 +276,35 @@ def _fetch_provider_override(provider: str) -> dict[str, Any] | None:
return _fetch_manifest(override_url.strip(), DEFAULT_FETCH_TIMEOUT)


def _get_provider_block(provider: str) -> dict[str, Any] | None:
def _get_provider_block(
provider: str,
*,
force_refresh: bool = False,
) -> 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()
catalog = get_catalog(force_refresh=force_refresh)
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:
def get_curated_openrouter_models(
*,
force_refresh: bool = False,
) -> 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")
block = _get_provider_block("openrouter", force_refresh=force_refresh)
if not block:
return None
out: list[tuple[str, str]] = []
Expand All @@ -306,12 +317,15 @@ def get_curated_openrouter_models() -> list[tuple[str, str]] | None:
return out or None


def get_curated_nous_models() -> list[str] | None:
def get_curated_nous_models(
*,
force_refresh: bool = False,
) -> 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")
block = _get_provider_block("nous", force_refresh=force_refresh)
if not block:
return None
out: list[str] = []
Expand All @@ -327,3 +341,17 @@ def reset_cache() -> None:
global _catalog_cache, _catalog_cache_source_mtime
_catalog_cache = None
_catalog_cache_source_mtime = 0.0


def clear_disk_cache() -> None:
"""Clear the in-process and disk model-catalog cache.

Uses ``get_hermes_home()`` via ``_cache_path()`` so Windows AppData,
custom ``HERMES_HOME``, Docker, and profile homes all resolve through the
same runtime path as normal catalog reads.
"""
reset_cache()
try:
_cache_path().unlink(missing_ok=True)
except OSError as exc:
logger.info("model catalog cache delete failed: %s", exc)
9 changes: 5 additions & 4 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -975,7 +975,7 @@ def fetch_openrouter_models(
# free pricing) is applied on top either way.
try:
from hermes_cli.model_catalog import get_curated_openrouter_models
remote = get_curated_openrouter_models()
remote = get_curated_openrouter_models(force_refresh=force_refresh)
except Exception:
remote = None
fallback = list(remote) if remote else list(OPENROUTER_MODELS)
Expand Down Expand Up @@ -1031,17 +1031,18 @@ 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]:
def get_curated_nous_model_ids(*, force_refresh: bool = False) -> 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).
unreachable. Pass ``force_refresh=True`` to bypass the catalog TTL. Always
returns a list (never None).
"""
try:
from hermes_cli.model_catalog import get_curated_nous_models
remote = get_curated_nous_models()
remote = get_curated_nous_models(force_refresh=force_refresh)
except Exception:
remote = None
if remote:
Expand Down
52 changes: 51 additions & 1 deletion tests/hermes_cli/test_model_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,36 @@ def test_network_failure_falls_back_to_disk_cache(self, isolated_home):
# 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)
result = model_catalog.get_catalog()

assert result == manifest

def test_force_refresh_failure_ignores_stale_disk_cache(self, isolated_home):
from hermes_cli import model_catalog

manifest = _valid_manifest()
cache = model_catalog._cache_path()
cache.parent.mkdir(parents=True, exist_ok=True)
with open(cache, "w") as fh:
json.dump(manifest, fh)

model_catalog.reset_cache()
with patch.object(model_catalog, "_fetch_manifest", return_value=None):
result = model_catalog.get_catalog(force_refresh=True)

assert result == {}

def test_clear_disk_cache_uses_resolved_hermes_home(self, isolated_home):
from hermes_cli import model_catalog

cache = model_catalog._cache_path()
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(_valid_manifest()))

assert cache.exists()
model_catalog.clear_disk_cache()
assert not cache.exists()

def test_fetch_failure_falls_back_to_stale_cache(self, isolated_home):
from hermes_cli import model_catalog
manifest = _valid_manifest()
Expand Down Expand Up @@ -185,6 +211,18 @@ def test_openrouter_returns_tuples(self, isolated_home):
("openrouter/elephant-alpha", "free"),
]

def test_openrouter_force_refresh_reaches_catalog_fetch(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_curated_openrouter_models(force_refresh=True)
model_catalog.get_curated_openrouter_models(force_refresh=True)

assert fetch.call_count == 2

def test_nous_returns_ids(self, isolated_home):
from hermes_cli import model_catalog
with patch.object(
Expand All @@ -193,6 +231,18 @@ def test_nous_returns_ids(self, isolated_home):
result = model_catalog.get_curated_nous_models()
assert result == ["anthropic/claude-opus-4.7", "moonshotai/kimi-k2.6"]

def test_nous_force_refresh_reaches_catalog_fetch(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_curated_nous_models(force_refresh=True)
model_catalog.get_curated_nous_models(force_refresh=True)

assert fetch.call_count == 2

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):
Expand Down
27 changes: 27 additions & 0 deletions tests/hermes_cli/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ def test_at_least_5_models(self):


class TestFetchOpenRouterModels:
def test_force_refresh_reaches_remote_manifest_catalog(self, monkeypatch):
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
with patch(
"hermes_cli.model_catalog.get_curated_openrouter_models",
return_value=[("openrouter/test-model", "")],
) as curated, patch(
"hermes_cli.models.urllib.request.urlopen",
side_effect=OSError("offline"),
):
fetch_openrouter_models(force_refresh=True)

curated.assert_called_once_with(force_refresh=True)

def test_live_fetch_recomputes_free_tags(self, monkeypatch):
class _Resp:
def __enter__(self):
Expand Down Expand Up @@ -169,6 +182,20 @@ def read(self):
assert "qwen/qwen3.6-plus" in ids


class TestNousCuratedModelIds:
def test_force_refresh_reaches_remote_manifest_catalog(self):
from hermes_cli.models import get_curated_nous_model_ids

with patch(
"hermes_cli.model_catalog.get_curated_nous_models",
return_value=["nous/test-model"],
) as curated:
result = get_curated_nous_model_ids(force_refresh=True)

assert result == ["nous/test-model"]
curated.assert_called_once_with(force_refresh=True)


class TestOpenRouterToolSupportHelper:
"""Unit tests for _openrouter_model_supports_tools (Kilo port #9068)."""

Expand Down
Loading
Loading