Files
wbin90620 9b1a821a0c The savings calculator offers every model the price list prices, its featured ones first
/enterprise's "What the same tokens cost" offered only the price list's
featured models, a hand-kept list (hanzoai/pricing featuredModelIds) that had
fallen behind: eleven older models, and none of a lab's newer ones however long
the price list had listed them.

The model picker now offers every paid, priced model of the labs, at list
price (a :batch row is the same model discounted, so it is left out). The
featured ones lead under "Featured", dearest first, and the page still opens on
the first of them; every other follows under its lab, A to Z, so a new model is
offered the day the price list lists it. The featured set itself is refreshed in
hanzoai/pricing.

The calculator gate holds it: the featured model under Featured and chosen, and
a model the price list does not feature still offered under its lab.
2026-10-21 02:23:07 -04:00

364 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* The savings calculator on /enterprise: what the tokens a team uses today cost
* at the providers' list prices, on Hanzo Cloud, and on GPUs of its own.
*
* EVERY RATE IS READ WHEN THE PAGE OPENS, and none is typed: the providers'
* rates and ours from the price list (GET /v1/pricing), a GPU hour from the
* same list's on-demand rates, a Team member's month from the plan list
* (GET /v1/billing/plans), and what Hanzo bills for a provider's model from
* the model list (GET /v1/models). Every model of the labs the price list prices
* is offered, its featured ones first (`labs`). The volumes, the people and the GPUs are the
* reader's; the ones the page opens with are an example and say so. Each column
* says what it assumes under its figure (lib/savings has the arithmetic).
*
* The Enterprise agreement is priced with our team, so no total here includes
* it, and the GPU column says so beside its figure.
*/
import { useMemo, useState, type ReactNode } from 'react'
import { ChevronDown, Minus, Plus } from 'lucide-react'
import { Button, Input, Label, Picker, Text, ToggleGroup, ToggleGroupItem, View, XStack, YStack } from '@hanzo/ui'
import { Loading, Unreachable } from '@/components/api/state'
import { usePlans, usePricing, useServed } from '@/lib/api/use'
import type { Lab, Own, Plan, Pricing, Served } from '@/lib/api/read'
import { bought, cost, even, gpus, HOURS, percent, rate, saves, usd, type Rates, type Tokens } from '@/lib/savings'
import { day } from '@/lib/tally'
import { CARD } from './kit'
/** A row of use: a provider's model and what it takes a month. */
const ZERO = 0
const NO_TOKENS: Tokens = { input: ZERO, output: ZERO }
type Row = { model: string; mTokIn: number; mTokOut: number; spend: number; share: number }
/** How a reader states use: tokens a month, or dollars a month. */
type Mode = 'tokens' | 'spend'
/** The most rows a reader adds. */
const ROWS = 3
/** A lab's model the calculator can price: paid, with both rates, and at its list price (a `:batch` row is the same model discounted). */
const priced = (m: Lab) => !m.isFree && m.pricing.input > 0 && m.pricing.output > 0 && !m.id.endsWith(':batch')
/**
* The models a reader picks from: every model of the labs the price list prices.
* The price list's featured ones lead, dearest first, and the page opens on the
* first of them; every other follows under its lab, A to Z. The featured set is
* curated in hanzoai/pricing (featuredModelIds); what is offered is not, so a
* lab's new model is here the day the price list lists it.
*/
function labs(doc: Pricing): { featured: Lab[]; byLab: [string, Lab[]][]; all: Lab[] } {
const paid = doc.thirdPartyModels.filter(priced)
const featured = paid.filter((m) => m.featured).sort((a, b) => b.pricing.output - a.pricing.output || a.name.localeCompare(b.name))
const rest = paid.filter((m) => !m.featured)
const byLab = [...new Set(rest.map((m) => m.provider))]
.sort((a, b) => a.localeCompare(b))
.map((p): [string, Lab[]] => [p, rest.filter((m) => m.provider === p).sort((a, b) => a.name.localeCompare(b.name))])
return { featured, byLab, all: [...featured, ...byLab.flatMap(([, ms]) => ms)] }
}
/** Ours to move the tokens to: the Enso SKUs the price list prices. */
const ours = (doc: Pricing): Own[] =>
doc.hanzoModels.filter((m) => m.family === 'enso' && m.pricing?.input != null && m.pricing.output != null && !m.name.includes('/'))
/** A provider's model by the name the reader reads: "Anthropic: Claude Opus 4.8" stays whole. */
const label = (m: Lab) => m.name
const at = (m: { pricing?: { input?: number | null; output?: number | null } }): Rates => ({ input: m.pricing?.input ?? 0, output: m.pricing?.output ?? 0 })
/** What Hanzo bills a provider's model at, from the model list, where it lists it. */
function billed(served: Served[] | null, id: string): Rates | null {
const p = served?.find((s) => s.id === id)?.pricing
return p?.input_per_million != null && p.output_per_million != null ? { input: p.input_per_million, output: p.output_per_million } : null
}
/** A number field's value, never below nothing. */
const amount = (v: string) => {
const n = Number(v)
return Number.isFinite(n) && n > 0 ? n : 0
}
const FIELD = { height: 48, fontSize: '1rem', rounded: 12, bg: 'var(--background)' } as const
const CHOICE = {
height: 48,
minH: 48,
py: 11,
pl: 14,
pr: 40,
fontSize: '1rem',
lineHeight: '1.5rem',
rounded: 12,
bg: 'var(--background)',
'$platform-web': { appearance: 'none', WebkitAppearance: 'none' },
} as const
function Field({ id, label: name, children }: { id: string; label: string; children: ReactNode }) {
return (
<YStack gap="$1.5" minW={0} flex={1}>
<Label htmlFor={id} fontSize="$2" color="var(--muted-foreground)">
{name}
</Label>
{children}
</YStack>
)
}
/** A native select with its chevron, which the platform no longer draws. */
function Choice({ id, value, onChange, children }: { id: string; value: string; onChange: (v: string) => void; children: ReactNode }) {
return (
<View position="relative" minW={0}>
<Picker id={id} {...CHOICE} value={value} onChange={(e) => onChange(e.target.value)} width="100%">
{children}
</Picker>
<View position="absolute" r={14} t={0} b={0} justify="center" pointerEvents="none" aria-hidden>
<ChevronDown size={16} color="var(--muted-foreground)" />
</View>
</View>
)
}
function Amount({ id, value, onChange, step = 1 }: { id: string; value: number; onChange: (v: number) => void; step?: number }) {
return <Input id={id} {...FIELD} type="number" inputMode="decimal" min={0} step={step} value={String(value)} onChange={(e) => onChange(amount(e.target.value))} />
}
/** One of the three totals: its name, its figure, what it saves against list, and what it assumes. */
function Total({ slot, name, figure, unit, saving, children }: { slot: string; name: string; figure: string; unit: string; saving?: number | null; children: ReactNode }) {
return (
<YStack gap="$2" py="$4" borderTopWidth={1} borderColor="var(--border)" data-figure={slot}>
<XStack justify="space-between" items="baseline" gap="$3" flexWrap="wrap">
<Text render="h3" fontSize="$4" fontWeight="600" color="var(--foreground)">
{name}
</Text>
{saving != null ? (
<Text fontSize="$2" fontWeight="600" color="var(--foreground)" px={10} py={2} rounded="var(--radius-full)" borderWidth={1} borderColor="var(--border-strong)" data-saving={slot}>
{saving >= 0 ? `${percent(saving)} less than list` : `${percent(-saving)} more than list`}
</Text>
) : null}
</XStack>
<Text fontSize="2.25rem" lineHeight="2.5rem" fontWeight="500" color="var(--foreground)" $platform-web={{ fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em' }}>
{figure}
<Text render="span" fontSize="$3" fontWeight="400" color="var(--muted-foreground)">
{` ${unit}`}
</Text>
</Text>
<Text render="p" fontSize="$2" lineHeight="1.35rem" color="var(--muted-foreground)">
{children}
</Text>
</YStack>
)
}
export default function Savings() {
const pricing = usePricing()
const plans = usePlans()
const served = useServed()
if (pricing.state === 'loading' || plans.state === 'loading') return <Loading what="the price list and the plans" />
if (pricing.state === 'unreachable') return <Unreachable what="the price list" />
return <Calculator doc={pricing.data} plans={plans.state === 'ready' ? plans.data : null} served={served.state === 'ready' ? served.data : null} />
}
function Calculator({ doc, plans, served }: { doc: Pricing; plans: Plan[] | null; served: Served[] | null }) {
const offered = useMemo(() => labs(doc), [doc])
const choices = offered.all
const skus = useMemo(() => ours(doc), [doc])
const racks = doc.infrastructure?.gpu ?? []
const team = plans?.find((p) => p.category === 'team' && p.pricePerUser && (p.priceMonthly ?? 0) > 0) ?? null
// The example the page opens with: the dearest featured model, half a billion tokens in and a tenth of that out.
const [mode, setMode] = useState<Mode>('tokens')
const [rows, setRows] = useState<Row[]>(() => (choices[0] ? [{ model: choices[0].id, mTokIn: 500, mTokOut: 50, spend: 5000, share: 10 }] : []))
const [people, setPeople] = useState(50)
const [sku, setSku] = useState(() => skus.find((m) => m.name === 'enso')?.name ?? skus[0]?.name ?? '')
const [rack, setRack] = useState(() => racks[0]?.gpu ?? '')
const [count, setCount] = useState(1)
const config = racks.find((g) => g.gpu === rack) ?? racks[0]
const [hourly, setHourly] = useState<number | null>(null)
const hour = hourly ?? config?.price ?? 0
if (!choices.length || !skus.length) return <Unreachable what="the models the price list prices" />
const mine = skus.find((m) => m.name === sku) ?? skus[0]
const set = (i: number, patch: Partial<Row>) => setRows((rs) => rs.map((r, k) => (k === i ? { ...r, ...patch } : r)))
// Each row as tokens a month, whichever way it was stated.
const usage = rows.map((r) => {
const m = choices.find((c) => c.id === r.model) ?? choices[0]
const tokens: Tokens = mode === 'tokens' ? { input: r.mTokIn, output: r.mTokOut } : (bought(r.spend, Math.min(100, r.share) / 100, at(m)) ?? NO_TOKENS)
return { m, tokens }
})
const total: Tokens = usage.reduce((t, u) => ({ input: t.input + u.tokens.input, output: t.output + u.tokens.output }), NO_TOKENS)
const listed = usage.reduce((s, u) => s + cost(u.tokens, at(u.m)), 0)
const onCloud = cost(total, at(mine))
const seats = team && people > 0 && (team.limits?.maxMembers == null || people <= Number(team.limits.maxMembers)) ? people * (team.priceMonthly ?? 0) : null
const cloud = onCloud + (seats ?? 0)
const fleet = gpus(count, hour)
const point = even(fleet, total, listed)
const kept = usage.every((u) => billed(served, u.m.id)) ? usage.reduce((s, u) => s + cost(u.tokens, billed(served, u.m.id)!), 0) : null
const millions = (v: number) => (v >= 1000 ? `${(v / 1000).toLocaleString('en-US', { maximumFractionDigits: 2 })} billion` : `${v.toLocaleString('en-US', { maximumFractionDigits: 1 })} million`)
return (
<View display="grid" gridTemplateColumns="minmax(0, 1fr)" gap={16} $lg={{ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)' }} data-slot="savings">
<YStack {...CARD} p={24} $sm={{ p: 28 }} gap="$5" minW={0}>
<XStack justify="space-between" items="center" gap="$3" flexWrap="wrap">
<Text render="h3" fontSize="$5" fontWeight="600" color="var(--foreground)">
What you use a month
</Text>
<XStack p={4} rounded={9999} bg="var(--background)">
<ToggleGroup type="single" value={mode} onValueChange={(v) => v && setMode(v as Mode)} disableDeactivation aria-label="State use by">
<ToggleGroupItem value="tokens" variant="outline" rounded="$10" borderColor="transparent" px="$3">
Tokens
</ToggleGroupItem>
<ToggleGroupItem value="spend" variant="outline" rounded="$10" borderColor="transparent" px="$3">
Spend
</ToggleGroupItem>
</ToggleGroup>
</XStack>
</XStack>
{rows.map((r, i) => (
<YStack key={i} gap="$3" data-row={i}>
{/* A field grows along a row; in its own row the model's keeps its height. */}
<XStack>
<Field id={`model-${i}`} label={i ? `Model ${i + 1}` : 'Model you use today'}>
<Choice id={`model-${i}`} value={r.model} onChange={(v) => set(i, { model: v })}>
{offered.featured.length ? (
<optgroup label="Featured">
{offered.featured.map((m) => (
<option key={m.id} value={m.id}>
{label(m)}
</option>
))}
</optgroup>
) : null}
{offered.byLab.map(([lab, ms]) => (
<optgroup key={lab} label={lab}>
{ms.map((m) => (
<option key={m.id} value={m.id}>
{label(m)}
</option>
))}
</optgroup>
))}
</Choice>
</Field>
</XStack>
<XStack gap="$3" flexWrap="wrap">
{mode === 'tokens' ? (
<>
<Field id={`in-${i}`} label="Input tokens, millions">
<Amount id={`in-${i}`} value={r.mTokIn} onChange={(v) => set(i, { mTokIn: v })} />
</Field>
<Field id={`out-${i}`} label="Output tokens, millions">
<Amount id={`out-${i}`} value={r.mTokOut} onChange={(v) => set(i, { mTokOut: v })} />
</Field>
</>
) : (
<>
<Field id={`spend-${i}`} label="Spend, dollars">
<Amount id={`spend-${i}`} value={r.spend} onChange={(v) => set(i, { spend: v })} step={100} />
</Field>
<Field id={`share-${i}`} label="Output share of tokens, %">
<Amount id={`share-${i}`} value={r.share} onChange={(v) => set(i, { share: Math.min(100, v) })} />
</Field>
</>
)}
</XStack>
{rows.length > 1 ? (
<Button size="sm" variant="ghost" self="flex-start" onPress={() => setRows((rs) => rs.filter((_, k) => k !== i))} aria-label={`Remove ${label(usage[i].m)}`}>
<XStack items="center" gap="$1.5">
<Minus size={14} aria-hidden />
<Text fontSize="$2" color="inherit">
Remove
</Text>
</XStack>
</Button>
) : null}
</YStack>
))}
{rows.length < ROWS ? (
<Button size="sm" variant="outline" self="flex-start" rounded="var(--radius-full)" onPress={() => setRows((rs) => [...rs, { model: choices[Math.min(rs.length, choices.length - 1)].id, mTokIn: 100, mTokOut: 10, spend: 1000, share: 10 }])}>
<XStack items="center" gap="$1.5">
<Plus size={14} aria-hidden />
<Text fontSize="$2" fontWeight="500" color="inherit">
Add a model
</Text>
</XStack>
</Button>
) : null}
<YStack gap="$3" pt="$4" borderTopWidth={1} borderColor="var(--border)">
<XStack gap="$3" flexWrap="wrap">
<Field id="people" label="People">
<Amount id="people" value={people} onChange={(v) => setPeople(Math.floor(v))} />
</Field>
<Field id="sku" label="On Hanzo Cloud, moved to">
<Choice id="sku" value={mine.name} onChange={setSku}>
{skus.map((m) => (
<option key={m.name} value={m.name}>
{m.fullName ?? m.name}
</option>
))}
</Choice>
</Field>
</XStack>
{racks.length ? (
<XStack gap="$3" flexWrap="wrap">
<Field id="rack" label="Your GPUs">
<Choice
id="rack"
value={config?.gpu ?? ''}
onChange={(v) => {
setRack(v)
setHourly(null)
}}
>
{racks.map((g) => (
<option key={g.gpu} value={g.gpu}>
{`${g.gpu}, ${g.vram}`}
</option>
))}
</Choice>
</Field>
<Field id="count" label="How many">
<Amount id="count" value={count} onChange={(v) => setCount(Math.max(1, Math.floor(v)))} />
</Field>
<Field id="hour" label="An hour of it, dollars">
<Amount id="hour" value={hour} onChange={setHourly} step={0.01} />
</Field>
</XStack>
) : null}
<Text render="p" fontSize="$1" lineHeight="1.25rem" color="var(--muted-foreground)">
The page opens with an example. Put in your own volumes, people and GPUs; every rate is our price list&apos;s
{doc.updated ? `, as it stood on ${day(doc.updated)}` : ''}.
</Text>
</YStack>
</YStack>
<YStack {...CARD} p={24} $sm={{ p: 28 }} gap={0} minW={0} aria-live="polite">
<Text render="h3" fontSize="$5" fontWeight="600" color="var(--foreground)" pb="$2">
What it costs a month
</Text>
<Total slot="list" name="At list prices" figure={usd(listed)} unit="a month">
{usage.map((u, i) => `${i ? '; ' : ''}${label(u.m)} at ${rate(u.m.pricing.input)} in and ${rate(u.m.pricing.output)} out per million tokens`).join('')}
, the provider&apos;s rate as our price list carries it. Seats on a provider&apos;s own plans are not priced here.
</Total>
<Total slot="cloud" name="Hanzo Cloud" figure={usd(cloud)} unit="a month" saving={saves(cloud, listed)}>
{`The same ${millions(total.input)} tokens in and ${millions(total.output)} out on ${mine.fullName ?? mine.name} at ${rate(at(mine).input)} in and ${rate(at(mine).output)} out per million: ${usd(onCloud)}`}
{seats !== null && team
? `, plus ${people.toLocaleString('en-US')} ${people === 1 ? 'member' : 'members'} on ${team.name} at ${usd(team.priceMonthly ?? 0)} a member a month: ${usd(seats)}. ${team.name} includes usage for each member; every token is priced here anyway.`
: team && team.limits?.maxMembers != null && people > Number(team.limits.maxMembers)
? `. ${team.name} is for up to ${team.limits.maxMembers} members; past that, members are part of Enterprise.`
: '.'}
{kept !== null ? ` Keeping the same models through Hanzo bills them at the rates our model list states: ${usd(kept)} a month.` : ''}
</Total>
<Total slot="gpus" name="Enterprise, on your GPUs" figure={usd(fleet)} unit="a month, plus the agreement" saving={saves(fleet, listed)}>
{`${count} × ${config?.gpu ?? 'GPU'} on all month (${HOURS} hours) at ${usd(hour)} an hour${hourly === null ? ', our price list’s on-demand rate; put in what an hour of your own costs' : ''}. No per-token charge, and members are unlimited. The agreement itself is priced with our team and is not in this figure.`}
{point ? ` These GPUs cost what list prices do at ${millions(point.millions)} tokens a month at your mix, about ${Math.round(point.perSecond).toLocaleString('en-US')} tokens a second around the clock. Whether they serve that is sized with you, against your models and your load.` : ''}
</Total>
</YStack>
</View>
)
}