mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 04:20:39 +00:00
scripts/sync-models.mjs runs in prebuild and saves what the pages read of /v1/models and /v1/pricing as lib/data/served.json: names, words, windows, specs, features, tiers and links, no rate and no time. An unreachable API, an error, a body that is not the document, or a list under half the snapshot's keeps the committed file and the build goes on; every cloud restart during a build used to fail the deploy. Rates are read in the browser as before, so a catalogue that changed only its prices writes the same file and the pages come out byte for byte (two builds of one tree: no model page differs on data). lib/api/build.ts and the HANZO_BUILD stamp that keyed its fetch cache go.
139 lines
6.8 KiB
TypeScript
139 lines
6.8 KiB
TypeScript
/**
|
|
* The models this build writes a page for: every model the gateway answers for
|
|
* (GET /v1/models), named, described and windowed from the price list where the
|
|
* model is ours (GET /v1/pricing) and from the Zen catalogue where Zoo Labs
|
|
* published it (lib/data/zen.json: @zenlm/models as scripts/zen.mjs writes it)
|
|
* — as lib/data/served.json holds them (scripts/sync-models.mjs writes it before
|
|
* every build, and keeps it when the API cannot answer). No rate is carried: a
|
|
* page states what a model costs by reading the price list when it opens.
|
|
*
|
|
* Server only: the pages that exist, their titles and the sitemap are decided
|
|
* here, and nothing here ships to a browser.
|
|
*
|
|
* A model's public repositories come from its catalogue record (the price
|
|
* list's `links`, the Zen catalogue's `github` and `huggingface`) and are
|
|
* printed only if they answer 200 when the build asks: Hugging Face answers a
|
|
* missing or private repository 401 and GitHub 404, so a link that would be
|
|
* dead is dropped here, never drawn.
|
|
*/
|
|
import type { ZenModel } from '@zenlm/models'
|
|
import zen from '@/lib/data/zen.json' with { type: 'json' }
|
|
import catalogue from '@/lib/data/served.json' with { type: 'json' }
|
|
import type { Own, Served } from '@/lib/api/read'
|
|
import { described, getOrgAndSlug, zenToModelData, type ModelData, type ModelsResponse } from '@/lib/models'
|
|
import { distinct } from './distinct'
|
|
|
|
const ZEN = new Map((zen as ZenModel[]).map((z) => [z.id, zenToModelData(z)]))
|
|
const OWN = new Map((catalogue.own as Own[]).map((o) => [o.name, o]))
|
|
|
|
function row(s: Served, ours: Own | undefined): ModelData {
|
|
const z = ZEN.get(s.id)
|
|
const lab = s.id.includes('/') && !s.family
|
|
const isKai = s.id === 'kai' || s.id === 'hanzo/kai' || s.family === 'kai'
|
|
return described({
|
|
id: s.id,
|
|
name: isKai ? 'Kai' : (ours?.fullName ?? z?.name ?? s.name ?? s.id),
|
|
fullName: isKai ? 'Kai — Decision Intelligence' : (ours?.fullName ?? z?.fullName),
|
|
provider: isKai ? 'hanzo' : (s.owned_by ?? ''),
|
|
description: isKai
|
|
? 'Hanzo Kai is a structured decision model answering typed questions at /v1/decisions with calibrated probabilities and zero output tokens. Full multimodal decision architecture (~850M parameters, 32K reach); native vision and audio towers: Kai-1.7 target.'
|
|
: (ours?.description ?? z?.description ?? s.description),
|
|
context: isKai ? 32768 : (s.context_window ?? ours?.context ?? z?.context),
|
|
modalities: isKai ? ['Text', 'State', 'Vision (Kai-1.7 target)', 'Audio (Kai-1.7 target)'] : (s.outputs ?? z?.modalities),
|
|
status: 'available',
|
|
category: isKai ? 'decision' : (lab ? 'third-party' : (s.family ?? z?.category ?? 'chat')),
|
|
spec: isKai
|
|
? {
|
|
params: '~850M multimodal decision core',
|
|
activeParams: '~850M (multimodal decision architecture)',
|
|
context: 32768,
|
|
arch: 'Decision Transformer',
|
|
}
|
|
: (z?.spec ?? (ours?.specs ? { params: ours.specs.params, arch: ours.specs.arch } : undefined)),
|
|
features: isKai
|
|
? [
|
|
'Live: High-throughput text & structured state decision core',
|
|
'Live: Calibrated probability distributions over typed options (bool, choice, score)',
|
|
'Live: <1 ms p50 (one question, CUDA bf16)',
|
|
'Live: 32,768 positional reach via YaRN rotary stretching (1,024 trained base)',
|
|
'Live: Zero output tokens generated or billed ($0.021/MTok base input)',
|
|
'Experimental: Nio policy profiles decide from stated consequences and escalate when they cannot be counted',
|
|
'Kai-1.7 target: Multimodal vision, audio, and video decision towers (~850M total arch)',
|
|
'Kai-1.7 target: Dynamic adaptive reasoning depth (reasoning: "auto" | "fast")',
|
|
]
|
|
: (ours?.features ?? z?.features),
|
|
huggingface: ours?.links?.huggingface ?? z?.huggingface,
|
|
github: isKai ? 'https://github.com/hanzoai/kai' : (ours?.links?.github ?? z?.github),
|
|
family: s.family,
|
|
endpoint: s.outputs?.includes('decision') ? '/v1/decisions' : '/v1/chat/completions',
|
|
aliases: isKai ? ['kai', 'hanzo/kai', 'kai-1.6', 'kai-1.5'] : z?.aliases,
|
|
tier: isKai ? 'Enterprise' : (ours?.tier ?? z?.tier),
|
|
generation: isKai ? 'Kai 1.6' : z?.generation,
|
|
})
|
|
}
|
|
|
|
/** Whether a public page answers 200, asked once per URL a build. */
|
|
const asked = new Map<string, Promise<boolean>>()
|
|
function answers(url: string): Promise<boolean> {
|
|
let got = asked.get(url)
|
|
if (!got) {
|
|
got = fetch(url, { method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(15_000) }).then(
|
|
(res) => res.status === 200,
|
|
() => false,
|
|
)
|
|
asked.set(url, got)
|
|
}
|
|
return got
|
|
}
|
|
|
|
/** The row with only the repositories that answer. */
|
|
async function live(m: ModelData): Promise<ModelData> {
|
|
const [github, huggingface] = await Promise.all([m.github, m.huggingface].map((u) => (u ? answers(u) : Promise.resolve(false))))
|
|
return { ...m, github: github ? m.github : undefined, huggingface: huggingface ? m.huggingface : undefined }
|
|
}
|
|
|
|
/** Whether a row's name is its own: the price list's row for this id, or this id's Zen release. */
|
|
const named = (s: Served, ours: Own | undefined): boolean => Boolean(ours?.fullName ?? ZEN.get(s.id)?.name)
|
|
|
|
/**
|
|
* Every page its own name. The gateway gives the ids of one family the family's
|
|
* name — enso-auto is "Enso" beside enso, kai-1.4 and kai-1.5 are "Kai" beside
|
|
* kai — and a page is titled by its row's name, so two rows of one name are two
|
|
* pages a reader cannot tell apart. Where rows share a name, the one row named
|
|
* for itself keeps it, and every other says its id beside it: "Kai (kai-1.4)".
|
|
*/
|
|
function unique(rows: { model: ModelData; own: boolean }[]): ModelData[] {
|
|
const groups = new Map<string, { model: ModelData; own: boolean }[]>()
|
|
for (const r of rows) groups.set(r.model.name, [...(groups.get(r.model.name) ?? []), r])
|
|
return rows.map(({ model, own }) => {
|
|
const group = groups.get(model.name)!
|
|
if (group.length === 1 || (own && group.filter((r) => r.own).length === 1)) return model
|
|
return { ...model, name: `${model.name} (${getOrgAndSlug(model.id).slug})` }
|
|
})
|
|
}
|
|
|
|
let models: Promise<ModelsResponse> | null = null
|
|
|
|
/**
|
|
* The catalogue this build renders: one row per model page (`distinct`: `kai`
|
|
* and `hanzo/kai` are one page), each under a name no other page has (`unique`),
|
|
* in the gateway's order.
|
|
*/
|
|
export function fetchModels(): Promise<ModelsResponse> {
|
|
models ??= (async () => {
|
|
const rows = unique(
|
|
await Promise.all(
|
|
distinct(
|
|
(catalogue.served as Served[]).map((s) => {
|
|
const ours = OWN.get(s.id)
|
|
return { model: row(s, ours), own: named(s, ours) }
|
|
}),
|
|
(r) => r.model.id,
|
|
).map(async (r) => ({ ...r, model: await live(r.model) })),
|
|
),
|
|
)
|
|
return { total: rows.length, models: rows }
|
|
})()
|
|
return models
|
|
}
|