diff --git a/hermes_cli/model_catalog.py b/hermes_cli/model_catalog.py index 40a3a5c00bd5..fec73234aa45 100644 --- a/hermes_cli/model_catalog.py +++ b/hermes_cli/model_catalog.py @@ -356,6 +356,42 @@ def get_curated_nous_models() -> list[str] | None: return out or None +def _default_model_from_block(block: dict[str, Any] | None) -> str | None: + """Return the id of the model entry labeled ``"default": true``, or None.""" + if not isinstance(block, dict): + return None + for m in block.get("models", []): + if isinstance(m, dict) and m.get("default"): + mid = str(m.get("id") or "").strip() + if mid: + return mid + return None + + +def get_default_model_from_cache(provider: str) -> str | None: + """Return the catalog's labeled default model for ``provider`` — cache only. + + The manifest marks exactly one model entry per provider with + ``"default": true``; that entry is the model Hermes silently lands on when + the user never picked one. This accessor reads ONLY the in-process copy or + the disk cache — it NEVER triggers a network fetch, so it is safe on hot + resolution paths (agent build, gateway session setup) that must stay + network-free. The cache is kept fresh by the picker/`hermes update` paths; + when no cached manifest exists (fresh install, offline), returns None and + the caller falls back to the in-repo constant. + """ + if _catalog_cache is not None: + block = _catalog_cache.get("providers", {}).get(provider) + found = _default_model_from_block(block) + if found: + return found + disk_data, _mtime = _read_disk_cache() + if disk_data is not None: + block = disk_data.get("providers", {}).get(provider) + return _default_model_from_block(block) + return None + + def seed_cache_from_checkout(project_root: "Path | str") -> bool: """Overwrite the disk cache with the catalog shipped in a local checkout. diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 40b64fb58204..d9235a6d43e6 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -74,7 +74,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ # MiniMax ("minimax/minimax-m3", ""), # Z-AI - ("z-ai/glm-5.2", ""), + ("z-ai/glm-5.2", "default"), ("z-ai/glm-5.1", ""), # Xiaomi ("xiaomi/mimo-v2.5-pro", ""), @@ -1301,9 +1301,14 @@ _PROVIDER_ALIASES = { } -# The model Hermes silently lands on when the user never picked one and the -# provider's catalog carries it (GUI onboarding confirm card, empty -# ``model.default``, provider-set-but-model-missing resolution). Deliberately a +# In-repo fallback for the model Hermes silently lands on when the user never +# picked one (GUI onboarding confirm card, empty ``model.default``, +# provider-set-but-model-missing resolution). The AUTHORITATIVE source is the +# remote model catalog: the manifest labels exactly one entry per provider +# with ``"default": true`` (see get_default_model_from_cache in +# model_catalog.py), so maintainers can rotate the default without shipping a +# release. This constant is the offline/fresh-install fallback and MUST match +# the labeled entry in website/static/api/model-catalog.json. Deliberately a # capable low-cost model rather than the curated lists' entry [0]: aggregator # lists are ordered most-capable-first, so [0] is the priciest Anthropic # flagship (claude-fable-5 / opus) — silently billing the most expensive model @@ -1311,43 +1316,57 @@ _PROVIDER_ALIASES = { PREFERRED_SILENT_DEFAULT_MODEL = "z-ai/glm-5.2" -def pick_silent_default_model(model_ids: list[str]) -> str: +def get_preferred_silent_default_model(provider: str = "openrouter") -> str: + """Return the silent-default model id — catalog label first, constant second. + + Reads the ``"default": true`` label from the cached remote catalog + (never hits the network — safe on hot resolution paths), falling back to + :data:`PREFERRED_SILENT_DEFAULT_MODEL` when no cached manifest exists or + the provider block carries no label. + """ + try: + from hermes_cli.model_catalog import get_default_model_from_cache + labeled = get_default_model_from_cache(provider) + if labeled: + return labeled + except Exception: + pass + return PREFERRED_SILENT_DEFAULT_MODEL + + +def pick_silent_default_model(model_ids: list[str], provider: str = "openrouter") -> str: """Pick the silent default from an available-models list. - Returns :data:`PREFERRED_SILENT_DEFAULT_MODEL` when the list carries it, + Returns the catalog-labeled default (see + :func:`get_preferred_silent_default_model`) when the list carries it, else the first entry, else "". Used by every surface that must choose a model on the user's behalf without an interactive picker (GUI onboarding recommended-default, empty-model runtime fallback). """ - if PREFERRED_SILENT_DEFAULT_MODEL in model_ids: - return PREFERRED_SILENT_DEFAULT_MODEL + preferred = get_preferred_silent_default_model(provider) + if preferred in model_ids: + return preferred return model_ids[0] if model_ids else "" -# Cost-safe overrides for the *silent* auto-default -# (``get_default_model_for_provider``). Most providers' curated lists lead with a -# sensible default, but metered aggregators (Nous Portal, OpenRouter) order +# Providers whose *silent* auto-default must go through the cost-safe +# catalog-labeled default (``get_preferred_silent_default_model``) instead of +# curated-list entry [0]. Metered aggregators (Nous Portal, OpenRouter) order # their lists best-/most-capable-first — entry [0] is the priciest flagship # (``anthropic/claude-fable-5``). Using that as the non-interactive fallback # when a profile sets a provider with no model silently bills the most # expensive model for traffic the user never opted into (a missing default -# escalated to Opus and billed 863 requests before the user noticed). Pin the -# silent default to ``PREFERRED_SILENT_DEFAULT_MODEL`` instead so a missing -# model can never escalate to the flagship. +# escalated to Opus and billed 863 requests before the user noticed). The +# catalog manifest labels the default entry (``"default": true``) so it can +# rotate without a release; a missing model must never escalate to the +# flagship. # -# This is deliberately a fixed, side-effect-free default for the hot resolution -# path. The *interactive* default (GUI onboarding / ``hermes model``) uses the -# richer free/paid-tier-aware resolver — see ``get_recommended_default_model`` -# in hermes_cli/web_server.py and ``partition_nous_models_by_tier`` — which can -# hit the Portal; this fallback must stay cheap and network-free. -_PROVIDER_SILENT_DEFAULT_OVERRIDES: dict[str, str] = { - "nous": PREFERRED_SILENT_DEFAULT_MODEL, - # OpenRouter has no static ``_PROVIDER_MODELS`` entry (its picker list is - # fetched live), but the curated snapshot (``OPENROUTER_MODELS``) carries - # the preferred default — trust the override so provider-set-but-model- - # missing setups land on it instead of resolving to "". - "openrouter": PREFERRED_SILENT_DEFAULT_MODEL, -} +# This is deliberately a network-free lookup for the hot resolution path +# (cache-only catalog read). The *interactive* default (GUI onboarding / +# ``hermes model``) uses the richer free/paid-tier-aware resolver — see +# ``get_recommended_default_model`` in hermes_cli/web_server.py and +# ``partition_nous_models_by_tier`` — which can hit the Portal. +_SILENT_DEFAULT_PROVIDERS: frozenset[str] = frozenset({"nous", "openrouter"}) def get_default_model_for_provider(provider: str) -> str: @@ -1360,17 +1379,19 @@ def get_default_model_for_provider(provider: str) -> str: For most providers this is the first entry in ``_PROVIDER_MODELS`` — the same model the ``hermes model`` picker offers first. For metered aggregators whose curated list is ordered most-capable-first, that entry is also the - most EXPENSIVE one, so silently defaulting to it is a billing footgun. Such - providers carry an explicit override in - ``_PROVIDER_SILENT_DEFAULT_OVERRIDES``; a missing model must never - auto-escalate to the flagship. + most EXPENSIVE one, so silently defaulting to it is a billing footgun. + Those providers (``_SILENT_DEFAULT_PROVIDERS``) resolve through the + catalog-labeled default instead; a missing model must never auto-escalate + to the flagship. """ models = _PROVIDER_MODELS.get(provider, []) - override = _PROVIDER_SILENT_DEFAULT_OVERRIDES.get(provider) - # Trust the override when the provider has no static catalog (OpenRouter's - # picker list is fetched live; its curated snapshot carries the override). - if override and (override in models or not models): - return override + if provider in _SILENT_DEFAULT_PROVIDERS: + preferred = get_preferred_silent_default_model(provider) + # Trust the preferred default even when the provider has no static + # catalog (OpenRouter's picker list is fetched live; its curated + # snapshot carries the default). + if preferred and (preferred in models or not models): + return preferred return models[0] if models else "" @@ -1456,6 +1477,7 @@ def fetch_openrouter_models( live_by_id[mid] = item curated: list[tuple[str, str]] = [] + silent_default = get_preferred_silent_default_model("openrouter") for preferred_id in preferred_ids: live_item = live_by_id.get(preferred_id) if live_item is None: @@ -1465,14 +1487,20 @@ def fetch_openrouter_models( # when the user selects them. Ported from Kilo-Org/kilocode#9068. if not _openrouter_model_supports_tools(live_item): continue - desc = "free" if _openrouter_model_is_free(live_item.get("pricing")) else "" + if preferred_id == silent_default: + # Keep the silent-default badge through the live refresh so the + # picker shows which model Hermes lands on when none is selected. + desc = "default" + else: + desc = "free" if _openrouter_model_is_free(live_item.get("pricing")) else "" curated.append((preferred_id, desc)) if not curated: return list(_openrouter_catalog_cache or fallback) - first_id, _ = curated[0] - curated[0] = (first_id, "recommended") + first_id, first_desc = curated[0] + if not first_desc: + curated[0] = (first_id, "recommended") _openrouter_catalog_cache = curated return list(curated) @@ -1980,10 +2008,10 @@ def detect_static_provider_for_model( # ``default_models[0]`` directly. For metered aggregators whose # curated list is ordered most-capable-first (e.g. Nous Portal), # entry [0] is the priciest flagship, and typing ``/model nous`` - # would silently escalate to it — the exact billing footgun - # ``_PROVIDER_SILENT_DEFAULT_OVERRIDES`` exists to prevent. For - # providers without an override this is unchanged (it returns - # ``models[0]``). + # would silently escalate to it — the exact billing footgun the + # catalog-labeled silent default (``_SILENT_DEFAULT_PROVIDERS``) + # exists to prevent. For providers outside that set this is + # unchanged (it returns ``models[0]``). return ( resolved_provider, get_default_model_for_provider(resolved_provider) or default_models[0], diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f924fecdd7c9..175ab7550e81 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5605,7 +5605,7 @@ def get_recommended_default_model(provider: str = ""): model_ids, pricing, portal_url ) - model = pick_silent_default_model(model_ids) + model = pick_silent_default_model(model_ids, provider="nous") return {"provider": "nous", "model": model, "free_tier": bool(free_tier)} except Exception: _log.exception("GET /api/model/recommended-default (nous) failed") @@ -5623,7 +5623,7 @@ def get_recommended_default_model(provider: str = ""): for row in payload.get("providers", []): if str(row.get("slug", "")).lower() == slug: models = [str(m) for m in (row.get("models") or [])] - return {"provider": slug, "model": pick_silent_default_model(models), "free_tier": None} + return {"provider": slug, "model": pick_silent_default_model(models, provider=slug), "free_tier": None} return {"provider": slug, "model": "", "free_tier": None} except Exception: _log.exception("GET /api/model/recommended-default failed") diff --git a/scripts/build_model_catalog.py b/scripts/build_model_catalog.py index 102ae2b05b0b..eeb3c51d214f 100755 --- a/scripts/build_model_catalog.py +++ b/scripts/build_model_catalog.py @@ -33,12 +33,31 @@ 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 +from hermes_cli.models import ( # noqa: E402 + OPENROUTER_MODELS, + PREFERRED_SILENT_DEFAULT_MODEL, + _PROVIDER_MODELS, +) OUTPUT_PATH = os.path.join(REPO_ROOT, "website", "static", "api", "model-catalog.json") CATALOG_VERSION = 1 +def _openrouter_entry(mid: str, desc: str) -> dict: + entry: dict = {"id": mid, "description": desc} + if mid == PREFERRED_SILENT_DEFAULT_MODEL: + entry["description"] = desc or "default" + entry["default"] = True + return entry + + +def _nous_entry(mid: str) -> dict: + entry: dict = {"id": mid} + if mid == PREFERRED_SILENT_DEFAULT_MODEL: + entry["default"] = True + return entry + + def build_catalog() -> dict: return { "version": CATALOG_VERSION, @@ -53,11 +72,13 @@ def build_catalog() -> dict: "display_name": "OpenRouter", "note": ( "Descriptions drive picker badges. Live /api/v1/models " - "filters curated ids by tool-calling support and free pricing." + "filters curated ids by tool-calling support and free pricing. " + 'The entry labeled "default": true is the model Hermes ' + "silently lands on when the user never picked one." ), }, "models": [ - {"id": mid, "description": desc} + _openrouter_entry(mid, desc) for mid, desc in OPENROUTER_MODELS ], }, @@ -66,11 +87,13 @@ def build_catalog() -> dict: "display_name": "Nous Portal", "note": ( "Free-tier gating is determined live via Portal pricing " - "(partition_nous_models_by_tier), not this manifest." + "(partition_nous_models_by_tier), not this manifest. " + 'The entry labeled "default": true is the model Hermes ' + "silently lands on when the user never picked one." ), }, "models": [ - {"id": mid} + _nous_entry(mid) for mid in _PROVIDER_MODELS.get("nous", []) ], }, diff --git a/tests/hermes_cli/test_model_catalog.py b/tests/hermes_cli/test_model_catalog.py index b464fb046a9a..a156ffbaef4e 100644 --- a/tests/hermes_cli/test_model_catalog.py +++ b/tests/hermes_cli/test_model_catalog.py @@ -288,6 +288,68 @@ class TestCuratedAccessors: assert model_catalog.get_curated_nous_models() is None +class TestDefaultModelFromCache: + """get_default_model_from_cache reads the '"default": true' label without + ever hitting the network.""" + + def _manifest_with_default(self) -> dict: + m = _valid_manifest() + m["providers"]["openrouter"]["models"][1]["default"] = True # gpt-5.4 + m["providers"]["nous"]["models"][1]["default"] = True # kimi-k2.6 + return m + + def test_reads_label_from_disk_cache(self, isolated_home): + from hermes_cli import model_catalog + cache = isolated_home / "cache" + cache.mkdir() + (cache / "model_catalog.json").write_text( + json.dumps(self._manifest_with_default()) + ) + with patch.object(model_catalog, "_fetch_manifest") as fetch: + assert ( + model_catalog.get_default_model_from_cache("openrouter") + == "openai/gpt-5.4" + ) + assert ( + model_catalog.get_default_model_from_cache("nous") + == "moonshotai/kimi-k2.6" + ) + fetch.assert_not_called() + + def test_no_label_returns_none(self, isolated_home): + from hermes_cli import model_catalog + cache = isolated_home / "cache" + cache.mkdir() + (cache / "model_catalog.json").write_text(json.dumps(_valid_manifest())) + with patch.object(model_catalog, "_fetch_manifest") as fetch: + assert model_catalog.get_default_model_from_cache("openrouter") is None + fetch.assert_not_called() + + def test_no_cache_returns_none_without_network(self, isolated_home): + from hermes_cli import model_catalog + with patch.object(model_catalog, "_fetch_manifest") as fetch: + assert model_catalog.get_default_model_from_cache("openrouter") is None + fetch.assert_not_called() + + def test_shipped_manifest_labels_glm52_default(self, isolated_home): + """Contract with the in-repo manifest: both provider blocks label the + same default entry the code constant points at.""" + import hermes_cli.model_catalog as model_catalog + from hermes_cli.models import PREFERRED_SILENT_DEFAULT_MODEL + + repo_root = Path(model_catalog.__file__).resolve().parent.parent + manifest = json.loads( + (repo_root / "website" / "static" / "api" / "model-catalog.json").read_text() + ) + for provider in ("openrouter", "nous"): + block = manifest["providers"][provider] + labeled = [m["id"] for m in block["models"] if m.get("default")] + assert labeled == [PREFERRED_SILENT_DEFAULT_MODEL], ( + f"{provider}: exactly one entry must be labeled default and it " + f"must match PREFERRED_SILENT_DEFAULT_MODEL" + ) + + class TestDisabled: def test_disabled_config_short_circuits(self, isolated_home): from hermes_cli import model_catalog diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index bda4807d8094..ca5fb8219ea9 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -82,7 +82,10 @@ class TestFetchOpenRouterModels: def test_falls_back_to_static_snapshot_on_fetch_failure(self, monkeypatch): monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None) - with patch("hermes_cli.models._urlopen_model_catalog_request", side_effect=OSError("boom")): + # Pin the remote manifest out too — otherwise the fallback silently + # depends on whatever the deployed catalog currently contains. + with patch("hermes_cli.model_catalog.get_curated_openrouter_models", return_value=None), \ + patch("hermes_cli.models._urlopen_model_catalog_request", side_effect=OSError("boom")): models = fetch_openrouter_models(force_refresh=True) assert models == OPENROUTER_MODELS diff --git a/tests/test_empty_model_fallback.py b/tests/test_empty_model_fallback.py index 335d38df1c10..53260f655ed9 100644 --- a/tests/test_empty_model_fallback.py +++ b/tests/test_empty_model_fallback.py @@ -38,14 +38,14 @@ class TestGetDefaultModelForProvider: def test_nous_silent_default_is_not_the_expensive_flagship(self): """Nous Portal is a metered aggregator whose curated list is ordered most-capable-first, so entry [0] is the priciest flagship - (anthropic/claude-opus-4.8). The silent fallback (provider set, no model) + (anthropic/claude-fable-5). The silent fallback (provider set, no model) must NOT escalate to it — otherwise an unconfigured profile silently bills the most expensive model. Regression for the billing footgun. """ from hermes_cli.models import ( _PROVIDER_MODELS, - _PROVIDER_SILENT_DEFAULT_OVERRIDES, get_default_model_for_provider, + get_preferred_silent_default_model, ) result = get_default_model_for_provider("nous") @@ -53,27 +53,68 @@ class TestGetDefaultModelForProvider: assert "opus" not in result.lower(), ( f"silent default escalated to an expensive flagship: {result!r}" ) + assert "claude" not in result.lower(), ( + f"silent default escalated to an expensive flagship: {result!r}" + ) assert result != _PROVIDER_MODELS["nous"][0], ( "silent default must not be the most-capable/priciest catalog entry" ) - # The override must point at a model that actually exists in the catalog. - assert result == _PROVIDER_SILENT_DEFAULT_OVERRIDES["nous"] + # The default must resolve through the catalog-label helper and point + # at a model that actually exists in the curated catalog. + assert result == get_preferred_silent_default_model("nous") assert result in _PROVIDER_MODELS["nous"] - def test_override_falls_back_to_catalog_when_missing(self): - """If an override model is no longer in the catalog, fall back to [0] - rather than returning a stale/absent id.""" + def test_catalog_label_overrides_constant(self): + """A ``"default": true`` label in the cached catalog manifest wins over + the in-repo constant, so maintainers can rotate the silent default + without shipping a release.""" from unittest.mock import patch from hermes_cli import models as models_mod - with patch.dict( - models_mod._PROVIDER_SILENT_DEFAULT_OVERRIDES, - {"openai-codex": "does-not-exist-model"}, - clear=False, + with patch( + "hermes_cli.model_catalog.get_default_model_from_cache", + return_value="qwen/qwen3.7-plus", ): - result = models_mod.get_default_model_for_provider("openai-codex") - assert result == models_mod._PROVIDER_MODELS["openai-codex"][0] + assert ( + models_mod.get_preferred_silent_default_model("nous") + == "qwen/qwen3.7-plus" + ) + # nous catalog carries qwen3.7-plus, so the full resolver follows. + assert ( + models_mod.get_default_model_for_provider("nous") + == "qwen/qwen3.7-plus" + ) + + def test_no_catalog_cache_falls_back_to_constant(self): + """With no cached manifest (fresh install / offline), the in-repo + constant is the silent default.""" + from unittest.mock import patch + + from hermes_cli import models as models_mod + + with patch( + "hermes_cli.model_catalog.get_default_model_from_cache", + return_value=None, + ): + assert ( + models_mod.get_preferred_silent_default_model("openrouter") + == models_mod.PREFERRED_SILENT_DEFAULT_MODEL + ) + + def test_stale_label_not_in_catalog_falls_back(self): + """If the labeled default model is no longer in the provider's curated + catalog, fall back to entry [0] rather than returning an absent id.""" + from unittest.mock import patch + + from hermes_cli import models as models_mod + + with patch( + "hermes_cli.model_catalog.get_default_model_from_cache", + return_value="does-not-exist-model", + ): + result = models_mod.get_default_model_for_provider("nous") + assert result == models_mod._PROVIDER_MODELS["nous"][0] class TestDetectStaticProviderCostSafeDefault: @@ -101,15 +142,15 @@ class TestDetectStaticProviderCostSafeDefault: assert model != _PROVIDER_MODELS["nous"][0] def test_provider_without_override_still_uses_first_model(self): - """Providers with no silent-default override are unchanged.""" + """Providers outside _SILENT_DEFAULT_PROVIDERS are unchanged.""" from hermes_cli.models import ( _PROVIDER_MODELS, - _PROVIDER_SILENT_DEFAULT_OVERRIDES, + _SILENT_DEFAULT_PROVIDERS, detect_static_provider_for_model, ) for provider in ("anthropic", "xai"): - if provider in _PROVIDER_SILENT_DEFAULT_OVERRIDES: + if provider in _SILENT_DEFAULT_PROVIDERS: continue result = detect_static_provider_for_model(provider, "openrouter") assert result is not None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 471ca788fb03..febb4ec0d800 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2118,11 +2118,12 @@ def _resolve_model() -> str: if isinstance(m, str) and m: return m.strip() # No env seed and no config preference: fall back to the cost-safe silent - # default, never an expensive Anthropic flagship the user didn't pick. + # default (catalog-labeled, cache-only read), never an expensive Anthropic + # flagship the user didn't pick. try: - from hermes_cli.models import PREFERRED_SILENT_DEFAULT_MODEL + from hermes_cli.models import get_preferred_silent_default_model - return PREFERRED_SILENT_DEFAULT_MODEL + return get_preferred_silent_default_model() except Exception: return "z-ai/glm-5.2" diff --git a/website/docs/reference/model-catalog.md b/website/docs/reference/model-catalog.md index 4e44543354fd..71ed187d72d6 100644 --- a/website/docs/reference/model-catalog.md +++ b/website/docs/reference/model-catalog.md @@ -29,6 +29,7 @@ Published on every merge to `main` via the existing `deploy-site.yml` GitHub Pag "openrouter": { "metadata": {}, "models": [ + {"id": "z-ai/glm-5.2", "description": "default", "default": true}, {"id": "moonshotai/kimi-k2.6", "description": "recommended", "metadata": {}}, {"id": "openai/gpt-5.4", "description": ""} ] @@ -36,6 +37,7 @@ Published on every merge to `main` via the existing `deploy-site.yml` GitHub Pag "nous": { "metadata": {}, "models": [ + {"id": "z-ai/glm-5.2", "default": true}, {"id": "anthropic/claude-opus-4.7"}, {"id": "moonshotai/kimi-k2.6"} ] @@ -48,7 +50,8 @@ 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. +- **`description`** — OpenRouter-only. Drives picker badge text (`"recommended"`, `"free"`, `"default"`, or empty). Nous Portal doesn't use this — free-tier gating is determined live from the Portal's pricing endpoint. +- **`default`** — exactly one entry per provider may carry `"default": true`. That model is the **silent default**: what Hermes lands on when the user never selected a model (GUI onboarding confirm card, `provider` configured with no `model`, empty `model.default`). Read cache-only at runtime (`get_default_model_from_cache`) so hot resolution paths never hit the network; when no cached manifest exists, Hermes falls back to the in-repo `PREFERRED_SILENT_DEFAULT_MODEL` constant, which must match the labeled entry. This lets maintainers rotate the silent default without shipping a release. It is deliberately a capable low-cost model, never the priciest flagship. - **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 diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index b829b00eb03d..07fca06d6964 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-07-09T18:38:59Z", + "updated_at": "2026-07-15T04:51:12Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -9,7 +9,7 @@ "openrouter": { "metadata": { "display_name": "OpenRouter", - "note": "Descriptions drive picker badges. Live /api/v1/models filters curated ids by tool-calling support and free pricing." + "note": "Descriptions drive picker badges. Live /api/v1/models filters curated ids by tool-calling support and free pricing. The entry labeled \"default\": true is the model Hermes silently lands on when the user never picked one." }, "models": [ { @@ -118,7 +118,8 @@ }, { "id": "z-ai/glm-5.2", - "description": "" + "description": "default", + "default": true }, { "id": "z-ai/glm-5.1", @@ -177,7 +178,7 @@ "nous": { "metadata": { "display_name": "Nous Portal", - "note": "Free-tier gating is determined live via Portal pricing (partition_nous_models_by_tier), not this manifest." + "note": "Free-tier gating is determined live via Portal pricing (partition_nous_models_by_tier), not this manifest. The entry labeled \"default\": true is the model Hermes silently lands on when the user never picked one." }, "models": [ { @@ -256,7 +257,8 @@ "id": "minimax/minimax-m3" }, { - "id": "z-ai/glm-5.2" + "id": "z-ai/glm-5.2", + "default": true }, { "id": "z-ai/glm-5.1"