mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
#38 offered every priced model, and with them the catalog's twenty aliases ("~anthropic/claude-opus-latest" and the like), each a pointer at whichever model is a lab's latest, listed under a provider of its own ("~anthropic"). They repeated rows already offered and sorted as "~anthropic", "~deepseek" groups at the head of the picker. A `~` row is now left out, as a `:batch` row already is.
370 lines
19 KiB
TypeScript
370 lines
19 KiB
TypeScript
'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, at its list
|
||
* price, and itself. A `:batch` row is the same model discounted, and a `~` row
|
||
* ("~anthropic/claude-opus-latest") is an alias of whichever model is the lab's
|
||
* latest, listed under a "~anthropic" provider of its own; both repeat a row
|
||
* that is already offered.
|
||
*/
|
||
const priced = (m: Lab) => !m.isFree && m.pricing.input > 0 && m.pricing.output > 0 && !m.id.endsWith(':batch') && !m.id.startsWith('~')
|
||
|
||
/**
|
||
* 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'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's rate as our price list carries it. Seats on a provider'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>
|
||
)
|
||
}
|