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 {