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

108 lines
4.3 KiB
TypeScript

/**
* The rate card, as the page writes it.
*
* ONE WRITER, so there is one place a number can be got wrong. Every figure on
* /pricing comes from the price list's `tariff` (GET /v1/pricing, read by
* `lib/api`), which the cloud resolves on every request from commerce's rate
* authority — the rows a SuperAdmin edits at admin.hanzo.ai — falling back to
* the floors it charges by (cloud tariff.go). Nothing is baked: the number a
* reader sees is the number the ledger books, read when the page opens, and
* scripts/audit-price-literals.mjs is what stops anyone shortcutting that by
* typing one.
*
* MICRO-USD IN, STRING OUT. The card carries integer micro-USD because a web
* call costs a fifth of a cent and a cent cannot hold it. Turning that into money
* is a DISPLAY act and it happens here, once — `money` for a rate as stated, and
* `perSecond` for the hourly compute rates, which are charged by the second and
* read that way. Neither rounds: the card's own precision survives to the page.
*/
import type { Card, Rate, Tool } from '@/lib/api/read'
export type { Card, Part, Rate, Tool, Window } from '@/lib/api/read'
/**
* Micro-USD as money. Zero is a price, and it reads as one. A sub-dollar rate
* keeps the precision the card states and at least two decimals, so a fifth of
* a cent reads $0.002 and a dime $0.10.
*/
export function money(micros: number): string {
if (micros === 0) return '$0'
const v = micros / 1e6
if (v >= 1) return `$${v.toLocaleString('en-US')}`
return `$${v.toFixed(Math.max(2, (String(v).split('.')[1] ?? '').length))}`
}
/**
* What one SECOND of an hourly rate costs.
*
* The card stores the hour because that is the unit a span is priced in and
* because a GiB-second is four and a half micro-USD, which no integer holds. The
* second is what a reader is actually charged by, so it is derived here rather
* than published as a second number that could disagree with the first.
*/
export function perSecond(micros: number): string {
return money(micros / 3600)
}
/**
* How a unit reads beside its price. The card names the unit exactly — a reader
* never has to guess what a number is per — and this is the short form of that
* name, the only place the two vocabularies meet.
*
* A rate of zero shows no unit, because "$0 per seat" and "$0 per call" both say
* the same thing and the shorter one says it better.
*/
const UNIT: Record<string, string> = {
'vcpu-hour': '/hr',
'gib-hour': '/GiB-hr',
call: '/call',
}
/** A rate as it reads on the card: the price, and what it is per. */
export function priced(r: Rate): string {
return r.rate_micro_usd === 0 ? money(0) : money(r.rate_micro_usd) + (UNIT[r.per] ?? '')
}
/** The hourly rates, which are charged by the second and read both ways. */
const SECOND: Record<string, string> = {
'vcpu-hour': '/second',
'gib-hour': '/GiB-second',
}
/** The same rate by the second, or null where the rate is not an hourly one. */
export function bySecond(r: Rate): string | null {
const unit = SECOND[r.per]
return unit && r.rate_micro_usd > 0 ? perSecond(r.rate_micro_usd) + unit : null
}
/**
* The tool rows the card prices, by the name the list gives them — the cloud's own
* map (apps/pricing/tariff.go webRows), so a row and a rate are one fact.
*/
const CARD_TOOLS: Record<string, string> = { 'Web Search': 'web_search', 'Web Fetch': 'web_fetch' }
/**
* The tool list with every row the card prices read FROM the card.
*
* The list owns a row's words and the card owns its number, which is how the
* cloud publishes the document too (repriceTools). Read through the card, a web
* row cannot state one price beside a card that charges another for the same
* call, and a web tool the list never carried still appears.
*/
export function pricedTools(tools: Tool[], by: Card): Tool[] {
const byName = new Map(by.rates.map((r) => [r.name, r]))
const seen = new Set<string>()
const out = tools.map((row) => {
const r = byName.get(CARD_TOOLS[row.name] ?? '')
if (!r) return row
seen.add(r.name)
return { ...row, price: r.rate_micro_usd / 1e6, unit: `per ${r.per}` }
})
for (const [name, id] of Object.entries(CARD_TOOLS)) {
const r = byName.get(id)
if (r && !seen.has(id)) out.push({ name, price: r.rate_micro_usd / 1e6, unit: `per ${r.per}` })
}
return out
}