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

@ -1,9 +1,11 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ModelPickerDialog } from '@/components/model-picker'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { getGlobalModelOptions } from '@/hermes'
import {
Check,
ChevronDown,
@ -660,6 +662,18 @@ function ConfirmingModelPanel({
// a familiar UI for users who'll see this picker again later.
const [pickerOpen, setPickerOpen] = useState(false)
// Pull pricing + tier for the just-picked default so the confirm card
// shows the same $/Mtok + Free/Pro info the picker and CLI do.
const options = useQuery({
queryKey: ['onboarding-model-options', flow.providerSlug],
queryFn: () => getGlobalModelOptions()
})
const providerRow = options.data?.providers?.find(
p => String(p.slug).toLowerCase() === flow.providerSlug.toLowerCase()
)
const price = providerRow?.pricing?.[flow.currentModel]
const freeTier = providerRow?.free_tier
return (
<div className="grid gap-4">
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
@ -670,8 +684,25 @@ function ConfirmingModelPanel({
<div className="grid gap-3 rounded-2xl border border-border bg-background/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Default model</p>
<div className="flex items-center gap-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Default model</p>
{freeTier === true && (
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
Free tier
</span>
)}
{freeTier === false && (
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
Pro
</span>
)}
</div>
<p className="mt-1 truncate font-mono text-sm">{flow.currentModel}</p>
{price && (price.input || price.output) && (
<p className="mt-1 font-mono text-xs text-muted-foreground">
{price.free ? 'Free' : `${price.input || '?'} in / ${price.output || '?'} out per Mtok`}
</p>
)}
</div>
<Button disabled={flow.saving} onClick={() => setPickerOpen(true)} size="sm" variant="outline">
Change

View file

@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
import type { HermesGateway } from '../hermes'
import { getGlobalModelOptions } from '../hermes'
@ -164,6 +164,8 @@ function ModelResults({
return null
}
const unavailable = new Set(provider.unavailable_models ?? [])
return (
<CommandGroup heading={<ProviderHeading provider={provider} />} key={provider.slug}>
{provider.warning && (
@ -175,22 +177,37 @@ function ModelResults({
)}
{models.map(model => {
const isCurrent = model === currentModel && provider.slug === currentProvider
const price = provider.pricing?.[model]
const locked = unavailable.has(model)
return (
<CommandItem
className={cn(
'pl-6 font-mono',
'flex items-center gap-2 pl-6 font-mono',
isCurrent &&
'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground'
'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground',
locked && 'cursor-not-allowed opacity-45'
)}
disabled={locked}
key={`${provider.slug}:${model}`}
onSelect={() => onSelectModel(provider, model)}
onSelect={() => {
if (!locked) {
onSelectModel(provider, model)
}
}}
value={`${provider.name} ${provider.slug} ${model}`}
>
<span className="min-w-0 flex-1 truncate">{model}</span>
{locked && <span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">Pro</span>}
<ModelPrice isCurrent={isCurrent} price={price} />
</CommandItem>
)
})}
{unavailable.size > 0 && (
<div className="px-6 pb-2 pt-1 text-[0.62rem] leading-relaxed text-muted-foreground">
Pro models need a paid Nous subscription.
</div>
)}
</CommandGroup>
)
})}
@ -198,6 +215,39 @@ function ModelResults({
)
}
// Compact In/Out $/Mtok price tag, mirroring the CLI picker's price columns.
// Renders nothing when pricing is unavailable for the model.
function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boolean }) {
if (!price || (!price.input && !price.output)) {
return null
}
if (price.free) {
return (
<span
className={cn(
'shrink-0 rounded-sm px-1 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide',
isCurrent ? 'bg-primary-foreground/20' : 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
)}
>
Free
</span>
)
}
return (
<span
className={cn(
'shrink-0 text-[0.66rem] tabular-nums',
isCurrent ? 'text-primary-foreground/80' : 'text-muted-foreground'
)}
title="Input / Output price per million tokens"
>
{price.input || '?'} / {price.output || '?'}
</span>
)
}
function LoadingResults() {
return (
<CommandGroup heading={<Skeleton className="h-3 w-32" />}>
@ -211,12 +261,25 @@ function LoadingResults() {
}
function ProviderHeading({ provider }: { provider: ModelOptionProvider }) {
// free_tier is only set for Nous. true → "Free tier", false → "Pro".
const tierBadge =
provider.free_tier === true ? (
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
Free tier
</span>
) : provider.free_tier === false ? (
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
Pro
</span>
) : null
return (
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{provider.name}</span>
<span className="font-mono text-xs font-normal normal-case tracking-normal text-muted-foreground">
{provider.slug} · {provider.total_models ?? provider.models?.length ?? 0}
</span>
{tierBadge}
</span>
)
}

View file

@ -185,6 +185,17 @@ export interface ModelInfoResponse {
provider: string
}
export interface ModelPricing {
/** Formatted $/Mtok input price, e.g. "$3.00", or "free", or "" if unknown. */
input: string
/** Formatted $/Mtok output price. */
output: string
/** Formatted $/Mtok cached-input price, or null when the model has none. */
cache: string | null
/** True when the model costs nothing (free tier eligible). */
free: boolean
}
export interface ModelOptionProvider {
is_current?: boolean
models?: string[]
@ -192,6 +203,13 @@ export interface ModelOptionProvider {
slug: string
total_models?: number
warning?: string
/** Per-model pricing keyed by model id (present when the picker requested
* pricing and the provider supports live pricing). */
pricing?: Record<string, ModelPricing>
/** Nous only: whether the current account is on the free tier. */
free_tier?: boolean
/** Nous only: paid models a free-tier user cannot select (shown disabled). */
unavailable_models?: string[]
}
export interface ModelOptionsResponse {

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")

View file

@ -0,0 +1,98 @@
"""Tests for inventory._apply_pricing — the pricing/tier enrichment that
feeds the desktop GUI model picker (and onboarding) so it can show $/Mtok
columns + Free/Pro badges and gate paid models on free Nous accounts, the
same way the `hermes model` CLI picker does.
"""
import hermes_cli.inventory as inv
import hermes_cli.models as models_mod
def _patch_pricing(monkeypatch, *, free_tier, pricing, unavailable=None):
monkeypatch.setattr(models_mod, "get_pricing_for_provider", lambda slug, **kw: pricing.get(slug, {}))
monkeypatch.setattr(models_mod, "check_nous_free_tier", lambda *, force_fresh=False: free_tier)
monkeypatch.setattr(
models_mod, "partition_nous_models_by_tier",
lambda ids, pr, free_tier: (
[m for m in ids if m not in (unavailable or [])],
list(unavailable or []),
),
)
def test_apply_pricing_formats_per_model_prices(monkeypatch):
"""Each model gets formatted input/output/cache + a free flag."""
_patch_pricing(
monkeypatch,
free_tier=False,
pricing={
"openrouter": {
"a/paid": {"prompt": "0.000003", "completion": "0.000015", "input_cache_read": "0.0000003"},
"b/free": {"prompt": "0", "completion": "0"},
}
},
)
rows = [{"slug": "openrouter", "models": ["a/paid", "b/free"]}]
inv._apply_pricing(rows)
pricing = rows[0]["pricing"]
assert pricing["a/paid"] == {"input": "$3.00", "output": "$15.00", "cache": "$0.30", "free": False}
assert pricing["b/free"]["free"] is True
assert pricing["b/free"]["input"] == "free"
def test_apply_pricing_nous_free_tier_gates_paid_models(monkeypatch):
"""A free-tier Nous account marks paid models unavailable and sets the flag."""
_patch_pricing(
monkeypatch,
free_tier=True,
pricing={
"nous": {
"free/model": {"prompt": "0", "completion": "0"},
"paid/model": {"prompt": "0.000005", "completion": "0.00001"},
}
},
unavailable=["paid/model"],
)
rows = [{"slug": "nous", "models": ["free/model", "paid/model"]}]
inv._apply_pricing(rows)
assert rows[0]["free_tier"] is True
assert rows[0]["unavailable_models"] == ["paid/model"]
assert rows[0]["pricing"]["free/model"]["free"] is True
def test_apply_pricing_nous_paid_tier_no_gating(monkeypatch):
"""A paid Nous account gates nothing."""
_patch_pricing(
monkeypatch,
free_tier=False,
pricing={"nous": {"x/model": {"prompt": "0.000001", "completion": "0.000002"}}},
)
rows = [{"slug": "nous", "models": ["x/model"]}]
inv._apply_pricing(rows)
assert rows[0]["free_tier"] is False
assert rows[0]["unavailable_models"] == []
def test_apply_pricing_skips_providers_without_pricing(monkeypatch):
"""A provider with no live pricing simply gets no pricing key."""
_patch_pricing(monkeypatch, free_tier=False, pricing={})
rows = [{"slug": "anthropic", "models": ["claude-x"]}]
inv._apply_pricing(rows)
assert "pricing" not in rows[0]
def test_apply_pricing_failure_is_swallowed(monkeypatch):
"""A pricing fetch that raises must not break the whole payload."""
def boom(slug, **kw):
raise RuntimeError("network down")
monkeypatch.setattr(models_mod, "get_pricing_for_provider", boom)
rows = [{"slug": "openrouter", "models": ["a/b"]}]
inv._apply_pricing(rows) # must not raise
assert "pricing" not in rows[0]

View file

@ -6425,6 +6425,7 @@ def _(rid, params: dict) -> dict:
include_unconfigured=True,
picker_hints=True,
canonical_order=True,
pricing=True,
max_models=50,
)
return _ok(rid, payload)