mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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.
798 lines
29 KiB
TypeScript
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;
|