mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 04:20:39 +00:00
394 lines
14 KiB
TypeScript
394 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'
|
||
|
||
/**
|
||
* 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, 561)
|
||
}
|
||
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, 561)
|
||
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)
|