mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-15 09:21:36 +00:00
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:
parent
8a9b4bb2c2
commit
3c04527e1e
7 changed files with 307 additions and 6 deletions
|
|
@ -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"] = []
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue