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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user