fix: silent no-model default is GLM-5.2, never the Anthropic flagship (#64635)
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.fix/verification-admin-route-recovery
parent
5d410355ac
commit
080daa3f42
|
|
@ -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
|
# Cost-safe overrides for the *silent* auto-default
|
||||||
# (``get_default_model_for_provider``). Most providers' curated lists lead with a
|
# (``get_default_model_for_provider``). Most providers' curated lists lead with a
|
||||||
# sensible default, but Nous Portal is a per-token *metered aggregator* whose
|
# sensible default, but metered aggregators (Nous Portal, OpenRouter) order
|
||||||
# list is ordered best-/most-capable-first — entry [0] is the priciest flagship
|
# their lists best-/most-capable-first — entry [0] is the priciest flagship
|
||||||
# (``anthropic/claude-opus-4.8``, $5/$25 per Mtok). Using that as the
|
# (``anthropic/claude-fable-5``). Using that as the non-interactive fallback
|
||||||
# non-interactive fallback when a profile sets ``provider: nous`` with no model
|
# when a profile sets a provider with no model silently bills the most
|
||||||
# silently bills the most expensive model for traffic the user never opted into
|
# expensive model for traffic the user never opted into (a missing default
|
||||||
# (a missing default escalated to Opus and billed 863 requests before the user
|
# escalated to Opus and billed 863 requests before the user noticed). Pin the
|
||||||
# noticed). Pin the silent default to a low-cost curated model instead so a
|
# silent default to ``PREFERRED_SILENT_DEFAULT_MODEL`` instead so a missing
|
||||||
# missing model can never escalate to the flagship.
|
# model can never escalate to the flagship.
|
||||||
#
|
#
|
||||||
# This is deliberately a fixed, side-effect-free default for the hot resolution
|
# This is deliberately a fixed, side-effect-free default for the hot resolution
|
||||||
# path. The *interactive* default (GUI onboarding / ``hermes model``) uses the
|
# 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
|
# 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.
|
# hit the Portal; this fallback must stay cheap and network-free.
|
||||||
_PROVIDER_SILENT_DEFAULT_OVERRIDES: dict[str, str] = {
|
_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
|
same model the ``hermes model`` picker offers first. For metered aggregators
|
||||||
whose curated list is ordered most-capable-first, that entry is also the
|
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
|
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
|
``_PROVIDER_SILENT_DEFAULT_OVERRIDES``; a missing model must never
|
||||||
auto-escalate to the flagship.
|
auto-escalate to the flagship.
|
||||||
"""
|
"""
|
||||||
models = _PROVIDER_MODELS.get(provider, [])
|
models = _PROVIDER_MODELS.get(provider, [])
|
||||||
override = _PROVIDER_SILENT_DEFAULT_OVERRIDES.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 override
|
||||||
return models[0] if models else ""
|
return models[0] if models else ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5576,6 +5576,7 @@ def get_recommended_default_model(provider: str = ""):
|
||||||
get_pricing_for_provider,
|
get_pricing_for_provider,
|
||||||
check_nous_free_tier,
|
check_nous_free_tier,
|
||||||
partition_nous_models_by_tier,
|
partition_nous_models_by_tier,
|
||||||
|
pick_silent_default_model,
|
||||||
union_with_portal_free_recommendations,
|
union_with_portal_free_recommendations,
|
||||||
union_with_portal_paid_recommendations,
|
union_with_portal_paid_recommendations,
|
||||||
)
|
)
|
||||||
|
|
@ -5604,21 +5605,25 @@ def get_recommended_default_model(provider: str = ""):
|
||||||
model_ids, pricing, portal_url
|
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)}
|
return {"provider": "nous", "model": model, "free_tier": bool(free_tier)}
|
||||||
except Exception:
|
except Exception:
|
||||||
_log.exception("GET /api/model/recommended-default (nous) failed")
|
_log.exception("GET /api/model/recommended-default (nous) failed")
|
||||||
return {"provider": "nous", "model": "", "free_tier": None}
|
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:
|
try:
|
||||||
from hermes_cli.inventory import build_models_payload, load_picker_context
|
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())
|
payload = build_models_payload(load_picker_context())
|
||||||
for row in payload.get("providers", []):
|
for row in payload.get("providers", []):
|
||||||
if str(row.get("slug", "")).lower() == slug:
|
if str(row.get("slug", "")).lower() == slug:
|
||||||
models = row.get("models") or []
|
models = [str(m) for m in (row.get("models") or [])]
|
||||||
return {"provider": slug, "model": models[0] if models else "", "free_tier": None}
|
return {"provider": slug, "model": pick_silent_default_model(models), "free_tier": None}
|
||||||
return {"provider": slug, "model": "", "free_tier": None}
|
return {"provider": slug, "model": "", "free_tier": None}
|
||||||
except Exception:
|
except Exception:
|
||||||
_log.exception("GET /api/model/recommended-default failed")
|
_log.exception("GET /api/model/recommended-default failed")
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,17 @@ class TestGetDefaultModelForProvider:
|
||||||
assert result
|
assert result
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
|
|
||||||
def test_openrouter_returns_empty(self):
|
def test_openrouter_returns_preferred_silent_default(self):
|
||||||
"""OpenRouter uses dynamic model fetch, no static catalog entry."""
|
"""OpenRouter has no static catalog (live fetch), but the silent
|
||||||
from hermes_cli.models import get_default_model_for_provider
|
default must still resolve — to the cost-safe preferred model, never
|
||||||
# OpenRouter is not in _PROVIDER_MODELS — it uses live fetching
|
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")
|
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):
|
def test_unknown_provider_returns_empty(self):
|
||||||
from hermes_cli.models import get_default_model_for_provider
|
from hermes_cli.models import get_default_model_for_provider
|
||||||
|
|
|
||||||
|
|
@ -2117,7 +2117,14 @@ def _resolve_model() -> str:
|
||||||
return str(m.get("default", "") or "").strip()
|
return str(m.get("default", "") or "").strip()
|
||||||
if isinstance(m, str) and m:
|
if isinstance(m, str) and m:
|
||||||
return m.strip()
|
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:
|
def _resolve_session_platform() -> str:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue