From 080daa3f424f06bc3a27ec9deb9267d94db9a964 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:11:46 -0700 Subject: [PATCH] fix: silent no-model default is GLM-5.2, never the Anthropic flagship (#64635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user starts a chat without ever selecting a model (GUI Chat App onboarding, provider-set-but-model-missing config, empty model.default), every silent fallback path resolved to the first curated catalog entry — anthropic/claude-fable-5, the priciest flagship. Users were silently billed for the most expensive model without opting in. - hermes_cli/models.py: add PREFERRED_SILENT_DEFAULT_MODEL (z-ai/glm-5.2) + pick_silent_default_model() helper; point the nous silent-default override at it and add an openrouter override (previously resolved to "" and let downstream paths land on the flagship). - hermes_cli/web_server.py: /api/model/recommended-default (the endpoint the Desktop onboarding confirm card reads) now picks GLM-5.2 when the provider's list carries it instead of blindly taking entry [0]. - tui_gateway/server.py: _resolve_model()'s last-resort literal was anthropic/claude-sonnet-4; now PREFERRED_SILENT_DEFAULT_MODEL. - tests: update empty-model fallback tests for the new contract. --- hermes_cli/models.py | 52 +++++++++++++++++++++++------- hermes_cli/web_server.py | 13 +++++--- tests/test_empty_model_fallback.py | 15 ++++++--- tui_gateway/server.py | 9 +++++- 4 files changed, 68 insertions(+), 21 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index d81c409814c7..9941760aad10 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1301,16 +1301,39 @@ _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 +# 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 +# for traffic the user never opted into. +PREFERRED_SILENT_DEFAULT_MODEL = "z-ai/glm-5.2" + + +def pick_silent_default_model(model_ids: list[str]) -> str: + """Pick the silent default from an available-models list. + + Returns :data:`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 + 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 Nous Portal is a per-token *metered aggregator* whose -# list is ordered best-/most-capable-first — entry [0] is the priciest flagship -# (``anthropic/claude-opus-4.8``, $5/$25 per Mtok). Using that as the -# non-interactive fallback when a profile sets ``provider: nous`` 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 a low-cost curated model instead so a -# missing model can never escalate to the flagship. +# sensible default, but 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. # # This is deliberately a fixed, side-effect-free default for the hot resolution # path. The *interactive* default (GUI onboarding / ``hermes model``) uses the @@ -1318,7 +1341,12 @@ _PROVIDER_ALIASES = { # 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": "deepseek/deepseek-v4-flash", + "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, } @@ -1333,13 +1361,15 @@ def get_default_model_for_provider(provider: str) -> str: 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 low-cost override in + providers carry an explicit override in ``_PROVIDER_SILENT_DEFAULT_OVERRIDES``; a missing model must never auto-escalate to the flagship. """ models = _PROVIDER_MODELS.get(provider, []) override = _PROVIDER_SILENT_DEFAULT_OVERRIDES.get(provider) - if override and override in models: + # 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 return models[0] if models else "" diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ddaed53d322d..f924fecdd7c9 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5576,6 +5576,7 @@ def get_recommended_default_model(provider: str = ""): get_pricing_for_provider, check_nous_free_tier, partition_nous_models_by_tier, + pick_silent_default_model, union_with_portal_free_recommendations, union_with_portal_paid_recommendations, ) @@ -5604,21 +5605,25 @@ def get_recommended_default_model(provider: str = ""): model_ids, pricing, portal_url ) - model = model_ids[0] if model_ids else "" + model = pick_silent_default_model(model_ids) return {"provider": "nous", "model": model, "free_tier": bool(free_tier)} except Exception: _log.exception("GET /api/model/recommended-default (nous) failed") return {"provider": "nous", "model": "", "free_tier": None} - # Non-Nous: first curated model for the provider, matching prior behaviour. + # Non-Nous: preferred silent default when the provider's curated list + # carries it, else the first curated model. Aggregator lists lead with the + # priciest Anthropic flagship (claude-fable-5), which must never be the + # model a user lands on without explicitly picking it. try: from hermes_cli.inventory import build_models_payload, load_picker_context + from hermes_cli.models import pick_silent_default_model payload = build_models_payload(load_picker_context()) for row in payload.get("providers", []): if str(row.get("slug", "")).lower() == slug: - models = row.get("models") or [] - return {"provider": slug, "model": models[0] if models else "", "free_tier": None} + 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": "", "free_tier": None} except Exception: _log.exception("GET /api/model/recommended-default failed") diff --git a/tests/test_empty_model_fallback.py b/tests/test_empty_model_fallback.py index a15666ecb604..410211f06faa 100644 --- a/tests/test_empty_model_fallback.py +++ b/tests/test_empty_model_fallback.py @@ -13,12 +13,17 @@ class TestGetDefaultModelForProvider: assert result assert isinstance(result, str) - def test_openrouter_returns_empty(self): - """OpenRouter uses dynamic model fetch, no static catalog entry.""" - from hermes_cli.models import get_default_model_for_provider - # OpenRouter is not in _PROVIDER_MODELS — it uses live fetching + def test_openrouter_returns_preferred_silent_default(self): + """OpenRouter has no static catalog (live fetch), but the silent + default must still resolve — to the cost-safe preferred model, never + the curated list's Anthropic flagship (claude-fable-5).""" + from hermes_cli.models import ( + PREFERRED_SILENT_DEFAULT_MODEL, + get_default_model_for_provider, + ) result = get_default_model_for_provider("openrouter") - assert result == "" + assert result == PREFERRED_SILENT_DEFAULT_MODEL + assert "claude" not in result.lower() def test_unknown_provider_returns_empty(self): from hermes_cli.models import get_default_model_for_provider diff --git a/tui_gateway/server.py b/tui_gateway/server.py index af5cead103c1..471ca788fb03 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2117,7 +2117,14 @@ def _resolve_model() -> str: return str(m.get("default", "") or "").strip() if isinstance(m, str) and m: return m.strip() - return "anthropic/claude-sonnet-4" + # 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. + try: + from hermes_cli.models import PREFERRED_SILENT_DEFAULT_MODEL + + return PREFERRED_SILENT_DEFAULT_MODEL + except Exception: + return "z-ai/glm-5.2" def _resolve_session_platform() -> str: