mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
462 lines
18 KiB
TypeScript
462 lines
18 KiB
TypeScript
/**
|
||
* /models/<lab>/<model> — one model, as a card.
|
||
*
|
||
* Read top to bottom: the hero in the family pages' frame (components/family/
|
||
* Hero: a chip, the name, one line, two actions), the links row (the model's
|
||
* public repositories and this site's boards for it, lib/models/links), the
|
||
* facts as a grid of short labelled cells, the call in four languages
|
||
* (lib/models/examples, by the endpoint the model answers at), the price in
|
||
* one line, and for Kai its limits. No paragraph restates a cell.
|
||
*
|
||
* A Kai page sets what ships today beside the Kai-1.7 target, each in its own
|
||
* column under its own label, so no target reads as measured. The rate is
|
||
* read when the page opens (components/models/Cost); the export states none.
|
||
*/
|
||
|
||
import type { Metadata } from 'next'
|
||
import type { ReactNode } from 'react'
|
||
import { DOCS, KEYS } from '@/components/home/nav-data'
|
||
import { Hero } from '@/components/family/Hero'
|
||
import { Target } from '@/components/kai/figures'
|
||
import { Crumbs, Tile, DIM, SM } from '@/components/models/kit'
|
||
import { Cost } from '@/components/models/Cost'
|
||
import { Links } from '@/components/models/Links'
|
||
import { Samples } from '@/components/models/Samples'
|
||
import {
|
||
canonicalOrg,
|
||
context,
|
||
getOrgAndSlug,
|
||
orgDisplayName,
|
||
formatContext,
|
||
modelPagePath,
|
||
type ModelData,
|
||
} from '@/lib/models'
|
||
import { examples } from '@/lib/models/examples'
|
||
import { links } from '@/lib/models/links'
|
||
import { fetchModels } from '@/lib/models/served'
|
||
import { Box, XStack, YStack } from '@/components/ui'
|
||
import { Action, Lede, Line, More, Title } from '@hanzo/ui/marketing'
|
||
import { Grid } from '@hanzo/ui/grid'
|
||
import { Band } from '@/components/marketing/band'
|
||
import { Code } from '@/components/code'
|
||
|
||
export const revalidate = 3600
|
||
|
||
interface Props {
|
||
params: Promise<{ org: string; model: string }>
|
||
}
|
||
|
||
export async function generateStaticParams() {
|
||
const data = await fetchModels()
|
||
return data.models
|
||
.map((m) => {
|
||
const { org, slug } = getOrgAndSlug(m.id)
|
||
return { org: canonicalOrg(org), model: slug }
|
||
})
|
||
}
|
||
|
||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||
// Next hands a segment over percent-encoded: `claude-opus-4.8:batch` arrives as
|
||
// `claude-opus-4.8%3Abatch`, and compared raw it matched no model, so every
|
||
// `:batch` page rendered "Model not found".
|
||
const { org, model: segment } = await params
|
||
const modelSlug = decodeURIComponent(segment)
|
||
const data = await fetchModels()
|
||
const model = data.models.find((m) => {
|
||
const { org: mOrg, slug: mSlug } = getOrgAndSlug(m.id)
|
||
return (canonicalOrg(mOrg) === org || mOrg === org) && mSlug === modelSlug
|
||
})
|
||
|
||
if (!model) {
|
||
return { title: 'Model Not Found — Hanzo AI' }
|
||
}
|
||
|
||
const desc = summary(model)
|
||
|
||
return {
|
||
title: `${model.name} — Use via Hanzo AI API`,
|
||
description: desc,
|
||
openGraph: {
|
||
title: `${model.name} — Hanzo AI`,
|
||
description: desc,
|
||
url: `https://hanzo.ai${modelPagePath(model.id)}`,
|
||
siteName: 'Hanzo AI',
|
||
type: 'website',
|
||
},
|
||
twitter: {
|
||
card: 'summary_large_image',
|
||
title: `${model.name} — Hanzo AI`,
|
||
description: desc,
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The one sentence this page says about itself.
|
||
*
|
||
* It was computed inside generateMetadata and nowhere else, so the JSON-LD had
|
||
* to reach for `model.description` — a field that is often absent, because the
|
||
* catalog only writes one where it has something to say. A SoftwareApplication
|
||
* with no description is what a search engine got.
|
||
*
|
||
* The catalog's own words win when there are any; otherwise the model is
|
||
* described by what a reader came here to learn — that it is callable through
|
||
* one API with one key.
|
||
*/
|
||
function summary(model: ModelData): string {
|
||
const ctx = context(model)
|
||
// A catalog description is markdown, and a link clipped mid-URL is a broken
|
||
// link in the meta tag, the JSON-LD and llms.txt: the words, not the links.
|
||
const d = model.description?.replace(/\[([^\]]*)\]\(<?[^)>]*>?\)/g, '$1').trim()
|
||
if (d) {
|
||
const maxLen = 75
|
||
const clipped = d.length > maxLen ? `${d.slice(0, maxLen).trimEnd()}...` : d
|
||
return `${clipped} ${call(model)}${ctx ? ` Context: ${formatContext(ctx)}.` : ''}`
|
||
}
|
||
return `Use ${model.name} through one Hanzo API key. ${call(model)}`
|
||
}
|
||
|
||
/** A model id that names its free variant: `google/gemma-4-31b-it:free`, `zen-free`. */
|
||
const FREE = /[:-]free$/
|
||
|
||
function buildJsonLd(model: ModelData) {
|
||
const ctx = context(model)
|
||
return {
|
||
'@context': 'https://schema.org',
|
||
'@type': 'SoftwareApplication',
|
||
name: model.name,
|
||
description: summary(model),
|
||
url: `https://hanzo.ai${modelPagePath(model.id)}`,
|
||
applicationCategory: 'AIApplication',
|
||
provider: {
|
||
'@type': 'Organization',
|
||
name: 'Hanzo AI',
|
||
url: 'https://hanzo.ai',
|
||
},
|
||
// A price only where the build knows it: a free variant costs nothing. Every
|
||
// other rate is read from GET /v1/pricing when the page opens, so the
|
||
// structured data states none rather than a $0 the bill would contradict.
|
||
...(FREE.test(model.id)
|
||
? { offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD', description: 'Free through the Hanzo AI API' } }
|
||
: {}),
|
||
...(ctx ? { featureList: [`${formatContext(ctx)} context window`] } : {}),
|
||
}
|
||
}
|
||
|
||
/** Where the page says a model is called. */
|
||
const call = (m: ModelData) => (m.endpoint === '/v1/decisions' ? 'Call it at POST /v1/decisions.' : "Call via Hanzo's OpenAI-compatible API.")
|
||
|
||
/** The ground the related band lifts to. */
|
||
const LIFT = 'color-mix(in oklab, var(--secondary) 10%, transparent)'
|
||
|
||
/** A section's heading on this page, one rung under the hero's. */
|
||
const COLUMN = {
|
||
render: 'h2',
|
||
display: 'block',
|
||
fontSize: '$4',
|
||
lineHeight: 'calc(1.75 / 1.125)',
|
||
fontWeight: '600',
|
||
color: 'var(--text-primary)',
|
||
} as const
|
||
|
||
/** One labelled fact. `mono` sets an id or a call in the code face. */
|
||
interface Fact {
|
||
label: string
|
||
value: ReactNode
|
||
mono?: boolean
|
||
}
|
||
|
||
/**
|
||
* Facts as a grid of cells on a hairline: the label small and quiet, the value
|
||
* in ink. Two across from 480px, so a value has room for a sentence's worth.
|
||
*/
|
||
function Facts({ title, tag, rows }: { title: string; tag?: ReactNode; rows: readonly Fact[] }) {
|
||
return (
|
||
<YStack gap="$3" data-facts={title} minW={0}>
|
||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||
<Line {...COLUMN}>{title}</Line>
|
||
{tag}
|
||
</XStack>
|
||
<Grid columns={{ min: 200, max: 2 }} gap={1} bg="var(--border)" borderWidth={1} borderColor="var(--border)" rounded="var(--radius-xl)" overflow="hidden">
|
||
{rows.map((r, i) => (
|
||
// An odd last cell takes the row, so no empty cell shows the hairline's ground.
|
||
<YStack key={r.label} gap={4} px="$4" py="$3.5" bg="$background" {...(i === rows.length - 1 && rows.length % 2 ? { '$platform-web': { gridColumn: '1 / -1' } } : {})}>
|
||
<Line fontSize="$1" lineHeight="$1" fontWeight="500" color={DIM} $platform-web={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||
{r.label}
|
||
</Line>
|
||
<Line {...SM} fontWeight="600" color="$ink" {...(r.mono ? { fontFamily: '$mono' } : {})} $platform-web={{ overflowWrap: 'anywhere' }}>
|
||
{r.value}
|
||
</Line>
|
||
</YStack>
|
||
))}
|
||
</Grid>
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* What each Kai id answers from, and its pin (universe
|
||
* charts/app/values/hanzo/{kai,kai-1-4,kai-1-5}.yaml). `hanzo/kai` is `kai`.
|
||
*/
|
||
const CHECKPOINT: Record<string, string> = {
|
||
kai: 'kai-1.3 (pin @551f1990…)',
|
||
'kai-1.4': 'kai-1.4 (pin @4a5fdb8c…)',
|
||
'kai-1.5': 'kai-1.5 (pin @b8827bb0…)',
|
||
}
|
||
|
||
/** What ships today on a Kai id. The latency is production's, measured on the default id. */
|
||
function shipping(model: ModelData): Fact[] {
|
||
const id = model.id.replace(/^hanzo\//, '')
|
||
return [
|
||
{ label: 'Model ID', value: model.id, mono: true },
|
||
{ label: 'Checkpoint', value: CHECKPOINT[id] ?? id, mono: true },
|
||
{ label: 'Parameters', value: '~850M (multimodal decision architecture)' },
|
||
...(id === 'kai' ? [{ label: 'Latency (GPU)', value: '<1 ms p50 (one question, CUDA bf16)' }] : []),
|
||
{ label: 'Context reach', value: '32,768 tokens (YaRN; trained: 1,024)' },
|
||
{ label: 'Modality', value: 'Text and structured state' },
|
||
{ label: 'Output tokens', value: '0 (typed probability distribution)' },
|
||
{ label: 'Endpoint', value: 'POST /v1/decisions', mono: true },
|
||
{ label: 'Architecture', value: 'Proprietary Decision Transformer + factorized cross-attention' },
|
||
{ label: 'Safety qualification', value: 'Not yet qualified: a sealed evaluation is being authored' },
|
||
{ label: 'Price', value: <Cost id={model.id} lab={false} /> },
|
||
]
|
||
}
|
||
|
||
/** The Kai-1.7 multimodal design's figures: targets, not measurements. */
|
||
const TARGETS: readonly Fact[] = [
|
||
{ label: 'Parameters', value: '~850M (text, vision, audio, video)' },
|
||
{ label: 'Resident memory', value: 'Under 2.0 GiB' },
|
||
{ label: 'Text latency', value: '<1 ms p50 (GPU), multi-pass adaptive refinement' },
|
||
{ label: 'Image latency', value: '360–400 ms (image + text state)' },
|
||
{ label: 'Encoders', value: 'Vision · Audio · Video' },
|
||
{ label: 'Reasoning', value: 'reasoning: "auto" | "fast" | "deep"', mono: true },
|
||
]
|
||
|
||
/** What Kai does not do today. */
|
||
const LIMITS: readonly string[] = [
|
||
'Reads text and structured state. Images, audio and video are the Kai-1.7 target.',
|
||
'Reaches 32,768 tokens by position; accuracy at that length is qualified for kai-1.4 and kai-1.5 only.',
|
||
'At most 100 questions a request. More questions, or a state past 32,768 tokens, is refused with 422.',
|
||
'The reasoning field is the Kai-1.7 target: the API refuses it today (400).',
|
||
]
|
||
|
||
/** Any other model's facts, from its catalogue record. */
|
||
function specs(model: ModelData): Fact[] {
|
||
const ctx = context(model)
|
||
const out: Fact[] = []
|
||
if (ctx) out.push({ label: 'Context Window', value: formatContext(ctx) })
|
||
if (model.spec?.params) out.push({ label: 'Parameters', value: model.spec.params })
|
||
if (model.spec?.activeParams) out.push({ label: 'Active Params', value: model.spec.activeParams })
|
||
if (model.spec?.arch) out.push({ label: 'Architecture', value: model.spec.arch })
|
||
if (model.modalities?.length) out.push({ label: 'Modalities', value: model.modalities.join(', ') })
|
||
out.push({ label: 'Price', value: <Cost id={model.id} lab={model.category === 'third-party'} /> })
|
||
out.push({ label: 'Model ID', value: model.id, mono: true })
|
||
return out
|
||
}
|
||
|
||
/** The hero's one line: Kai's own, else the catalogue's first sentence. */
|
||
function line(model: ModelData, kai: boolean): string {
|
||
if (kai) return 'Decisions, not completions.'
|
||
if (model.endpoint === '/v1/decisions') return 'Typed decisions at POST /v1/decisions.'
|
||
const first = model.description?.match(/^.*?[.!?](?=\s|$)/)?.[0] ?? model.description
|
||
return first && first.length <= 160 ? first : `${model.name}, through one API key.`
|
||
}
|
||
|
||
export default async function ModelPage({ params }: Props) {
|
||
// Next hands a segment over percent-encoded: `claude-opus-4.8:batch` arrives as
|
||
// `claude-opus-4.8%3Abatch`, and compared raw it matched no model, so every
|
||
// `:batch` page rendered "Model not found".
|
||
const { org, model: segment } = await params
|
||
const modelSlug = decodeURIComponent(segment)
|
||
const data = await fetchModels()
|
||
const model = data.models.find((m) => {
|
||
const { org: mOrg, slug: mSlug } = getOrgAndSlug(m.id)
|
||
return (canonicalOrg(mOrg) === org || mOrg === org) && mSlug === modelSlug
|
||
})
|
||
|
||
if (!model) {
|
||
return (
|
||
<YStack
|
||
minH="100vh"
|
||
items="center"
|
||
justify="center"
|
||
bg="$background"
|
||
$platform-web={{ color: 'var(--foreground)', textAlign: 'center' }}
|
||
>
|
||
<YStack>
|
||
<Title render="h1" quiet mb={8}>Model not found</Title>
|
||
<More href="/models" self="center">
|
||
← Back to all models
|
||
</More>
|
||
</YStack>
|
||
</YStack>
|
||
)
|
||
}
|
||
|
||
// THE LAB'S PAGE IS AT ITS CANONICAL NAME, and this page's own segment is not
|
||
// it: `[org]/page.tsx` generates its params from `canonicalOrg(org)`, so the
|
||
// raw segment (/models/~anthropic, /models/meta-llama) names a route the
|
||
// export never writes. The trail and "View all" link the canonical one.
|
||
const lab = canonicalOrg(org)
|
||
const providerName = orgDisplayName(lab)
|
||
const kai = model.family === 'kai'
|
||
const decides = model.endpoint === '/v1/decisions'
|
||
const { ts, py, curl, go } = examples(model)
|
||
const jsonLd = buildJsonLd(model)
|
||
|
||
// Related models: same org, excluding current. A Zen model's are Zen: the
|
||
// bare ids filed under the same org are mostly other labs' short names.
|
||
const zen = model.id.startsWith('zen')
|
||
const related = data.models
|
||
.filter((m) => {
|
||
const { org: mOrg } = getOrgAndSlug(m.id)
|
||
return (canonicalOrg(mOrg) === lab || mOrg === org) && m.id !== model.id && (!zen || m.id.startsWith('zen'))
|
||
})
|
||
.slice(0, 6)
|
||
|
||
const primary = decides ? { label: 'Get decisions', href: '#quickstart' } : { label: 'Get API key', href: KEYS }
|
||
const action = kai
|
||
? { label: 'Explore Kai', href: '/kai' }
|
||
: decides
|
||
? { label: 'Read the docs', href: `${DOCS}/docs/decisions` }
|
||
: { label: 'Try in Chat', href: '/chat' }
|
||
|
||
return (
|
||
<>
|
||
{/* JSON-LD structured data */}
|
||
<script
|
||
type="application/ld+json"
|
||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||
/>
|
||
|
||
<Box minH="100vh" $platform-web={{ color: 'var(--foreground)' }}>
|
||
<Box px="$4" pt="$8" $sm={{ px: '$5' }} $lg={{ px: '$6' }}>
|
||
<Box width="100%" maxW={1024} mx="auto">
|
||
<Crumbs
|
||
trail={[
|
||
{ label: 'Models', href: '/models' },
|
||
{ label: providerName, href: `/models/${lab}` },
|
||
{ label: modelSlug },
|
||
]}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
|
||
<Hero
|
||
chip={model.tier ? `${providerName} · ${model.tier}` : providerName}
|
||
title={model.name}
|
||
lede={line(model, kai)}
|
||
primary={primary}
|
||
action={action}
|
||
below={<Links links={links(model)} center />}
|
||
/>
|
||
|
||
<Band id="specs" pad="$10" measure={1024}>
|
||
{kai ? (
|
||
<Grid columns={{ min: 320, max: 2 }} gap={24}>
|
||
<Facts title="Shipping today" rows={shipping(model)} />
|
||
<Facts title="In development" tag={<Target />} rows={TARGETS} />
|
||
</Grid>
|
||
) : (
|
||
<YStack gap="$5">
|
||
<Facts title="Specifications" rows={specs(model)} />
|
||
{model.features && model.features.length > 0 ? (
|
||
<Line {...SM} color={DIM}>
|
||
{model.features.join(' · ')}
|
||
</Line>
|
||
) : null}
|
||
</YStack>
|
||
)}
|
||
</Band>
|
||
|
||
<Band id="quickstart" pad="$10" measure={896}>
|
||
<YStack gap="$4">
|
||
<XStack items="baseline" justify="space-between" gap="$3" flexWrap="wrap">
|
||
<Line {...COLUMN}>Quick start</Line>
|
||
<More href={KEYS}>Get an API key →</More>
|
||
</XStack>
|
||
<Samples
|
||
samples={[
|
||
{ lang: 'TypeScript', code: ts, sample: <Code lang="typescript" code={ts} /> },
|
||
{ lang: 'Python', code: py, sample: <Code lang="python" code={py} /> },
|
||
{ lang: 'cURL', code: curl, sample: <Code lang="bash" code={curl} /> },
|
||
{ lang: 'Go', code: go, sample: <Code lang="go" code={go} /> },
|
||
]}
|
||
/>
|
||
<Line {...SM} color={DIM} data-price="">
|
||
<Cost id={model.id} lab={model.category === 'third-party'} />
|
||
{kai ? '. A native decision writes 0 output tokens; a decision Jev or a language model answers bills at that model’s rates, output included.' : ''}
|
||
</Line>
|
||
</YStack>
|
||
</Band>
|
||
|
||
{kai ? (
|
||
<Band id="limits" pad="$10" measure={896}>
|
||
<YStack gap="$3">
|
||
<Line {...COLUMN}>Limits</Line>
|
||
<YStack render="ul" gap="$2">
|
||
{LIMITS.map((l) => (
|
||
<XStack render="li" key={l} items="baseline" gap="$3">
|
||
<Box render="span" width={4} height={4} mt={8} shrink={0} rounded="var(--radius-full)" bg="var(--muted-foreground)" />
|
||
<Line {...SM} color={DIM}>
|
||
{l}
|
||
</Line>
|
||
</XStack>
|
||
))}
|
||
</YStack>
|
||
</YStack>
|
||
</Band>
|
||
) : null}
|
||
|
||
{/* Related models */}
|
||
{related.length > 0 && (
|
||
<Band pad="$10" measure={1024} ground={LIFT}>
|
||
<Line {...COLUMN} mb={24}>
|
||
More from {providerName}
|
||
</Line>
|
||
<Grid columns={{ min: 240, max: 3 }} gap={12}>
|
||
{related.map((m) => {
|
||
const mCtx = context(m)
|
||
return (
|
||
<Tile
|
||
key={m.id}
|
||
href={modelPagePath(m.id)}
|
||
name={m.name}
|
||
ctx={mCtx ? formatContext(mCtx) : undefined}
|
||
blurb={m.description}
|
||
/>
|
||
)
|
||
})}
|
||
</Grid>
|
||
<Box mt="$5">
|
||
<More href={`/models/${lab}`}>
|
||
View all {providerName} models →
|
||
</More>
|
||
</Box>
|
||
</Band>
|
||
)}
|
||
|
||
{/* CTA */}
|
||
<Band pad="$10" measure={672}>
|
||
<YStack $platform-web={{ textAlign: 'center' }}>
|
||
<Title quiet mb={12}>
|
||
Use {model.name} via Hanzo AI
|
||
</Title>
|
||
<Lede mb={24} mt="$3.5">
|
||
{decides ? 'One API key. One call to POST /v1/decisions.' : 'One API key. Every major model. OpenAI-compatible. Start free.'}
|
||
</Lede>
|
||
<XStack flexWrap="wrap" justify="center" gap="$3">
|
||
<Action href={KEYS} fill>
|
||
Get an API key
|
||
</Action>
|
||
<Action href="/models">
|
||
Browse All Models
|
||
</Action>
|
||
</XStack>
|
||
</YStack>
|
||
</Band>
|
||
</Box>
|
||
</>
|
||
)
|
||
}
|