feat(desktop): show model pricing + free/paid tier gating in GUI picker

The CLI `hermes model` picker shows per-model $/Mtok pricing and gates paid
models on free Nous accounts. The GUI picker showed bare model names. Bring it
to parity across both the model-picker dialog and onboarding confirm card.

Backend:
- inventory.build_models_payload gains a pricing=True flag → _apply_pricing
  enriches each provider row with formatted per-model pricing
  ({input,output,cache,free}) via the same _format_price_per_mtok the CLI uses,
  and for Nous adds free_tier + unavailable_models (paid models a free user
  can't select) via check_nous_free_tier + partition_nous_models_by_tier.
  Best-effort: any pricing/tier failure is swallowed and fails open (no gating).
- /api/model/options and TUI model.options now pass pricing=True so the
  global picker and in-session picker both carry pricing.

Frontend:
- ModelOptionProvider gains pricing/free_tier/unavailable_models; new
  ModelPricing type.
- model-picker dialog renders In/Out $/Mtok (or a Free pill) per model, a
  Free tier/Pro badge on the Nous heading, and disables + grays unavailable
  paid models for free users with a 'Pro models need a paid subscription' note.
- onboarding confirm card shows the chosen model's price + tier badge.

Tests: test_inventory_pricing covers price formatting, free-tier gating,
paid no-gating, providers without pricing, and swallowed failures.
This commit is contained in:
emozilla 2026-05-31 05:57:28 -04:00
parent 8a9b4bb2c2
commit 3c04527e1e
7 changed files with 307 additions and 6 deletions

View file

@ -114,6 +114,7 @@ def build_models_payload(
include_unconfigured: bool = False,
picker_hints: bool = False,
canonical_order: bool = False,
pricing: bool = False,
max_models: int = 50,
) -> dict:
"""Build the ``{providers, model, provider}`` shape every consumer
@ -128,6 +129,11 @@ def build_models_payload(
- ``canonical_order``: reorder canonical-slug rows to
``CANONICAL_PROVIDERS`` declaration order; truly-custom rows go
last (TUI display order).
- ``pricing``: enrich each row with formatted per-model pricing and,
for Nous, ``free_tier``/``unavailable_models`` so the GUI picker can
show $/Mtok columns and gate paid models on free accounts
mirroring the ``hermes model`` CLI picker. Adds network calls
(pricing fetch + Nous tier check); only set for interactive pickers.
"""
from hermes_cli.model_switch import list_authenticated_providers
@ -146,6 +152,8 @@ def build_models_payload(
_apply_picker_hints(rows)
if canonical_order:
rows = _reorder_canonical(rows)
if pricing:
_apply_pricing(rows)
return {
"providers": rows,
@ -238,3 +246,85 @@ def _reorder_canonical(rows: list[dict]) -> list[dict]:
)
extras = [r for r in rows if r["slug"] not in order]
return canon + extras
def _apply_pricing(rows: list[dict]) -> None:
"""Enrich each provider row with per-model pricing + Nous tier gating.
Mutates ``rows`` in-place. For every row whose provider supports live
pricing (openrouter / nous / novita) adds::
row["pricing"] = {model_id: {"input": "$3.00", "output": "$15.00",
"cache": "$0.30" | None, "free": bool}}
For Nous additionally adds::
row["free_tier"] = bool # current account is free-tier
row["unavailable_models"] = [...] # paid models a free user can't pick
Prices are pre-formatted via ``_format_price_per_mtok`` so the GUI just
renders strings identical formatting to the CLI picker. All failures
are swallowed (best-effort): a row simply gets no ``pricing`` key.
"""
from hermes_cli.models import (
_format_price_per_mtok,
check_nous_free_tier,
get_pricing_for_provider,
partition_nous_models_by_tier,
)
# Resolve Nous free-tier once (cached in models.py for the TTL window).
nous_free_tier: Optional[bool] = None
for row in rows:
slug = str(row.get("slug", "")).lower()
models = row.get("models") or []
if not models:
continue
try:
raw_pricing = get_pricing_for_provider(slug) or {}
except Exception:
raw_pricing = {}
if not raw_pricing:
continue
formatted: dict[str, dict] = {}
for mid in models:
p = raw_pricing.get(mid)
if not p:
continue
inp_raw = p.get("prompt", "")
out_raw = p.get("completion", "")
cache_raw = p.get("input_cache_read", "")
inp = _format_price_per_mtok(inp_raw) if inp_raw != "" else ""
out = _format_price_per_mtok(out_raw) if out_raw != "" else ""
cache = _format_price_per_mtok(cache_raw) if cache_raw else None
# A model is "free" when both input and output cost nothing.
is_free = inp == "free" and (out == "free" or out == "")
formatted[mid] = {
"input": inp,
"output": out,
"cache": cache,
"free": is_free,
}
if formatted:
row["pricing"] = formatted
if slug == "nous":
try:
if nous_free_tier is None:
nous_free_tier = check_nous_free_tier(force_fresh=True)
row["free_tier"] = bool(nous_free_tier)
if nous_free_tier:
_selectable, unavailable = partition_nous_models_by_tier(
list(models), raw_pricing, free_tier=True
)
row["unavailable_models"] = unavailable
else:
row["unavailable_models"] = []
except Exception:
# Tier detection failed — fail open (no gating) so the user
# is never blocked from picking a model.
row["free_tier"] = False
row["unavailable_models"] = []

View file

@ -1311,7 +1311,7 @@ def get_model_options():
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
return build_models_payload(load_picker_context(), max_models=50)
return build_models_payload(load_picker_context(), max_models=50, pricing=True)
except Exception:
_log.exception("GET /api/model/options failed")
raise HTTPException(status_code=500, detail="Failed to list model options")