Files
zeekay edb6963531 Plans, prices and the model table are read from api.hanzo.ai when a page opens, and no page carries a copy
The root layout handed every page the plan catalog (lib/rooms.ts configure
with lib/data/plans.json), and /pricing, /calculator, /customers and the
benchmark pages bundled the 202 KB price list. lib/api is now the one reader:
plans() GET /v1/billing/plans, pricing() /v1/pricing, summary(), served()
/v1/models, templates(), blueprints() /v1/blueprint/sbom, one promise a path a
page load, each drawn as loading, ready or "api.hanzo.ai is unreachable"
(components/api) and never an older number. plans.json, pricing.json,
models.json, templates.json, blueprints.json and sync-pricing, sync-models,
sync-templates, sync-blueprints are gone.

What a static export must know before anyone asks, which model pages exist and
their titles, is read by the build itself (lib/api/build, Next's fetch cache
keyed by the build's stamp: one answer per build) and renders no rate.
lib/moved reads the retired model pages against the export, and the 153 pages
the old snapshot kept for models the gateway dropped are in retired.json.
Kai's price beside Jev's, Enso's tier rates, a model page's rate and the GPU
table read the price list in the browser; the site's plan math no longer
imports @hanzo/rooms/lib/plans, whose bundled @hanzo/plans catalog rode the
home, /pricing and the picker.

Home first load 452.7 -> 437.4 KB gzip (budget 440); pages over
perf.budget.json 32 -> 9, the rest over for settings code, connectors and the
commerce catalog. The gates serve invented answers (e2e/gates/api) and block
them; e2e/gates/read.spec.ts holds that no script carries a plan, price or
model table.
2026-10-15 11:07:56 -07:00

203 lines
12 KiB
JavaScript

// The deck reads what the site reads. Nothing on a slide is typed: the bench
// rows and the whole API surface are copied out of lib/data, and the model
// counts and prices are read from api.hanzo.ai (GET /v1/pricing and /v1/models,
// the documents the site reads when a page opens; the site keeps no copy), into
// assets/ beside index.html, which is the only place a flat server or a
// renderer can fetch them from.
// node deck/sync.mjs
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { read, singular } from '../scripts/catalog.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const data = (f) => JSON.parse(readFileSync(join(here, '..', 'lib', 'data', f), 'utf8'))
copyFileSync(join(here, '..', 'lib', 'data', 'bench.json'), join(here, 'assets', 'bench.json'))
console.log('deck/assets/bench.json <- lib/data/bench.json')
// THE WALL. Which labs, and how many of each lab's models we carry, counted off
// the pricing catalog rather than remembered. The mark and its colour live in
// the slide, because those belong to the lab and not to us; the key here is the
// name of the file that draws it.
const MARK = {
openai: 'openai', huggingface: 'huggingface', qwen: 'qwen', google: 'google',
mistralai: 'mistral', anthropic: 'anthropic', deepseek: 'deepseek',
nvidia: 'nvidia', 'meta-llama': 'meta', perplexity: 'perplexity',
amazon: 'aws', 'x-ai': 'xai',
}
const NAME = {
openai: 'OpenAI', huggingface: 'Hugging Face', qwen: 'Qwen', google: 'Google',
mistralai: 'Mistral', anthropic: 'Anthropic', deepseek: 'DeepSeek',
nvidia: 'NVIDIA', 'meta-llama': 'Meta', perplexity: 'Perplexity',
amazon: 'Amazon', 'x-ai': 'xAI',
}
/** A public read of api.hanzo.ai, or the run stops and says which. */
const get = async (path) => {
const res = await fetch(`https://api.hanzo.ai${path}`, { headers: { accept: 'application/json' } })
if (!res.ok) throw new Error(`api.hanzo.ai${path}: HTTP ${res.status}`)
return res.json()
}
const pricing = await get('/v1/pricing')
const served = (await get('/v1/models')).data.length
const count = {}
for (const m of pricing.thirdPartyModels) {
const lab = String(m.id).split('/')[0]
count[lab] = (count[lab] ?? 0) + 1
}
const labs = Object.keys(MARK)
.map((lab) => ({ mark: MARK[lab], name: NAME[lab], models: count[lab] ?? 0 }))
.sort((a, b) => b.models - a.models)
// The prices on the score slide, so a bar and its price come from one file.
const priced = [...pricing.hanzoModels, ...pricing.thirdPartyModels]
const price = (id) => priced.find((m) => m.id === id || m.name === id)?.pricing?.output ?? null
const out = {
labs,
models: served,
free: pricing.freeModels.length,
providers: Object.keys(pricing.providers).length,
price: Object.fromEntries([
['Enso Ultra', price('enso-ultra')], ['Enso Pro', price('enso')], ['Enso Flash', price('enso-flash')],
['Gemini 3.1 Pro', price('google/gemini-3.1-pro-preview')],
['GPT-5.5', price('openai/gpt-5.5')], ['GPT-5.2 Pro', price('openai/gpt-5.2-pro')],
]),
}
for (const [k, v] of Object.entries(out.price)) if (v == null) throw new Error('no price for ' + k)
writeFileSync(join(here, 'assets', 'models.json'), JSON.stringify(out, null, 2) + '\n')
console.log(`deck/assets/models.json <- api.hanzo.ai/v1/pricing + /v1/models (${labs.length} labs, ${out.models} models)`)
// THE PLATFORM. What the API is and how big, read from the two snapshots
// scripts/sync-catalog.mjs writes out of api.hanzo.ai/v1/openapi.json behind
// its own ratchets — one probe for the estate, one committed answer. The deck
// is that answer's third reader, after the mega-menu and the skill manifest, so
// no slide can disagree with the site about the size of the platform.
const catalog = data('catalog.json')
const doc = data('families.json')
const label = Object.fromEntries(catalog.categories.map((c) => [c.id, c.label]))
const family = Object.fromEntries(doc.families.map((f) => [f.name, f]))
// WHICH products the appendix walks is declared on the slides — one per layer,
// as `data-family` — and every fact about them is resolved here. That is the
// same division the hero's tour already uses: the choice and the prose are
// ours, the sentence, the path and the count are the API's own words. A name
// the document stops carrying stops the sync rather than shipping a slide that
// has quietly gone stale.
const html = readFileSync(join(here, 'index.html'), 'utf8')
// The catalog advertises `/v1/agents` where the API answers at `/v1/agent`, so
// a product is matched on the path that ANSWERS as well as the one it claims —
// `singular` is the same rule scripts/sync-catalog.mjs resolves paths by, and
// the deck must curl what the API serves rather than what the catalog says.
const sells = (f) => catalog.products
.filter((p) => p.api === `/v1/${f}` || singular(p.api) === `/v1/${f}`)
.sort((a, b) => (b.id === f) - (a.id === f) || a.order - b.order)[0]
const core = [...html.matchAll(/<section[^>]*\sdata-family="([^"]+)"/g)].map(([, name]) => {
const f = family[name], p = sells(name)
if (!f) throw new Error(`the document declares no /v1/${name}`)
if (!p) throw new Error(`the catalog sells nothing at /v1/${name}`)
if (!f.category) throw new Error(`the catalog places /v1/${name} in no layer`)
if (!f.summary) throw new Error(`the document says nothing about /v1/${name}`)
return {
family: name, name: p.name, layer: label[f.category], path: `/v1/${name}`,
operations: f.operations, summary: f.summary, page: p.href, repo: p.repo,
}
})
const platform = {
document: doc.document,
catalog: catalog.source,
host: new URL(doc.document).host,
// WHEN the two snapshots were taken. The appendix says so on its own face:
// these are the document's words as of a moment, and a deck that implies it
// read them just now is making a claim it cannot keep between refreshes.
taken: new Date(Math.min(Date.parse(doc.fetched), Date.parse(catalog.fetched)))
.toLocaleDateString('en-US', { day: 'numeric', month: 'long', year: 'numeric' }),
families: doc.families.length,
operations: doc.families.reduce((n, f) => n + f.operations, 0),
products: catalog.products.length,
// The whole surface, one entry per family, for the plate that draws it: an
// area in the ratio of the operations under it. Ranked here so the layout is
// a fold over a sorted list rather than a sort inside a drawing routine.
surface: doc.families.filter((f) => f.operations).map((f) => [f.name, f.operations])
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])),
layers: catalog.categories.map((c) => ({
label: c.label, products: catalog.products.filter((p) => p.category === c.id).length,
})),
tour: catalog.tour,
core,
}
// EVERY CALL THIS DECK PRINTS IS CHECKED AGAINST THE API THAT SERVES IT.
//
// The paths come from a committed snapshot, and a snapshot is a moment: six of
// the hero tour's twelve beats name operations the document stopped serving
// when it singularised its segments, and the deck was printing all twelve. A
// path on a slide is a promise that a reader can copy it and reach something,
// so it is verified here rather than trusted — through the same reader
// scripts/sync-catalog.mjs probes with, so the estate still has one prober.
//
// An unreachable API keeps the last certified answer rather than failing the
// build — the same rule the rest of lib/data lives by, and the committed
// platform.json was verified when it was written. What it must never do is
// write a NEW path it could not check, so on that branch it stops.
const certified = join(here, 'assets', 'platform.json')
const { paths, error } = await read()
if (error && !existsSync(certified)) throw new Error(`cannot certify the calls on this deck and none are held — ${error}`)
if (error) {
console.warn(` [platform] keeping the certified snapshot, unchanged — ${error}`)
} else {
const dead = (p) => !paths.has(p) && ![...paths].some((x) => x.startsWith(p.replace(/\/$/, '') + '/'))
const gone = platform.tour.beats.filter((b) => dead(b.path))
platform.tour = { ...platform.tour, beats: platform.tour.beats.filter((b) => !dead(b.path)) }
for (const c of platform.core) if (dead(c.path)) throw new Error(`the API does not serve ${c.path}`)
if (gone.length) console.log(` tour: ${gone.length} beat(s) the API no longer serves — ${gone.map((b) => b.path).join(', ')}`)
writeFileSync(certified, JSON.stringify(platform, null, 2) + '\n')
}
console.log(`deck/assets/platform.json <- lib/data/{catalog,families}.json ` +
`(${platform.families} families, ${platform.operations} operations, ${core.length} walked)`)
// THE CADENCE IS ARITHMETIC. Every slide opens at its index times the hold the
// page script keeps, so inserting one renumbers the rest instead of asking a
// person to. The hold is read from the script that animates it, because a
// second copy of the number here is how the two come to disagree.
const HOLD = Number(/const HOLD = (\d+)/.exec(html)[1])
let n = 0
const timed = html.replace(/(<section class="slide[^>]*?data-start=")\d+(")/g, (_, a, b) => a + n++ * HOLD + b)
if (timed !== html) writeFileSync(join(here, 'index.html'), timed)
console.log(`deck/index.html <- ${n} slides at ${HOLD}s, ${(n * HOLD) / 60 | 0}m${(n * HOLD) % 60}s`)
// THE ROOMS. Which ones exist has an owner already — `lib/data/rooms.json`,
// which `scripts/sync-rooms.mjs` walks off `app/(app)` so the sitemap cannot
// drift from the tree — so this reads that answer and then asks each room what
// it is. The words are the room's OWN `metadata`, not a description of it
// written here: a room that says neither a title nor a description stops the
// sync, because a tile with copy invented for it is the one thing this slide
// cannot afford.
//
// The name is the title's first word, or its second where the first is the
// company's — every room is titled either `Inbox — …` or `Hanzo Meet — …`. The
// line is what the title says after the dash, except on the two rooms whose
// title ends in the site name rather than a subtitle, where the description's
// first clause says it instead.
const room = (slug) => {
const src = readFileSync(join(here, '..', 'app', '(app)', slug, 'page.tsx'), 'utf8')
const pick = (held, key) => (new RegExp(`const ${held}\\s*=\\s*\\n?\\s*'([^']*)'`).exec(src)
?? new RegExp(`\\b${key}:\\s*\\n?\\s*'([^']*)'`).exec(src))?.[1] ?? null
const title = pick('TITLE', 'title'), about = pick('DESCRIPTION', 'description')
if (!title || !about) throw new Error(`/${slug} says no ${title ? 'description' : 'title'} of its own`)
const word = title.split(/\s+/)
const tail = title.split(' — ')[1] ?? ''
// Trimmed HERE rather than clipped in CSS. Two rooms title themselves `X —
// Hanzo AI` and so borrow their line from the description, which runs long
// enough to overflow a tile; a box that hides the tail of a sentence is a
// rendering fault, whereas an ellipsis in the data says a person trimmed it.
const clause = tail && tail !== 'Hanzo AI' ? tail : about.split(/[.,]/)[0]
const line = clause.length <= 52 ? clause
: clause.slice(0, clause.lastIndexOf(' ', 52)).replace(/[\s,;:—-]+$/, '') + '…'
return { slug, name: word[0] === 'Hanzo' ? word[1] : word[0], line, title, about }
}
const rooms = data('rooms.json').map((route) => room(route.replace(/^\//, '')))
if (!rooms.some((r) => r.slug === 'dev')) throw new Error('no /dev room — the slide it anchors would be arguing the opposite')
writeFileSync(join(here, 'assets', 'rooms.json'), JSON.stringify(rooms, null, 2) + '\n')
console.log(`deck/assets/rooms.json <- app/(app) via lib/data/rooms.json (${rooms.length} rooms, ${rooms.length - 1} of them not the code)`)