Files
hanzo.ai/components/pricing/APIPricing.tsx
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

798 lines
29 KiB
TypeScript

'use client'
import type { ViewProps } from '@hanzo/gui'
import { useState, useMemo, type ReactNode } from "react";
import Link from "next/link";
import { Search, ChevronDown, Star, Filter } from "lucide-react";
import { Input } from '@hanzo/gui'
import { Picker, Text, View, YStack, XStack } from '@hanzo/ui'
import { Grid } from '@hanzo/ui/grid'
import { Head, Panel, Rates, Act, Actions, Wide } from './kit'
import { Line, type Tone } from '@/components/type'
import { KEYS } from '@/components/home/nav-data'
import { keep } from '@hanzo/build/mind'
import { pricedTools } from './rates'
import { usePricing } from '@/lib/api/use'
import { Ready } from '@/components/api/state'
import type { Lab, Own, Pricing, Summary, Tool } from '@/lib/api/read'
import { mix } from '@hanzo/app/lib/mix'
import { distinct } from '@/lib/models/distinct'
interface HanzoModel {
name: string;
fullName: string;
description: string;
features: string[];
tier: string;
// Optional, because the catalog is: an embedding, rerank, image, audio or
// video model states no parameter count and no architecture. Declaring it
// required did not make it present — it only moved the discovery from the
// compiler to a reader's blank screen.
specs?: { params?: string; arch?: string };
pricing: { input?: number; output?: number; cacheRead?: number | null; cacheWrite?: number | null; perUnit?: number };
pricingUnit?: string;
endpoint?: string;
/** The maker, as the price list's `owned_by` names it. */
owner?: string;
}
type ThirdPartyModel = Lab;
/** What the tables draw: the price list, its rows shaped for the cards. */
interface PricingResponse {
updated?: string;
summary?: Summary;
hanzoModels: HanzoModel[];
thirdPartyModels: ThirdPartyModel[];
tools: Tool[];
}
/** A price-list row of ours → the Hanzo model card shape. */
function apiToHanzo(m: Own): HanzoModel {
return {
name: m.name,
fullName: m.fullName || m.name,
description: m.description || "",
features: m.features || [],
tier: m.tier || "pro",
specs: { params: m.specs?.params || "", arch: m.specs?.arch || "" },
pricing: m.pricing ?? { input: null, output: null, cacheRead: null, cacheWrite: null },
pricingUnit: m.pricingUnit,
endpoint: m.endpoint,
owner: m.owned_by,
};
}
/**
* The price list's rows → the Hanzo cards, one per model. The list names Kai
* twice, as `kai` and as its canonical id `hanzo/kai`, because the gateway bills
* each id at its own row; `distinct` keeps the one card. Routing policies
* (`router:*`) are not models and draw no card.
*/
const hanzoCards = (rows: Own[]) =>
distinct(
rows.filter((m) => !m.name.startsWith("router:")),
(m) => m.name,
).map(apiToHanzo);
type ModelKind = "chat" | "decision" | "embedding" | "rerank" | "image" | "audio" | "video";
/** Classify a model into a pricing section from its endpoint / unit / id. */
function kindOf(m: HanzoModel): ModelKind {
const id = (m.name || "").toLowerCase();
const ep = (m.endpoint || "").toLowerCase();
if (ep.includes("/decisions")) return "decision";
if (ep.includes("/embeddings") || id.includes("embedding")) return "embedding";
if (ep.includes("/rerank") || id.includes("rerank")) return "rerank";
if (ep.includes("/videos") || m.pricingUnit === "clip" || m.pricingUnit === "video" || id.includes("video")) return "video";
if (ep.includes("/images") || m.pricingUnit === "image" || id.includes("image")) return "image";
if (ep.includes("/audio") || ["asr", "tts", "voice", "speech", "foley", "music", "transcription"].some((k) => id.includes(k))) return "audio";
return "chat";
}
/**
* Whether this model has a chat: only a model you can chat with does — no routing
* policy, no guard, nothing that embeds, reranks, draws, speaks or films. The
* button remembers the model as the chat default and opens /chat on this site.
*/
function chats(m: HanzoModel): boolean {
return kindOf(m) === "chat" && !/^router[:-]|guard|moderation/.test(m.name.toLowerCase());
}
/** Enso = flagship frontier family (Flash / Pro / Ultra). */
const isEnso = (m: HanzoModel) =>
(m.name || "").toLowerCase().startsWith("enso") || (m.fullName || "").toLowerCase().startsWith("enso");
const ENSO_ORDER: Record<string, number> = { "enso-flash": 0, enso: 1, "enso-pro": 1, "enso-ultra": 2 };
const fmt = (val: number | null) => {
if (val == null) return "N/A";
if (val === 0) return "Free";
return `$${val} / MTok`;
};
/**
* The badge every model row wears.
*
* It was THREE components — TierBadge, FreeBadge, FeaturedBadge — plus a fourth
* copy inline for "Default", all four spelling the same nine classes. What
* actually varies is the tint and the ink, so that is what the table holds.
*/
const TINT: Record<string, { tint: number; tone: Tone; edge?: ViewProps['borderColor'] }> = {
'ultra max': { tint: 0.15, tone: 'plain', edge: '$rim' },
ultra: { tint: 0.1, tone: 'faint' },
'pro max': { tint: 0.1, tone: 'dim' },
pro: { tint: 0.1, tone: 'dim' },
}
function Tag({ label, icon, tier }: { label: string; icon?: ReactNode; tier?: string }) {
const s = TINT[tier ?? ''] ?? TINT.pro
return (
<XStack
display="inline-flex"
items="center"
gap="$1"
px="$2"
py={2}
rounded={9999}
borderWidth={1}
borderColor={s.edge ?? '$edge'}
bg={s.tint >= 0.15 ? '$raised' : '$hover'}
>
{icon}
<Line px={10} tone={s.tone} weight="700" caps>
{label}
</Line>
</XStack>
)
}
const UNIT_LABELS: Record<string, string> = {
image: "/ image",
step: "/ step",
minute: "/ minute",
};
const fmtUnit = (val: number | undefined, unit?: string) => {
if (val == null) return "N/A";
return `$${val} ${UNIT_LABELS[unit || ""] || ""}`.trim();
};
const MODELS_PER_PAGE = 50;
/** The price list as the tables draw it: every row from the one read, the tool rows priced by its card. */
const shape = (doc: Pricing): PricingResponse => ({
updated: doc.updated,
summary: doc.summary,
hanzoModels: hanzoCards(doc.hanzoModels),
thirdPartyModels: doc.thirdPartyModels.filter((m) => !m.id.startsWith("router:")),
tools: pricedTools(doc.tools, doc.tariff),
});
/**
* The six kinds, and the sentence each one is introduced by.
*
* They were six copies of the same heading/lede/list block. What differs is
* three strings, so three strings is what this holds; `endpoint` is set in the
* mono face, which is why the lede is markup rather than a sentence.
*/
const KINDS: { kind: ModelKind; title: string; lede: ReactNode }[] = [
{
kind: 'embedding',
title: 'Embedding Models',
lede: <>High-quality text embeddings via <Mono>/v1/embeddings</Mono></>,
},
{
kind: 'rerank',
title: 'Reranker Models',
lede: <>Improve retrieval quality via <Mono>/v1/rerank</Mono></>,
},
{
kind: 'image',
title: 'Image Generation',
lede: <>FLUX, Stable Diffusion, and more via <Mono>/v1/images/generations</Mono></>,
},
{
kind: 'audio',
title: 'Audio & Speech',
lede: <>Speech-to-text, text-to-speech, and audio generation via <Mono>/v1/audio/*</Mono></>,
},
{
kind: 'video',
title: 'Video Generation',
lede: <>Text-to-video via <Mono>/v1/videos/generations</Mono></>,
},
]
/** An endpoint, a model id, a code sample — set in the mono face. */
function Mono({ children }: { children: ReactNode }) {
return (
<Line render="code" tone="soft" fontFamily="$mono" lineHeight="unset">
{children}
</Line>
)
}
/** A link out of a section, on the heading's own line. @hanzo/design's base
* draws a link undecorated, so the pill is all it wears. */
function Aside({ href, children }: { href: string; children: ReactNode }) {
return (
<Link href={href}>
<XStack
display="inline-flex"
items="center"
minH={36}
px="$4"
rounded={9999}
borderWidth={1}
borderColor="$edge"
bg="$raised"
hoverStyle={{
bg: '$raised',
}}
>
<Line size="sm" weight="500">
{children}
</Line>
</XStack>
</Link>
)
}
/** One Hanzo model: what it is on the left, how to start it on the right. */
function ModelCard({ model }: { model: HanzoModel }) {
const open = chats(model)
// What the row prices decides the cells, not whether it names a unit: a
// decision row names `token` and prices per million tokens, and reading any
// unit as per-call printed "Price N/A" against Kai. A cache rate the row does
// not state is not offered, so it gets no cell rather than an "N/A".
const p = model.pricing
const rates = p.perUnit != null
? [{ label: 'Price', value: fmtUnit(p.perUnit, model.pricingUnit) }]
: [
{ label: 'Input', value: fmt(p.input ?? null) },
{ label: 'Output', value: fmt(p.output ?? null) },
...(p.cacheWrite != null ? [{ label: 'Cache Write', value: fmt(p.cacheWrite) }] : []),
...(p.cacheRead != null ? [{ label: 'Cache Read', value: fmt(p.cacheRead) }] : []),
]
return (
<Panel marginBottom="$4">
{/* A phone stacks the start button under what the model is: beside it,
a long `Start Using <id>` label left the description a 43px column. */}
<XStack justify="space-between" items="flex-start" mb="$4" $max-sm={{ flexDirection: 'column', items: 'stretch' }}>
<YStack flex={1} $max-sm={{ flexBasis: 'auto' }}>
<XStack items="center" flexWrap="wrap" gap="$3" mb="$2">
<Line render="h3" size="xl" weight="600">
{model.fullName || model.name}
</Line>
<Tag label={model.tier} tier={model.tier} />
</XStack>
{model.description ? (
<Line render="p" tone="muted" marginBottom="$3">
{model.description}
</Line>
) : null}
{model.specs?.params || model.specs?.arch ? (
<XStack items="center" flexWrap="wrap" gap="$4" mb="$3">
{model.specs?.params ? (
<Line size="xs" tone="muted">
Parameters: <Line size="xs" tone="soft">{model.specs.params}</Line>
</Line>
) : null}
{model.specs?.arch ? (
<Line size="xs" tone="muted">
Architecture: <Line size="xs" tone="soft">{model.specs.arch}</Line>
</Line>
) : null}
</XStack>
) : null}
{model.features ? (
<YStack gap={6} mb="$4">
{model.features.map((feature, i) => (
<XStack key={i} items="center" gap={10}>
<YStack width={6} height={6} rounded={9999} bg="var(--primary)" />
<Line size="sm" tone="muted">
{feature}
</Line>
</XStack>
))}
</YStack>
) : null}
</YStack>
{/* SHRINKS when the row overflows, and only then. flexShrink={0} kept the
button at the width of its label, and a label is `Start Using ` plus a
model id, which walks off the side where `overflow-x: clip` puts it out
of reach. At 1440 the button is the width it always was. */}
{open ? (
<YStack ml="$5" shrink={1} minW={0} $max-sm={{ ml: 0, self: 'flex-start', maxW: '100%' }}>
<Act label={`Start Using ${model.name}`} href="/chat" onPress={() => keep({ model: model.name })} solid size="default" />
</YStack>
) : null}
</XStack>
<XStack
flexWrap="wrap"
gap="$4"
p="$4"
rounded="var(--radius-lg)"
bg="$sunken"
>
{rates.map((r) => (
<YStack key={r.label} minW={120}>
<Line tone="muted" marginBottom="$1">
{r.label}
</Line>
<Line size="lg" weight="500">
{r.value}
</Line>
</YStack>
))}
{model.pricingUnit && model.endpoint ? (
<YStack ml="auto" justify="center">
<Mono>{model.endpoint}</Mono>
</YStack>
) : null}
</XStack>
</Panel>
)
}
/**
* Every model's rate, read from the price list when the page opens (GET
* /v1/pricing, the same read the rate card above draws from). Until it answers
* the section says so, and when it does not answer it says that instead of
* printing an older list.
*/
const APIPricing = () => {
const reading = usePricing();
return (
<Ready reading={reading} what="the model rates">
{(doc) => <Rows data={shape(doc)} />}
</Ready>
);
};
function Rows({ data }: { data: PricingResponse }) {
// Third-party filters
const [search, setSearch] = useState("");
const [selectedProvider, setSelectedProvider] = useState<string>("all");
const [showFreeOnly, setShowFreeOnly] = useState(false);
const [showCount, setShowCount] = useState(MODELS_PER_PAGE);
// Derive providers list and filter models
const providers = useMemo(() => {
if (!data?.thirdPartyModels) return [];
const set = new Set(data.thirdPartyModels.map((m) => m.provider));
return Array.from(set).sort();
}, [data]);
const filteredModels = useMemo(() => {
if (!data?.thirdPartyModels) return [];
let models = data.thirdPartyModels;
if (selectedProvider !== "all") {
models = models.filter((m) => m.provider === selectedProvider);
}
if (showFreeOnly) {
models = models.filter((m) => m.isFree);
}
if (search.trim()) {
const q = search.toLowerCase();
models = models.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.id?.toLowerCase().includes(q) ||
m.provider?.toLowerCase().includes(q)
);
}
return models;
}, [data, selectedProvider, showFreeOnly, search]);
const featuredModels = useMemo(() => {
if (!data?.thirdPartyModels) return [];
return data.thirdPartyModels.filter((m) => m.featured);
}, [data]);
// The tool rows the card prices take the card's number (`shape`), so a web
// rate a SuperAdmin edits moves here in the same read that moves the rate table.
const { hanzoModels, tools } = data;
const visibleModels = filteredModels.slice(0, showCount);
// Enso = flagship family, surfaced first. Everything else grouped by kind
// (endpoint / unit / id) so chat, embedding, rerank, image, audio, and
// video models all render regardless of which fields the backend populates.
const ensoModels = [...hanzoModels.filter(isEnso)].sort(
(a, b) => (ENSO_ORDER[a.name.toLowerCase()] ?? 1) - (ENSO_ORDER[b.name.toLowerCase()] ?? 1)
);
// Kai is the decision family: Hanzo's own rows at /v1/decisions. Jev answers
// there too and is TypeSafe's, so the third-party table carries it.
const kaiModels = hanzoModels.filter((m) => kindOf(m) === "decision" && m.owner === "hanzo");
const rest = hanzoModels.filter((m) => !isEnso(m) && kindOf(m) !== "decision");
const llmModels = rest.filter((m) => kindOf(m) === "chat");
return (
<Wide marginBottom="$10">
{/* Hanzo Enso — flagship frontier family (Flash / Pro / Ultra) */}
<YStack mb="$10">
<Head
title="Hanzo Enso"
lede="Flagship frontier models — Flash, Pro, and Ultra. Live per-token retail, one API key."
aside={<Aside href="/enso">Explore Enso</Aside>}
/>
{ensoModels.length > 0 ? (
ensoModels.map((model) => <ModelCard key={model.name} model={model} />)
) : (
<Panel>
<XStack items="center" flexWrap="wrap" gap="$3" mb="$2">
<Line render="h3" size="xl" weight="600">
Enso Flash · Pro · Ultra
</Line>
<Tag label="Default" />
</XStack>
<Line render="p" tone="muted">
Enso is the default for every new chat and API request — Flash, Pro, and Ultra behind one
endpoint, with the Enso router picking the right tier per task. Live per-token retail pricing
appears here automatically — served straight from <Mono>api.hanzo.ai/v1/pricing</Mono>,
no placeholder numbers.
</Line>
<YStack mt="$4" items="flex-start">
<Act
label="Start with Enso"
href={KEYS}
aside
size="default"
/>
</YStack>
</Panel>
)}
</YStack>
{/* Zen Models Section */}
<YStack mb="$10">
<Head
title="Zen Models"
lede={
<>
{rest.length} models across LLM, embedding, reranker, image, audio, and video
{data.updated ? (
<Line size="xs" tone="muted" marginLeft="$2">
Updated{' '}
{/* A fixed locale and UTC, so the build and every visitor's browser
print the same day and hydration matches. */}
{new Date(data.updated).toLocaleDateString('en-US', { timeZone: 'UTC', year: 'numeric', month: 'short', day: 'numeric' })}
</Line>
) : null}
</>
}
aside={<Aside href="/zen/models">Full Model Catalog</Aside>}
/>
{/* Tier legend */}
<XStack flexWrap="wrap" gap="$3" mb="$6">
{Object.keys(TINT).map((tier) => (
<Tag key={tier} label={tier} tier={tier} />
))}
</XStack>
{/* LLM Models */}
<YStack mb="$6">
{llmModels.map((model) => (
<ModelCard key={model.name} model={model} />
))}
</YStack>
<Line render="p" size="sm" tone="muted" marginBottom="$5">
All Zen LLMs available via OpenAI-compatible API at <Mono>api.hanzo.ai/v1/chat/completions</Mono>
</Line>
{KINDS.map(({ kind, title, lede }) => {
const models = rest.filter((m) => kindOf(m) === kind)
if (!models.length) return null
return (
<YStack key={kind} mb="$6">
<Line render="h3" size="x2" weight="700" marginBottom="$2">
{title}
</Line>
<Line render="p" tone="muted" marginBottom="$4">
{lede}
</Line>
{models.map((model) => (
<ModelCard key={model.name} model={model} />
))}
</YStack>
)
})}
<Line render="p" size="sm" tone="muted" marginBottom="$6">
Prompt caching pricing is for the standard 5-minute TTL. A longer one
costs more.
</Line>
<Actions>
<Act label="Try Hanzo" href="/login" solid />
</Actions>
</YStack>
{/* Hanzo Kai — the decision model, priced per input token */}
{kaiModels.length > 0 ? (
<YStack mb="$10">
<Head
title="Hanzo Kai"
lede={<>The decision model: a typed answer to each question, via <Mono>/v1/decisions</Mono>.</>}
aside={<Aside href="/kai">Explore Kai</Aside>}
/>
{kaiModels.map((model) => (
<ModelCard key={model.name} model={model} />
))}
</YStack>
) : null}
{/* Tools Section */}
{tools && tools.length > 0 ? (
<YStack mb="$10">
<Line render="h2" size="x2" weight="700" marginBottom="$6">
Explore pricing for tools
</Line>
<YStack mb="$6">
<Grid columns={{ min: 280, max: 3 }} gap={24}>
{tools.map((tool) => (
<Panel key={tool.name}>
<Line render="h3" size="xl" weight="600" marginBottom="$2">
{tool.name}
</Line>
<YStack items="flex-end">
<Line tone="muted">{tool.unit}</Line>
<Line weight="500">${tool.price}</Line>
</YStack>
</Panel>
))}
</Grid>
</YStack>
<Line render="p" size="sm" tone="muted" marginBottom="$6">
*Does not include input and output tokens required to process requests
</Line>
<Actions>
<Act label="Try Hanzo" href="/login" solid />
</Actions>
</YStack>
) : null}
{/* Featured Third-Party Models */}
{featuredModels.length > 0 ? (
<YStack mb="$10">
<Line render="h2" size="x2" weight="700" marginBottom="$2">
Featured Third-Party Models
</Line>
<Line render="p" tone="muted" marginBottom="$6">
Top models from leading AI providers — all accessible through one Hanzo API key
</Line>
<Grid columns={{ min: 320, max: 2 }} gap={16}>
{featuredModels.map((model) => (
<Panel key={model.id || model.name} pad={20}>
{/* The pill wraps under the name when the two do not fit one
line, and the name wraps inside the card when it alone does not. */}
<XStack items="flex-start" justify="space-between" flexWrap="wrap" gap="$2" mb="$3">
<YStack shrink={1} minW={0}>
<XStack items="center" flexWrap="wrap" gap="$2" mb="$1">
<Line render="h3" weight="600">
{model.name}
</Line>
{model.isFree ? <Tag label="Free" /> : null}
</XStack>
<Line size="xs" tone="muted">
{model.provider}
</Line>
</YStack>
<Tag label="Featured" icon={<Star size={10} />} tier="ultra" />
</XStack>
{model.features ? (
// A block box with inline runs, not a flex row: these are a
// phrase about one model, and flex would blockify each into
// its own line. Each run is an inline-block, so a line breaks
// between features and never inside "text+image+file->text".
<YStack display="block" mb="$3">
{model.features.map((f, i) => (
<Line key={i} size="xs" tone="muted" marginRight="$3" display="inline-block">
{f}
</Line>
))}
</YStack>
) : null}
<XStack
gap="$3"
p="$3"
rounded="var(--radius-lg)"
bg="$sunken"
>
{[
{ label: 'Input', value: fmt(model.pricing.input) },
{ label: 'Output', value: fmt(model.pricing.output) },
].map((r) => (
<YStack key={r.label} flex={1}>
<Line size="xs" tone="muted">
{r.label}
</Line>
<Line size="sm" weight="500">
{r.value}
</Line>
</YStack>
))}
</XStack>
</Panel>
))}
</Grid>
</YStack>
) : null}
{/* All Third-Party Models — Searchable Table */}
{data.thirdPartyModels && data.thirdPartyModels.length > 0 ? (
<YStack mb="$10">
<Line render="h2" size="x2" weight="700" marginBottom="$2">
All Third-Party Models
</Line>
<Line render="p" tone="muted" marginBottom="$5">
{data.thirdPartyModels.length} models from {providers.length} providers — dynamically
detected and priced
</Line>
{/* Filters */}
<XStack flexWrap="wrap" items="center" gap="$3" mb="$5">
<YStack position="relative" flex={1} minW={200} maxW={448}>
<View render={<Search size={16} color="var(--muted-foreground)" />} position="absolute" l={12} t="50%" y="-50%" />
{/* gui's Input paints `::placeholder` from `placeholderTextColor`
and nothing else: without it the hint takes the typed ink and
an empty field reads as filled. */}
<Input
unstyled
type="text"
placeholder="Search models..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setShowCount(MODELS_PER_PAGE)
}}
{...FIELD}
width="100%"
pl={36}
pr={16}
placeholderTextColor="$placeholderColor"
focusVisibleStyle={{ outlineWidth: 2 }}
/>
</YStack>
<YStack position="relative">
<View render={<Filter size={14} color="var(--muted-foreground)" />} position="absolute" l={12} t="50%" y="-50%" z={1} />
<Picker
value={selectedProvider}
onChange={(e) => {
setSelectedProvider(e.target.value)
setShowCount(MODELS_PER_PAGE)
}}
{...FIELD}
width="auto"
pl={32}
pr={32}
$platform-web={{ appearance: 'none' }}
>
<option value="all">All Providers ({providers.length})</option>
{providers.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</Picker>
<View render={<ChevronDown size={14} color="var(--muted-foreground)" />} position="absolute" r={8} t="50%" y="-50%" pointerEvents="none" />
</YStack>
<Text
render={
<button
type="button"
onClick={() => {
setShowFreeOnly(!showFreeOnly)
setShowCount(MODELS_PER_PAGE)
}}
aria-pressed={showFreeOnly}
/>
}
{...FIELD}
cursor="pointer"
fontWeight="500"
px={16}
color={showFreeOnly ? 'var(--foreground)' : 'var(--muted-foreground)'}
bg={showFreeOnly ? mix('var(--primary)', 10) : 'var(--secondary)'}
>
Free Only
</Text>
<Line size="xs" tone="muted" marginLeft="auto">
{filteredModels.length} model{filteredModels.length !== 1 ? 's' : ''}
</Line>
</XStack>
<Rates
columns={[
{ head: 'Model' },
{ head: 'Provider', wide: true, tone: 'muted' },
{ head: 'Context', wide: true, tone: 'muted' },
{ head: 'Input', align: 'right' },
{ head: 'Output', align: 'right' },
]}
rows={visibleModels.map((model) => [
<XStack key="n" items="center" flexWrap="wrap" gap="$2">
<Line size="sm" weight="500">
{model.name}
</Line>
{model.featured ? <Tag label="Featured" icon={<Star size={10} />} tier="ultra" /> : null}
{model.isFree ? <Tag label="Free" /> : null}
</XStack>,
model.provider,
context(model.contextWindow),
<Line key="i" size="sm" tone={model.isFree ? 'soft' : 'plain'}>
{fmt(model.pricing.input)}
</Line>,
<Line key="o" size="sm" tone={model.isFree ? 'soft' : 'plain'}>
{fmt(model.pricing.output)}
</Line>,
])}
/>
{filteredModels.length > showCount ? (
<XStack justify="center" mt="$5">
<Text
render={<button type="button" onClick={() => setShowCount((c) => c + MODELS_PER_PAGE)} />}
{...FIELD}
rounded={9999}
cursor="pointer"
fontWeight="500"
px={24}
bg="transparent"
color={mix('var(--foreground)', 80)}
>
Show more ({filteredModels.length - showCount} remaining)
</Text>
</XStack>
) : null}
{filteredModels.length === 0 ? (
<YStack py="$8">
<Line tone="muted" align="center">
No models match your filters.
</Line>
</YStack>
) : null}
<Line render="p" size="sm" tone="muted" marginTop="$5">
Third-party model pricing includes a passthrough markup. Prices update automatically every 6 hours.
All models accessible via <Mono>api.hanzo.ai/v1/chat/completions</Mono> using
the provider-prefixed model ID (e.g. <Mono>anthropic/claude-sonnet-4.6</Mono>).
</Line>
</YStack>
) : null}
</Wide>
);
}
/** A context window, in thousands. */
const context = (n: number) => (n ? `${Math.round(n / 1000)}K` : 'N/A')
/** The one control surface the filters share. */
const FIELD = {
minH: 36,
rounded: 'var(--radius-lg)',
borderWidth: 1,
borderColor: 'var(--border)',
bg: 'var(--secondary)',
color: 'var(--foreground)',
fontSize: '$2',
py: 8,
} as const
export default APIPricing;