Files
hanzo.ai/lib/models/catalog.ts
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

138 lines
5.7 KiB
TypeScript

/**
* The catalogue as a table: one row per model a caller can reach, with what it
* costs and where it files — joined in the browser from the two reads that own
* those facts (lib/api), when the page opens:
*
* GET /v1/models says WHAT ANSWERS, and what a lab's model is billed at:
* the gateway prices each row with the lookup billing charges from.
*
* GET /v1/pricing says what OUR models cost and hold. The routing table
* states no rate a reader should see for a model of ours, so the price list
* is the one owner of those.
*
* Joined by the id a caller passes, each fact keeps exactly one owner, and the
* table and a model's own page (components/models/Cost) ask the same question
* the same way: `labRate` and `ownRate`.
*
* WHERE A ROW FILES, and why `provider` cannot answer it. The routing table
* stamps `provider: "Hanzo"` on every un-prefixed id — including other labs'
* models under the gateway's short names — so grouping on it would head a card
* "Hanzo" over GPT-5. Three questions, asked in order, none of them a typed list:
*
* a prefix names the lab → `anthropic/claude-opus-4.6` files under Anthropic
* the price list claims it, → `enso`, `zen5`, `router:writing` file under us
* or the name is one we own
* otherwise it is a short name → `gpt-5` files under Aliases, which says so
*/
import { canonicalOrg, getOrgAndSlug, modelName, modelPagePath } from '@/lib/models'
import { own, type Pricing, type Served } from '@/lib/api/read'
import { distinct } from './distinct'
export const ALIAS = 'alias'
export interface Row {
/** The exact string a caller passes as `model`. */
id: string
/** What it is called, with the lab's own prefix off the front. */
name: string
/** Where it files. A lab's namespace, `hanzo` for ours, or `ALIAS`. */
lab: string
/** The page that answers for it. */
href: string
/** USD per million tokens. `null` where no rate is published. */
in: number | null
out: number | null
/** A rate charged per call instead, with the thing it is charged per. */
each: number | null
unit: string | null
/** No retail rate at all — this one is a conversation. */
sales: boolean
/** Context window in tokens; 0 where the catalogue states none. */
ctx: number
}
/** Ours by NAME prefix: enso, zen, or hanzo. */
export const HOUSE_MODEL = /^(enso|zen|hanzo)(\d|-|$)/i
/**
* Sorts catalog rows with all of OUR models first, followed by third-party
* labs ordered by model count descending, and aliases last.
* Inside our models (`hanzo` lab):
* - Enso frontier family leads (0)
* - Zen open-weight family follows (1)
* - Remaining Hanzo models (2)
* - Alphabetical by ID
*/
export function sortCatalogRows(rows: Row[]): Row[] {
const size = new Map<string, number>()
for (const row of rows) size.set(row.lab, (size.get(row.lab) ?? 0) + 1)
/** Which group comes first: ours, then the labs with the most to offer, then
* the short names. */
const lab = (name: string) => (name === 'hanzo' ? Infinity : name === ALIAS ? -1 : (size.get(name) ?? 0))
/** And which row comes first INSIDE ours. The two families we lead with lead here too. */
const house = (id: string) => (id.startsWith('enso') ? 0 : id.startsWith('zen') ? 1 : 2)
return [...rows].sort(
(a, b) =>
lab(b.lab) - lab(a.lab) ||
a.lab.localeCompare(b.lab) ||
house(a.id) - house(b.id) ||
a.id.localeCompare(b.id),
)
}
function labOf(id: string, doc: Pricing): string {
if (id.includes('/')) return canonicalOrg(getOrgAndSlug(id).org)
return HOUSE_MODEL.test(id) || own(doc, id) ? 'hanzo' : ALIAS
}
/** What a model costs and how much it holds, joined. */
export type Facts = Pick<Row, 'in' | 'out' | 'each' | 'unit' | 'sales' | 'ctx'>
const NOTHING: Facts = { in: null, out: null, each: null, unit: null, sales: false, ctx: 0 }
/** Whether a row is a lab's: prefixed, and in no family of ours. */
export const isLab = (s: Pick<Served, 'id' | 'family'>) => s.id.includes('/') && !s.family
/** A lab's rate, as the gateway bills it. A `variable` router states only the ceiling a call can bill, which is not a price, so it states none. */
export function labRate(s: Served): Facts {
const p = s.pricing && !s.pricing.variable ? s.pricing : null
return { ...NOTHING, in: p?.input_per_million ?? null, out: p?.output_per_million ?? null }
}
/** Our model's rate, as the price list states it: per token, or per thing made. */
export function ownRate(doc: Pricing, id: string): Facts {
const ours = own(doc, id)
const p = ours?.pricing
if (p?.perUnit != null) return { ...NOTHING, each: p.perUnit, unit: ours.pricingUnit ?? 'call' }
if (p && (p.input != null || p.output != null)) return { ...NOTHING, in: p.input ?? null, out: p.output ?? null }
return NOTHING
}
/** One model's rate and window, joined. */
export function facts(s: Served, doc: Pricing): Facts {
return { ...(isLab(s) ? labRate(s) : ownRate(doc, s.id)), ctx: s.context_window ?? own(doc, s.id)?.context ?? 0 }
}
/**
* The catalogue in the order it is read: ours first, then the labs with the most
* to offer, then the short names, one row per model page (`distinct`).
*/
export function catalog(served: Served[], doc: Pricing): Row[] {
const rows = distinct(served, (s) => s.id).map((s) => {
const lab = labOf(s.id, doc)
return {
id: s.id,
// AN ALIAS HAS NO NAME — it IS one. Title-casing `gpt-5` into "Gpt 5"
// invents a name nobody uses, so the row shows the id and stops.
name: lab === ALIAS ? s.id : modelName({ id: s.id, name: own(doc, s.id)?.fullName ?? s.name ?? s.id }),
lab,
href: modelPagePath(s.id),
...facts(s, doc),
}
})
return sortCatalogRows(rows)
}