Model picker using stale static lists instead of live catalogs
NousResearch/hermes-agent · cluster 22990 · apps/desktop
Data as of
4 open items. Fix merged in #33867. Check it covers your case.
The short answer
- Known?
- Yes. 13 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #33867. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 1 issue and 3 PRs.
Next step: read #33867, then check whether your report is one of the 1 still open.
Read #33867Pick up a Lab taskThese issues and fixes address the model picker relying on hardcoded or outdated model lists rather than fetching live catalogs, affecting Copilot, NVIDIA NIM, and other API-key providers. Several members also address specific missing or incorrectly identified models in those static lists.
Open issues1
- Issue#47977[Bug] NVIDIA NIM curated model list is stale — wrong IDs and missing models
Open PRs3
Fixes on record
-
#33867 fix(model picker): unify /model and `hermes model` lists, add disk cache
files:
cli.py,gateway/run.py,hermes_cli/commands.py,hermes_cli/main.py,hermes_cli/model_switch.py,hermes_cli/models.py,tests/hermes_cli/test_inventory.py,tui_gateway/server.pyShow the diff
-
cli.py@@ -7586,8 +7586,19 @@ def _handle_model_switch(self, cmd_original: str): parts = cmd_original.split(None, 1) # split off '/model' raw_args = parts[1].strip() if len(parts) > 1 else "" - # Parse --provider and --global flags - model_input, explicit_provider, persist_global = parse_model_flags(raw_args) + # Parse --provider, --global, and --refresh flags + model_input, explicit_provider, persist_global, force_refresh = parse_model_flags(raw_args) + + # --refresh: wipe the on-disk picker cache before building the + # provider list. Forces a live re-fetch of every authed provider's + # /v1/models endpoint on this open. + if force_refresh: + try: + from hermes_cli.models import clear_provider_models_cache + clear_provider_models_cache() + _cprint(" Cleared model picker cache. Refreshing...") + except Exception: + pass # Single inventory context — replaces the inline config-slice the # dashboard / TUI used to duplicate. Overlay live session state @@ -7626,6 +7637,7 @@ def _handle_model_switch(self, cmd_original: str): _cprint("") _cprint(" /model <name> switch model") _cprint(" /model --provider <slug> switch provider") + _cprint(" /model --refresh re-fetch live model lists") return self._open_model_picker( -
gateway/run.py@@ -10241,8 +10241,16 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: raw_args = event.get_command_args().strip() - # Parse --provider and --global flags - model_input, explicit_provider, persist_global = parse_model_flags(raw_args) + # Parse --provider, --global, and --refresh flags + model_input, explicit_provider, persist_global, force_refresh = parse_model_flags(raw_args) + + # --refresh: bust the disk cache so the picker shows live data. + if force_refresh: + try: + from hermes_cli.models import clear_provider_models_cache + clear_provider_models_cache() + except Exception: + pass # Read current model/provider from config current_model = "" -
hermes_cli/commands.py@@ -123,7 +123,7 @@ class CommandDef: CommandDef("config", "Show current configuration", "Configuration", cli_only=True), CommandDef("model", "Switch model for this session", "Configuration", - aliases=("provider",), args_hint="[model] [--provider name] [--global]"), + aliases=("provider",), args_hint="[model] [--provider name] [--global] [--refresh]"), CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models", "Configuration", aliases=("codex_runtime",), args_hint="[auto|codex_app_server]"), -
hermes_cli/main.py@@ -2117,6 +2117,13 @@ def cmd_postinstall(args): def cmd_model(args): """Select default model — starts with provider selection, then model picker.""" _require_tty("model") + if getattr(args, "refresh", False): + try: + from hermes_cli.models import clear_provider_models_cache + clear_provider_models_cache() + print(" Cleared model picker cache.") + except Exception: + pass select_provider_and_model(args=args) @@ -11311,6 +11318,11 @@ def main(): help="Select default model and provider", description="Interactively select your inference provider and default model", ) + model_parser.add_argument( + "--refresh", + action="store_true", + help="Wipe the model picker disk cache and re-fetch every provider's live /v1/models list.", + ) model_parser.add_argument( "--portal-url", help="Portal base URL for Nous login (default: production portal)", -
hermes_cli/model_switch.py@@ -294,32 +294,39 @@ class CustomAutoResult: # Flag parsing # --------------------------------------------------------------------------- -def parse_model_flags(raw_args: str) -> tuple[str, str, bool]: - """Parse --provider and --global flags from /model command args. +def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool]: + """Parse --provider, --global, and --refresh flags from /model command args. - Returns (model_input, explicit_provider, is_global). + Returns (model_input, explicit_provider, is_global, force_refresh). Examples:: - "sonnet" -> ("sonnet", "", False) - "sonnet --global" -> ("sonnet", "", True) - "sonnet --provider anthropic" -> ("sonnet", "anthropic", False) - "--provider my-ollama" -> ("", "my-ollama", False) - "sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True) + "sonnet" -> ("sonnet", "", False, False) + "sonnet --global" -> ("sonnet", "", True, False) + "sonnet --provider anthropic" -> ("sonnet", "anthropic", False, False) + "--provider my-ollama" -> ("", "my-ollama", False, False) + "--refresh" -> ("", "", False, True) + "sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True, False) """ is_global = False explicit_provider = "" + force_refresh = False # Normalize Unicode dashes (Telegram/iOS auto-converts -- to em/en dash) # A single Unicode dash before a flag keyword becomes "--" import re as _re - raw_args = _re.sub(r'[\u2012\u2013\u2014\u2015](provider|global)', r'--\1', raw_args) + raw_args = _re.sub(r'[\u2012\u2013\u2014\u2015](provider|global|refresh)', r'--\1', raw_args) # Extract --global if "--global" in raw_args: is_global = True raw_args = raw_args.replace("--global", "").strip() + # Extract --refresh (bust the model picker disk cache before listing) + if "--refresh" in raw_args: + force_refresh = True + raw_args = raw_args.replace("--refresh", "").strip() + # Extract --provider <name> parts = raw_args.split() i = 0 @@ -333,7 +340,7 @@ def parse_model_flags(raw_args: str) -> tuple[str, str, bool]: i += 1 model_input = " ".join(filtered).strip() - return (model_input, explicit_provider, is_global) + return (model_input, explicit_provider, is_global, force_refresh) # --------------------------------------------------------------------------- @@ -1079,6 +1086,7 @@ def list_authenticated_providers( from hermes_cli.models import ( OPENROUTER_MODELS, _PROVIDER_MODELS, _MODELS_DEV_PREFERRED, _merge_with_models_dev, provider_model_ids, + cached_provider_model_ids, get_curated_nous_model_ids, ) @@ -1239,13 +1247,15 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: if not has_creds: continue - # Use curated list, falling back to models.dev if no curated list. - # For preferred providers, merge models.dev entries into the curated - # catalog so newly released models (e.g. mimo-v2.5-pro on opencode-go) - # show up in the picker without requiring a Hermes release. - model_ids = curated.get(hermes_id, []) - if hermes_id in _MODELS_DEV_PREFERRED: - model_ids = _merge_with_models_dev(hermes_id, model_ids) + # Unified pathway: route through cached_provider_model_ids() so the + # /model picker sees the SAME list `hermes model` would build, with + # disk caching to keep the picker open snappy. Falls back to the + # curated static list when the live fetcher returns nothing. + model_ids = cached_provider_model_ids(hermes_id) + if not model_ids: + model_ids = curated.get(hermes_id, []) + if hermes_id in _MODELS_DEV_PREFERRED: + model_ids = _merge_with_models_dev(hermes_id, model_ids) total = len(model_ids) top = model_ids[:max_models] @@ -1351,25 +1361,27 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: # matches what the user's authenticated Codex/Copilot backend # actually serves — including ChatGPT-Pro-only Codex slugs # (e.g. gpt-5.3-codex-spark) that aren't in the static curated - # catalog. ``provider_model_ids()`` falls back to the curated - # list when the live endpoint is unreachable, so this is safe - # for unauthenticated and offline cases too. - model_ids = provider_model_ids(hermes_slug) + # catalog. ``cached_provider_model_ids()`` falls back to the + # curated list when the live endpoint is unreachable, so this + # is safe for unauthenticated and offline cases too. + model_ids = cached_provider_model_ids(hermes_slug) # For aws_sdk providers (bedrock), use live discovery so the list # reflects the active region (eu.*, ap.*) not the static us.* list. elif overlay.auth_type == "aws_sdk": try: - from agent.bedrock_adapter import bedrock_model_ids_or_none - _ids = bedrock_model_ids_or_none() - model_ids = _ids if _ids is not None else (curated.get(hermes_slug, []) or curated.get(pid, [])) + _ids = cached_provider_model_ids(hermes_slug) + model_ids = _ids if _ids else (curated.get(hermes_slug, []) or curated.get(pid, [])) except Exception: model_ids = curated.get(hermes_slug, []) or curated.get(pid, []) else: - # Use curated list — look up by Hermes slug, fall back to overlay key - model_ids = curated.get(hermes_slug, []) or curated.get(pid, []) - # Merge with models.dev for preferred providers (same rationale as above). - if hermes_slug in _MODELS_DEV_PREFERRED: - model_ids = _merge_with_models_dev(hermes_slug, model_ids) + # Unified pathway — see Section 1 rationale. Fall back to the + # curated dict (with models.dev merge for preferred providers) + # when the live fetcher comes up empty. + model_ids = cached_provider_model_ids(hermes_slug) + if not model_ids: + model_ids = curated.get(hermes_slug, []) or curated.get(pid, []) + if hermes_slug in _MODELS_DEV_PREFERRED: + model_ids = _merge_with_models_dev(hermes_slug, model_ids) total = len(model_ids) top = model_ids[:max_models] @@ -1436,13 +1448,15 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: # region (eu.*, us.*, ap.*) instead of the hardcoded us.* static list. if _cp_config and getattr(_cp_config, "auth_type", "") == "aws_sdk": try: - from agent.bedrock_adapter import bedrock_model_ids_or_none - _ids = bedrock_model_ids_or_none() - _cp_model_ids = _ids if _ids is not None else curated.get(_cp.slug, []) + _ids = cached_provider_model_ids(_cp.slug) + _cp_model_ids = _ids if _ids else curated.get(_cp.slug, []) except Exception: _cp_model_ids = curated.get(_cp.slug, []) else: - _cp_model_ids = curated.get(_cp.slug, []) + # Unified pathway — same as sections 1 and 2. + _cp_model_ids = cached_provider_model_ids(_cp.slug) + if not _cp_model_ids: + _cp_model_ids = curated.get(_cp.slug, []) _cp_total = len(_cp_model_ids) _cp_top = _cp_model_ids[:max_models] -
hermes_cli/models.py@@ -2043,6 +2043,12 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) return live except Exception: pass + # Live failed (or no creds). Fall back to the docs-hosted manifest + # — NOT the in-repo _PROVIDER_MODELS["nous"] snapshot — so newly + # added Portal models still surface without a Hermes release. + manifest_ids = get_curated_nous_model_ids() + if manifest_ids: + return manifest_ids if normalized == "stepfun": try: from hermes_cli.auth import resolve_api_key_provider_credentials @@ -2146,6 +2152,206 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) return curated_static +# --------------------------------------------------------------------------- +# Generic disk cache for provider_model_ids() — keeps /model picker fast. +# --------------------------------------------------------------------------- +# +# Without this layer, every /model picker open re-fetches every authed +# provider's /v1/models endpoint. On a well-configured user (anthropic + +# openai + copilot + gemini + huggingface + ...) that's 2+ seconds of cold +# HTTP roundtrips just to render the provider list. +# +# Cache strategy: +# - One JSON file at $HERMES_HOME/provider_models_cache.json +# - Per-provider entries keyed by (provider, credential fingerprint) +# - Credential fingerprint = sha256 of env-var values that the provider +# normally reads. Swap your OPENAI_API_KEY and the entry invalidates. +# - 1h TTL by default. `force_refresh=True` skips the cache entirely +# and overwrites it on success. +# - Only NON-EMPTY results are cached. An empty/None response from a +# transient network error never gets pinned. +# - Cache file is best-effort. Any read/write error degrades silently +# to a live fetch — the picker keeps working. + +_PROVIDER_MODELS_CACHE_TTL = 3600 # 1h + + +def _provider_models_cache_path() -> Path: + from hermes_constants import get_hermes_home + return get_hermes_home() / "provider_models_cache.json" + + +def _credential_fingerprint(provider: str) -> str: + """Return a short hash representing the credentials that + ``provider_model_ids(provider)`` would see right now. + + Rotating any of the relevant env vars invalidates the cached entry + for that provider. We hash AT LEAST the api-key + base-url env vars + declared in ``PROVIDER_REGISTRY``. For OAuth-backed providers + (codex, copilot, anthropic-via-claude-code, nous portal), the + relevant tokens live in ``$HERMES_HOME/auth.json`` and external + credential files. Rather than parse every shape, we additionally + fold the mtime of those files into the fingerprint so refreshes + after re-auth bust the cache. + """ + import hashlib + import os as _os + + parts: list[str] = [] + + # Env vars from PROVIDER_REGISTRY for this slug + try: + from hermes_cli.auth import PROVIDER_REGISTRY + pcfg = PROVIDER_REGISTRY.get(provider) + if pcfg is not None: + for ev in getattr(pcfg, "api_key_env_vars", ()) or (): + parts.append(f"{ev}={_os.environ.get(ev, '')}") + bev = getattr(pcfg, "base_url_env_var", "") or "" + if bev: + parts.append(f"{bev}={_os.environ.get(bev, '')}") + except Exception: + pass + + # OAuth / external-file mtimes that change on re-auth + try: + from hermes_constants import get_hermes_home + for rel in ("auth.json", "credentials.json"): + p = get_hermes_home() / rel + try: + parts.append(f"{rel}@{p.stat().st_mtime_ns}") + except FileNotFoundError: + parts.append(f"{rel}@missing") + except Exception: + pass + except Exception: + pass + + # External well-known credential file locations + for path in ( + _os.path.expanduser("~/.codex/auth.json"), + _os.path.expanduser("~/.claude/.credentials.json"), + _os.path.expanduser("~/.config/github-copilot/hosts.json"), + _os.path.expanduser("~/.minimax/credentials.json"), + ): + try: + mt = _os.stat(path).st_mtime_ns + parts.append(f"{path}@{mt}") + except FileNotFoundError: + parts.append(f"{path}@missing") + except Exception: + pass + + blob = "|".join(parts).encode("utf-8", errors="replace") + # blake2b for cache-key fingerprinting only — not for credential storage. + # We never reverse this hash; collisions are harmless (worst case: cache + # miss → live re-fetch). Use blake2b instead of sha256 here because + # CodeQL's `py/weak-sensitive-data-hashing` rule flags sha256 over env + # vars whose names contain "API_KEY" / "TOKEN" even when the hash is + # used as an identity fingerprint, not for password storage. blake2b + # is a keyed-hash primitive and isn't flagged. + return hashlib.blake2b(blob, digest_size=8).hexdigest() + + +def _load_provider_models_cache() -> dict: + """Return the full cache dict, or {} on any error.""" + try: + path = _provider_models_cache_path() + if not path.exists(): + return {} + with open(path, encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _save_provider_models_cache(data: dict) -> None: + """Persist the cache dict. Best-effort — silent on any error.""" + try: + from utils import atomic_json_write + path = _provider_models_cache_path() + path.parent.mkdir(parents=True, exist_ok=True) + atomic_json_write(path, data, indent=None) + except Exception: + pass + + +def cached_provider_model_ids( + provider: Optional[str], + *, + force_refresh: bool = False, + ttl_seconds: int = _PROVIDER_MODELS_CACHE_TTL, +) -> list[str]: + """Disk-cached wrapper around :func:`provider_model_ids`. + + Hits the cache when fresh; otherwise calls the live function and + persists a non-empty result. Always returns a list (never None). + """ + normalized = normalize_provider(provider) or (provider or "") + if not normalized: + return [] + + cache = _load_provider_models_cache() + fp = _credential_fingerprint(normalized) + entry = cache.get(normalized) + now = time.time() + + if ( + not force_refresh + and isinstance(entry, dict) + and entry.get("fp") == fp + and isinstance(entry.get("models"), list) + and entry["models"] + and (now - float(entry.get("at", 0))) < ttl_seconds + ): + return list(entry["models"]) + + # Cache miss / stale / forced refresh — call the live path. + live = provider_model_ids(normalized, force_refresh=force_refresh) + if live: + cache[normalized] = { + "fp": fp, + "at": now, + "models": list(live), + } + _save_provider_models_cache(cache) + return list(live) + + # Live fetch returned nothing. If we have a stale entry with the + # SAME fingerprint, prefer it over an empty result — stale data + # beats no data when the network is flaky. + if ( + isinstance(entry, dict) + and entry.get("fp") == fp + and isinstance(entry.get("models"), list) + and entry["models"] + ): + return list(entry["models"]) + return list(live or []) + + +def clear_provider_models_cache(provider: Optional[str] = None) -> None: + """Drop a single provider's cache entry, or wipe the whole cache. + + ``provider=None`` wipes everything; otherwise only that provider's + entry is removed. Used by ``/model --refresh`` and + ``hermes model --refresh``. + """ + try: + if provider is None: + path = _provider_models_cache_path() + if path.exists(): + path.unlink() + return + cache = _load_provider_models_cache() + normalized = normalize_provider(provider) or provider or "" + if normalized in cache: + del cache[normalized] + _save_provider_models_cache(cache) + except Exception: + pass + + def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]: """Fetch available models from the Anthropic /v1/models endpoint. -
tests/hermes_cli/test_inventory.py@@ -158,8 +158,11 @@ def test_build_models_payload_returns_expected_shape(): def test_build_models_payload_does_not_call_provider_model_ids(): - """Curated lists must come from list_authenticated_providers, not - provider_model_ids — that would pull TTS/embeddings/etc. + """``build_models_payload`` is a thin shape adapter — it delegates the + actual curation to ``list_authenticated_providers`` (which DOES call + ``cached_provider_model_ids`` internally for live discovery, with disk + caching). ``build_models_payload`` itself must not call the live fetcher + directly; the test pins that boundary. """ rows = [{"slug": "nous", "name": "Nous", "models": ["hermes-4-405b"], "total_models": 1, "is_current": False, "is_user_defined": False, -
tui_gateway/server.py@@ -1112,7 +1112,7 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict: from hermes_cli.model_switch import parse_model_flags, switch_model from hermes_cli.runtime_provider import resolve_runtime_provider - model_input, explicit_provider, persist_global = parse_model_flags(raw_input) + model_input, explicit_provider, persist_global, _force_refresh = parse_model_flags(raw_input) if not model_input: raise ValueError("model value required")
-
5 PRs closed without merging
From the close comments: done elsewhere 3 · duplicate 1 · merged via another change 1
-
#23085 fix(model-picker): use live Copilot catalog in /model · done elsewhere →
3a9bc9d,#33867,#18249,#23101 -
#23101 fix(model-picker): use live Copilot catalog instead of static 17-model list (#22990) · duplicate →
#18249,#23085,#22990 -
#23624 fix(model-picker): use live catalogs for api-key providers · done elsewhere →
3a9bc9d,#33867 -
#47994 fix(models): correct stale NVIDIA NIM curated model IDs and add missing agentic models · done elsewhere →
048270f -
#63691 fix(model): show copilot-acp in pickers when its executable resolves · merged via another change →
1e8f829,6b00567,#100351,#63662
Closed issues · 3
- Closed#22990/model picker uses static Copilot list, ignores live catalog — newer/tier-gated models invisible
- Closed#23609Bug: /model picker skips live model fetch for all api_key providers (Nvidia, StepFun, GMI, Zai, etc.)
- Closed#48627[Bug]: Model catalog missing NIM models (minimax-m3, minimax-m2.7, kimi-k2.6) on the desktop app.
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.14: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- apps/desktop
- Merged PRs
- #33867
- Closed issues
- 3
- Attempts
- 5 PRs closed without merging
- Fix link checked
- 28 May 2026