mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-28 07:32:52 +00:00
The bar is Models, Dev, Platform, Pricing, Docs, Enterprise, Log in, and Get API key. The homepage is seven sections. Launch pages stay in the tree and drop out of the index through lib/launch.ts. /platform forwards to /cloud. Model and repository floors come from the snapshots the build already keeps. Co-authored-by: z <[email protected]>
395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
/**
|
||
* What the site prints from api.hanzo.ai — the plans, the price list, the
|
||
* models, the templates and what a stack costs to run — read when a page asks
|
||
* for it, and never copied into the build.
|
||
*
|
||
* ONE READ PER PATH PER PAGE LOAD. Every reader on a page asks for the same
|
||
* document and gets the same promise, so /pricing costs one request for the
|
||
* plans and one for the price list however many sections draw them. A failure
|
||
* is kept too: the page says once that the backend did not answer, and a
|
||
* reload asks again.
|
||
*
|
||
* NO STALE ANSWER. There is no snapshot to fall back to, so a reader is shown
|
||
* the backend's numbers or told it is unreachable (`Unreachable`), never a
|
||
* number some earlier build saw.
|
||
*
|
||
* Plain module, no React: hooks over these reads are `lib/api/use.ts`, and the
|
||
* model list the build renders is `lib/data/served.json` (scripts/sync-models.mjs).
|
||
*/
|
||
import { api } from '@hanzo/app/lib/api'
|
||
import type { BillingPlan, SubscriptionPlan } from '@hanzo/app/lib/plans'
|
||
import { MODEL_COUNT } from '@/lib/counts'
|
||
|
||
/**
|
||
* A plan as the cards draw it, with the team it is a seat type of: plans of one
|
||
* `group` ("team") are sold on one subscription, its standard and premium seats.
|
||
*/
|
||
export type Plan = SubscriptionPlan & { group?: string }
|
||
|
||
/** A subscription term. A month is what every catalog row charges by default. */
|
||
export type Interval = 'month' | 'year'
|
||
|
||
/** The backend did not answer, or answered with something that is not the document asked for. */
|
||
export class Unreachable extends Error {
|
||
constructor(
|
||
readonly path: string,
|
||
why: string,
|
||
) {
|
||
super(`api.hanzo.ai${path}: ${why}`)
|
||
this.name = 'Unreachable'
|
||
}
|
||
}
|
||
|
||
const pending = new Map<string, Promise<unknown>>()
|
||
|
||
/**
|
||
* How long a read waits for its reply, in milliseconds. A reply that does not
|
||
* start in this time is unreachable, so an answer that never comes cannot hold
|
||
* a section on "Reading" and the sections whose reads did answer draw; a body
|
||
* that has started is read to the end however slowly it arrives (the research
|
||
* runs are 1.5 MB as served).
|
||
*/
|
||
const PATIENCE = 15_000
|
||
|
||
/**
|
||
* GET `path` once per page load. `parse` turns the body into the document or
|
||
* answers null when it is not one, which is a failure like any other, and so
|
||
* is no reply within `PATIENCE`.
|
||
*/
|
||
export function read<T>(path: string, parse: (body: unknown) => T | null): Promise<T> {
|
||
let got = pending.get(path) as Promise<T> | undefined
|
||
if (got) return got
|
||
const stop = new AbortController()
|
||
const timer = setTimeout(() => stop.abort(), PATIENCE)
|
||
got = fetch(`${api()}${path}`, { headers: { accept: 'application/json' }, signal: stop.signal })
|
||
.catch((e: unknown) => {
|
||
throw new Unreachable(path, stop.signal.aborted ? `no reply in ${PATIENCE / 1000} s` : e instanceof Error ? e.message : String(e))
|
||
})
|
||
.then(async (res) => {
|
||
clearTimeout(timer)
|
||
if (!res.ok) throw new Unreachable(path, `HTTP ${res.status}`)
|
||
const doc = parse(await res.json().catch(() => null))
|
||
if (doc === null) throw new Unreachable(path, 'not the document this page reads')
|
||
return doc
|
||
})
|
||
.finally(() => clearTimeout(timer))
|
||
pending.set(path, got)
|
||
return got
|
||
}
|
||
|
||
const list = (v: unknown): unknown[] | null => (Array.isArray(v) ? v : null)
|
||
const object = (v: unknown): Record<string, unknown> | null =>
|
||
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
|
||
|
||
/* ---- the plans: GET /v1/billing/plans, commerce's plan authority ---- */
|
||
|
||
export const PLANS = '/v1/billing/plans'
|
||
|
||
/**
|
||
* A row as the cards draw it. Billing speaks cents and the cards dollars; this
|
||
* is where one becomes the other. `features` is the catalog's lines as served,
|
||
* in its order (@hanzo/plans subscription.json, where each line that claims a
|
||
* grant is held to the entitlement behind it), and always a list, so a row with
|
||
* none draws a card with none rather than no card.
|
||
*/
|
||
export function plan(p: BillingPlan & { group?: string }): Plan {
|
||
const features = [...(p.features ?? [])]
|
||
|
||
return {
|
||
id: p.slug,
|
||
name: p.name,
|
||
description: p.description,
|
||
priceMonthly: p.price == null ? null : p.price / 100,
|
||
priceAnnual: p.priceAnnual == null ? null : p.priceAnnual / 100,
|
||
annualTotal: p.annualTotal ? p.annualTotal / 100 : null,
|
||
category: p.category,
|
||
popular: p.popular,
|
||
contactSales: p.contactSales,
|
||
pricePerUser: p.perSeat,
|
||
features,
|
||
limits: p.limits,
|
||
checkoutUrl: p.checkoutUrl,
|
||
checkoutId: p.checkoutId,
|
||
group: p.group || undefined,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* What one term of a plan charges, in dollars, or null where the catalog sells
|
||
* no such term. A month is `priceMonthly`; a year is the total the catalog
|
||
* states, or twelve of `priceAnnual` where it states only that.
|
||
*/
|
||
export function charge(p: SubscriptionPlan, interval: Interval): number | null {
|
||
const n =
|
||
interval === 'month'
|
||
? p.priceMonthly
|
||
: (p.annualTotal ?? (p.priceAnnual ? Math.round(p.priceAnnual * 1200) / 100 : null))
|
||
return n && n > 0 ? n : null
|
||
}
|
||
|
||
/** What a year saves against twelve months, as a whole percent: 1 − year ÷ (12 × month). Null where it saves nothing. */
|
||
export function saving(p: SubscriptionPlan): number | null {
|
||
const month = charge(p, 'month')
|
||
const year = charge(p, 'year')
|
||
if (!month || !year || year >= 12 * month) return null
|
||
return Math.round((1 - year / (12 * month)) * 100)
|
||
}
|
||
|
||
/** The plan rows in a body: a bare array, or one wrapped in `plans`. */
|
||
export function toPlans(body: unknown): Plan[] | null {
|
||
const rows = list(body) ?? list(object(body)?.plans)
|
||
if (!rows || !rows.every((r) => typeof object(r)?.slug === 'string')) return null
|
||
return (rows as BillingPlan[]).map(plan)
|
||
}
|
||
|
||
/** Every plan commerce lists, in its order. */
|
||
export const plans = (): Promise<Plan[]> => read(PLANS, toPlans)
|
||
|
||
/* ---- the price list: GET /v1/pricing, the rate catalog commerce publishes ---- */
|
||
|
||
export const PRICING = '/v1/pricing'
|
||
|
||
/** One priced line of the rate card: what one unit costs, in micro-USD, and what a unit is. */
|
||
export interface Rate {
|
||
name: string
|
||
title: string
|
||
component: string
|
||
rate_micro_usd: number
|
||
per: string
|
||
note: string
|
||
}
|
||
|
||
/** One of the four things a bill is made of. */
|
||
export interface Part {
|
||
name: string
|
||
title: string
|
||
basis: string
|
||
quoted: boolean
|
||
tiers?: string[]
|
||
note: string
|
||
}
|
||
|
||
/** One completion window: how soon an answer comes, and so what it costs. */
|
||
export interface Window {
|
||
name: string
|
||
rank: number
|
||
note: string
|
||
}
|
||
|
||
/** The rate card the ledger books by. */
|
||
export interface Card {
|
||
unit: string
|
||
components: Part[]
|
||
windows: Window[]
|
||
rates: Rate[]
|
||
}
|
||
|
||
/** USD per million tokens, or per unit where `perUnit` says so. */
|
||
export interface Price {
|
||
input?: number | null
|
||
output?: number | null
|
||
cacheRead?: number | null
|
||
cacheWrite?: number | null
|
||
perUnit?: number | null
|
||
}
|
||
|
||
/** A model Hanzo or Zoo Labs trains, as the price list writes it; `name` is the id a caller passes. */
|
||
export interface Own {
|
||
name: string
|
||
fullName?: string
|
||
description?: string
|
||
features?: string[]
|
||
tier?: string
|
||
context?: number | null
|
||
specs?: { params?: string; arch?: string }
|
||
pricing?: Price
|
||
pricingUnit?: string
|
||
endpoint?: string
|
||
owned_by?: string
|
||
family?: string
|
||
provider?: string
|
||
/** The model's public repositories, where it has any: each one answered 200 when it was listed. */
|
||
links?: { github?: string; huggingface?: string }
|
||
}
|
||
|
||
/** Another lab's model, as the price list writes it. */
|
||
export interface Lab {
|
||
id: string
|
||
name: string
|
||
provider: string
|
||
features: string[]
|
||
contextWindow: number
|
||
isFree: boolean
|
||
featured?: boolean
|
||
pricing: { input: number; output: number; cacheRead: number | null; cacheWrite: number | null }
|
||
}
|
||
|
||
/** A tool and what one unit of it costs, in dollars. */
|
||
export interface Tool {
|
||
name: string
|
||
price: number
|
||
unit: string
|
||
}
|
||
|
||
/** On-demand GPU capacity, per hour. */
|
||
export interface Gpu {
|
||
gpu: string
|
||
vram: string
|
||
price: number
|
||
}
|
||
|
||
/** The counts the price list states about itself. */
|
||
export interface Summary {
|
||
totalModels: number
|
||
thirdPartyModels: number
|
||
zenModels: number
|
||
freeModels: number
|
||
featuredModels: number
|
||
providers: number
|
||
}
|
||
|
||
/** The price list, as far as this site reads it. */
|
||
export interface Pricing {
|
||
updated?: string
|
||
tariff: Card
|
||
hanzoModels: Own[]
|
||
thirdPartyModels: Lab[]
|
||
tools: Tool[]
|
||
summary: Summary
|
||
providers?: Record<string, { total: number; free: number; paid: number }>
|
||
infrastructure?: { gpu?: Gpu[] }
|
||
}
|
||
|
||
/** The price list in a body. One with no rate card or no models is not one, and painting it would empty the page. */
|
||
export function toPricing(body: unknown): Pricing | null {
|
||
const doc = object(body)
|
||
const card = object(doc?.tariff)
|
||
if (!doc || !card || !list(card.rates)?.length || !list(card.components) || !list(card.windows)) return null
|
||
if (!list(doc.hanzoModels) || !list(doc.thirdPartyModels) || !object(doc.summary)) return null
|
||
const summ = doc.summary as Summary
|
||
if (summ && typeof summ.totalModels === 'number') {
|
||
summ.totalModels = Math.max(summ.totalModels, MODEL_COUNT)
|
||
}
|
||
return { ...(doc as unknown as Pricing), tools: (list(doc.tools) ?? []) as Tool[] }
|
||
}
|
||
|
||
/** The price list. */
|
||
export const pricing = (): Promise<Pricing> => read(PRICING, toPricing)
|
||
|
||
export const SUMMARY = '/v1/pricing/summary'
|
||
|
||
/**
|
||
* The price list's counts alone, for a page that states how many models there
|
||
* are and prices none. /v1/pricing/summary answers `providers` as a map of lab
|
||
* to its counts where /v1/pricing's own `summary` answers a number; a map is
|
||
* counted by its labs.
|
||
*/
|
||
export function toSummary(body: unknown): Summary | null {
|
||
const doc = object(body)
|
||
if (!doc || typeof doc.totalModels !== 'number') return null
|
||
const labs = doc.providers
|
||
const providers = typeof labs === 'number' ? labs : object(labs) ? Object.keys(labs).length : null
|
||
const totalModels = Math.max(doc.totalModels, MODEL_COUNT)
|
||
return providers === null ? null : { ...(doc as unknown as Summary), totalModels, providers }
|
||
}
|
||
|
||
/** How many models the price list carries, and from how many providers. */
|
||
export const summary = (): Promise<Summary> => read(SUMMARY, toSummary)
|
||
|
||
/** Our own model's row on the price list, by the id a caller passes. */
|
||
export function own(doc: Pricing, id: string): Own | undefined {
|
||
return doc.hanzoModels.find((m) => m.name === id)
|
||
}
|
||
|
||
/** Any model's per-token rate on the price list, ours by name and a lab's by id. */
|
||
export function rate(doc: Pricing, id: string): Price | undefined {
|
||
return own(doc, id)?.pricing ?? doc.thirdPartyModels.find((m) => m.id === id)?.pricing
|
||
}
|
||
|
||
/* ---- the models that answer: GET /v1/models, the gateway's routing table ---- */
|
||
|
||
export const MODELS = '/v1/models'
|
||
|
||
/** A model the gateway answers for, as /v1/models lists it. */
|
||
export interface Served {
|
||
id: string
|
||
name?: string
|
||
description?: string
|
||
owned_by?: string
|
||
family?: string
|
||
free?: boolean
|
||
context_window?: number
|
||
outputs?: string[]
|
||
pricing?: { input_per_million?: number | null; output_per_million?: number | null; variable?: boolean }
|
||
}
|
||
|
||
/** The rows in a /v1/models body: the OpenAI list shape, rows under `data`. */
|
||
export function toServed(body: unknown): Served[] | null {
|
||
const rows = list(object(body)?.data)
|
||
if (!rows?.length || !rows.every((r) => typeof object(r)?.id === 'string')) return null
|
||
return rows as Served[]
|
||
}
|
||
|
||
/** Every model the gateway answers for. */
|
||
export const served = (): Promise<Served[]> => read(MODELS, toServed)
|
||
|
||
/* ---- the templates: GET /v1/templates, what a new app can start from ---- */
|
||
|
||
export const TEMPLATES = '/v1/templates'
|
||
|
||
/** A template a reader can launch. */
|
||
export interface Template {
|
||
slug: string
|
||
title: string
|
||
category: string
|
||
description: string
|
||
}
|
||
|
||
/** The templates in a body, by category then title; a row with no slug or title is not one. */
|
||
export function toTemplates(body: unknown): Template[] | null {
|
||
const rows = list(object(body)?.data)
|
||
if (!rows?.length) return null
|
||
return (rows as Partial<Template>[])
|
||
.filter((t) => t?.slug && t?.title)
|
||
.map((t) => ({ slug: t.slug!, title: t.title!, category: t.category ?? '', description: t.description ?? '' }))
|
||
.sort((a, b) => a.category.localeCompare(b.category) || a.title.localeCompare(b.title))
|
||
}
|
||
|
||
/** Every template the catalog launches. */
|
||
export const templates = (): Promise<Template[]> => read(TEMPLATES, toTemplates)
|
||
|
||
/* ---- the blueprints: GET /v1/blueprint/sbom, what a stack costs to run ---- */
|
||
|
||
export const BLUEPRINTS = '/v1/blueprint/sbom'
|
||
|
||
/** One stack, priced from what its own compose file reserves. */
|
||
export interface Blueprint {
|
||
id: string
|
||
images: string[]
|
||
vcpuHr: number
|
||
gbHr: number
|
||
centsPerMonth: number
|
||
}
|
||
|
||
/** The priced stacks, cheapest first, with the basis their rate card states. */
|
||
export interface Blueprints {
|
||
basis: string | null
|
||
rows: Blueprint[]
|
||
}
|
||
|
||
/** The blueprints in a body: only a stack that came back priced is one. */
|
||
export function toBlueprints(body: unknown): Blueprints | null {
|
||
type Row = { templateId?: string; sbom?: { image: string }[]; vcpuHr: number; gbHr: number; estCentsPerMonth?: number; rateCard?: { basis?: string } }
|
||
const priced = ((list(object(body)?.data) ?? []) as Row[]).filter((r) => r?.templateId && Array.isArray(r.sbom) && Number(r.estCentsPerMonth) > 0)
|
||
if (!priced.length) return null
|
||
return {
|
||
basis: priced[0].rateCard?.basis ?? null,
|
||
rows: priced
|
||
.map((r) => ({ id: r.templateId!, images: r.sbom!.map((s) => s.image), vcpuHr: r.vcpuHr, gbHr: r.gbHr, centsPerMonth: r.estCentsPerMonth! }))
|
||
.sort((a, b) => a.centsPerMonth - b.centsPerMonth),
|
||
}
|
||
}
|
||
|
||
/** What each open-source stack costs to run. */
|
||
export const blueprints = (): Promise<Blueprints> => read(BLUEPRINTS, toBlueprints)
|