mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
The plans commerce serves name the team a per-seat plan is a seat type of (group). The Team card lists every seat type of its group with its own price a member a month, and what each beyond the standard seat adds, in place of one headline price; a seat type draws no card of its own. Until the catalog sells a second seat type the card is as it was.
185 lines
12 KiB
TypeScript
185 lines
12 KiB
TypeScript
import type { Page, Route } from '@playwright/test'
|
|
|
|
/**
|
|
* THE BACKEND, AS A GATE SERVES IT.
|
|
*
|
|
* A page reads its plans, rates and counts from api.hanzo.ai when it opens
|
|
* (lib/api) and keeps no copy, so a gate proves two things: that a page draws
|
|
* exactly what it was served, and what it says when nothing answers. The
|
|
* answers below are invented — a Pro at $23, an Enso at $7 → $31 — numbers that
|
|
* exist nowhere in the source, so a page showing one can only have read it here.
|
|
* (scripts/audit-price-literals.mjs reads e2e/ as fixtures for that reason.)
|
|
*/
|
|
|
|
/** A plan row as GET /v1/billing/plans serves it: money in cents. */
|
|
export type Row = {
|
|
slug: string
|
|
name: string
|
|
description: string
|
|
category: string
|
|
price: number
|
|
priceAnnual?: number | null
|
|
annualTotal?: number
|
|
perSeat?: boolean
|
|
/** The team a per-seat plan is a seat type of: plans of one group are sold on one subscription. */
|
|
group?: string
|
|
contactSales?: boolean
|
|
popular?: boolean
|
|
limits?: Record<string, number>
|
|
features: string[]
|
|
}
|
|
|
|
/** The plan authority's answer: the three families the pages sell, and one they never draw. */
|
|
export const PLANS: Row[] = [
|
|
{ slug: 'free', name: 'Free', description: 'Fixture: the free rung.', category: 'personal', price: 0, priceAnnual: 0, features: ['Fixture free line'], limits: { agents: 1 } },
|
|
{ slug: 'dev', name: 'Pro', description: 'Fixture: one person, every model.', category: 'personal', price: 2300, priceAnnual: 1900, annualTotal: 22800, popular: true, features: ['Fixture pro line'], limits: { agents: 10 } },
|
|
{ slug: 'max-5x', name: 'Max 5x', description: 'Fixture all-day: 5x the usage of Pro.', category: 'personal', price: 11100, priceAnnual: 9250, annualTotal: 111000, features: ['Fixture max line', '5x more usage than Pro', '5x fixture line only this tier has'], limits: { agents: 10 } },
|
|
{ slug: 'max-20x', name: 'Max 20x', description: 'Fixture heaviest: 20x the usage of Pro.', category: 'personal', price: 22200, priceAnnual: 18500, annualTotal: 222000, features: ['Fixture max line', '20x more usage than Pro'], limits: { agents: 10 } },
|
|
{ slug: 'team', name: 'Team', description: 'Fixture: by the seat.', category: 'team', price: 2700, priceAnnual: 2250, annualTotal: 27000, perSeat: true, features: ['Fixture team line'], limits: { minSeats: 3, maxMembers: 100 } },
|
|
{ slug: 'enterprise', name: 'Enterprise', description: 'Fixture: by conversation.', category: 'enterprise', price: 0, priceAnnual: null, contactSales: true, features: ['Fixture enterprise line'], limits: { agents: -1 } },
|
|
{ slug: 'dns-pro', name: 'DNS Pro', description: 'Fixture: another catalog.', category: 'dns', price: 500, priceAnnual: 400, annualTotal: 4800, features: ['Fixture dns line'], limits: { zones: 25 } },
|
|
]
|
|
|
|
/** Dollars as the cards write them: whole dollars bare, anything else to the cent. */
|
|
export const money = (cents: number) => {
|
|
const n = cents / 100
|
|
return Number.isInteger(n) ? `$${n.toLocaleString('en-US')}` : `$${n.toFixed(2)}`
|
|
}
|
|
|
|
/** One rate-card line: micro-USD per unit. */
|
|
const line = (name: string, title: string, component: string, rate_micro_usd: number, per: string) => ({ name, title, component, rate_micro_usd, per, note: `Fixture: ${title}.` })
|
|
|
|
/** The price list's answer (GET /v1/pricing): the rate card, our models, two labs, the tools and the counts. */
|
|
export const PRICING = {
|
|
updated: '2026-10-01T00:00:00Z',
|
|
tariff: {
|
|
unit: 'micro_usd',
|
|
components: [
|
|
{ name: 'model', title: 'Model inference', basis: 'per token, five tiers', quoted: true, tiers: ['input', 'cache_read', 'cache_write', 'output', 'reasoning'], note: 'Fixture: tokens.' },
|
|
{ name: 'computer', title: 'Computer', basis: 'per second, while running', quoted: true, note: 'Fixture: seconds.' },
|
|
{ name: 'web', title: 'Web tools', basis: 'per call', quoted: false, note: 'Fixture: calls.' },
|
|
{ name: 'media', title: 'Media generation', basis: 'per finished job', quoted: false, note: 'Fixture: jobs.' },
|
|
],
|
|
windows: [
|
|
{ name: 'immediate', rank: 0, note: 'Fixture: now.' },
|
|
{ name: 'soon', rank: 1, note: 'Fixture: soon.' },
|
|
{ name: 'eventually', rank: 2, note: 'Fixture: later.' },
|
|
],
|
|
rates: [
|
|
line('vcpu', 'vCPU', 'computer', 41_000, 'vcpu-hour'),
|
|
line('memory', 'Memory', 'computer', 5_300, 'gib-hour'),
|
|
line('web_search', 'Web search', 'web', 3_100, 'call'),
|
|
line('web_fetch', 'Web fetch', 'web', 700, 'call'),
|
|
],
|
|
},
|
|
hanzoModels: [
|
|
{ name: 'enso', fullName: 'Enso', description: 'Fixture: the default.', features: ['Fixture enso'], tier: 'ultra max', context: 1_000_000, owned_by: 'hanzo', family: 'enso', pricing: { input: 7, output: 31, cacheRead: null, cacheWrite: null } },
|
|
{ name: 'enso-flash', fullName: 'Enso Flash', description: 'Fixture: the fast one.', features: ['Fixture flash'], tier: 'pro', context: 262_144, owned_by: 'hanzo', family: 'enso', pricing: { input: 3, output: 9, cacheRead: null, cacheWrite: null } },
|
|
{ name: 'enso-ultra', fullName: 'Enso Ultra', description: 'Fixture: the top.', features: ['Fixture ultra'], tier: 'ultra max', context: 1_000_000, owned_by: 'hanzo', family: 'enso', pricing: { input: 11, output: 47, cacheRead: null, cacheWrite: null } },
|
|
{ name: 'kai', fullName: 'Kai', description: 'Fixture: decisions.', features: ['Decisions'], tier: 'starter', owned_by: 'hanzo', family: 'kai', endpoint: '/v1/decisions', pricingUnit: 'token', specs: { arch: 'decision' }, pricing: { input: 0.013, output: 0, cacheRead: null, cacheWrite: null } },
|
|
{ name: 'hanzo/kai', fullName: 'Kai', description: 'Fixture: decisions.', features: ['Decisions'], tier: 'starter', owned_by: 'hanzo', family: 'kai', endpoint: '/v1/decisions', pricingUnit: 'token', specs: { arch: 'decision' }, pricing: { input: 0.013, output: 0, cacheRead: null, cacheWrite: null } },
|
|
{ name: 'typesafe/jev-1.13', fullName: 'Jev 1.13', description: 'Fixture: the rival.', features: ['Decisions'], tier: 'starter', owned_by: 'typesafe', provider: 'TypeSafe', endpoint: '/v1/decisions', pricingUnit: 'token', specs: { arch: 'decision' }, pricing: { input: 0.052, output: 0, cacheRead: null, cacheWrite: null } },
|
|
{ name: 'zen5', fullName: 'Zen5', description: 'Fixture: open weights.', features: ['Fixture zen'], tier: 'pro', context: 1_000_000, owned_by: 'zenlm', family: 'zen', specs: { params: '35B', arch: 'MoE' }, pricing: { input: 1.7, output: 5.9, cacheRead: null, cacheWrite: null } },
|
|
],
|
|
thirdPartyModels: [
|
|
{ id: 'anthropic/claude-opus-4.6', name: 'Claude Opus 4.6', provider: 'Anthropic', features: ['Fixture lab'], contextWindow: 1_000_000, isFree: false, featured: true, pricing: { input: 6.1, output: 29, cacheRead: null, cacheWrite: null } },
|
|
{ id: 'openai/gpt-5', name: 'GPT-5', provider: 'OpenAI', features: ['Fixture lab'], contextWindow: 400_000, isFree: false, pricing: { input: 1.9, output: 13, cacheRead: null, cacheWrite: null } },
|
|
],
|
|
tools: [
|
|
{ name: 'Web Search', price: 0.009, unit: 'per call' },
|
|
{ name: 'Code Interpreter', price: 0.07, unit: 'per session minute' },
|
|
],
|
|
summary: { totalModels: 777, thirdPartyModels: 700, zenModels: 70, freeModels: 77, featuredModels: 7, providers: 66 },
|
|
providers: { Anthropic: { total: 1, free: 0, paid: 1 }, OpenAI: { total: 1, free: 0, paid: 1 } },
|
|
infrastructure: { gpu: [{ gpu: '1x H100', name: 'Fixture GPU', vram: '80 GB', price: 4.44 }] },
|
|
}
|
|
|
|
/** The price list's counts alone (GET /v1/pricing/summary): `providers` there is a map of lab to its counts, as the live one answers. */
|
|
export const SUMMARY = {
|
|
...PRICING.summary,
|
|
providers: Object.fromEntries(Array.from({ length: PRICING.summary.providers }, (_, i) => [`Fixture lab ${i}`, { total: 1, free: 0, paid: 1 }])),
|
|
}
|
|
|
|
/** The routing table's answer (GET /v1/models): what answers, a lab's rate as the gateway bills it. */
|
|
export const MODELS = {
|
|
object: 'list',
|
|
data: [
|
|
{ id: 'enso', owned_by: 'hanzo', family: 'enso', context_window: 1_000_000, outputs: ['text'] },
|
|
{ id: 'enso-flash', owned_by: 'hanzo', family: 'enso', context_window: 262_144, outputs: ['text'] },
|
|
{ id: 'kai', owned_by: 'hanzo', family: 'kai', outputs: ['decision'] },
|
|
{ id: 'hanzo/kai', owned_by: 'hanzo', family: 'kai', outputs: ['decision'] },
|
|
{ id: 'gpt-5', owned_by: 'hanzo', context_window: 400_000, outputs: ['text'] },
|
|
{ id: 'zen5', owned_by: 'zenlm', family: 'zen', context_window: 1_000_000, outputs: ['text'] },
|
|
{ id: 'anthropic/claude-opus-4.6', name: 'Claude Opus 4.6', owned_by: 'anthropic', context_window: 1_000_000, outputs: ['text'], pricing: { input_per_million: 6.4, output_per_million: 30.6 } },
|
|
{ id: 'anthropic/claude-sonnet-4.6', name: 'Claude Sonnet 4.6', owned_by: 'anthropic', context_window: 1_000_000, outputs: ['text'], pricing: { input_per_million: 3.3, output_per_million: 16.1 } },
|
|
{ id: 'openai/gpt-5', name: 'GPT-5', owned_by: 'openai', context_window: 400_000, outputs: ['text'], pricing: { input_per_million: 2.1, output_per_million: 13.7 } },
|
|
{ id: 'meta-llama/llama-4-scout:free', name: 'Llama 4 Scout (free)', owned_by: 'meta-llama', context_window: 131_072, outputs: ['text'], pricing: { input_per_million: 0, output_per_million: 0 } },
|
|
],
|
|
}
|
|
|
|
/** The template catalog's answer (GET /v1/templates): twelve to draw, and one the shelf hides. */
|
|
export const TEMPLATES = {
|
|
data: [
|
|
...['studio', 'oasis', 'vault', 'drive', 'savor', 'prism', 'kinetic', 'portfolio', 'pixel', 'hygge', 'solo', 'loop'].map((slug, i) => ({
|
|
slug,
|
|
title: `Fixture ${slug}`,
|
|
category: i % 2 ? 'AI/SaaS' : 'Portfolio',
|
|
description: `Fixture: the ${slug} template.`,
|
|
})),
|
|
{ slug: 'construct', title: 'Fixture construct', category: 'AI/SaaS', description: 'Fixture: hidden from the shelf.' },
|
|
],
|
|
}
|
|
|
|
/** The blueprint plane's answer (GET /v1/blueprint/sbom): two stacks priced, one not. */
|
|
export const BLUEPRINTS = {
|
|
data: [
|
|
{ templateId: 'gitea', sbom: [{ image: 'gitea/gitea:1.22' }, { image: 'postgres:16-alpine' }], vcpuHr: 1.25, gbHr: 2.5, estCentsPerMonth: 1717, rateCard: { basis: 'Fixture basis' } },
|
|
{ templateId: 'ghost', sbom: [{ image: 'ghost:5-alpine' }, { image: 'mysql:8' }], vcpuHr: 1.5, gbHr: 2.5, estCentsPerMonth: 2929, rateCard: { basis: 'Fixture basis' } },
|
|
{ templateId: 'unpriced', sbom: [{ image: 'busybox' }], vcpuHr: 0, gbHr: 0, estCentsPerMonth: 0 },
|
|
],
|
|
}
|
|
|
|
/** The reads a page makes, by path, and what a gate answers each with. */
|
|
const ANSWERS: Record<string, unknown> = {
|
|
'/v1/billing/plans': PLANS,
|
|
'/v1/pricing': PRICING,
|
|
'/v1/pricing/summary': SUMMARY,
|
|
'/v1/models': MODELS,
|
|
'/v1/templates': TEMPLATES,
|
|
'/v1/blueprint/sbom': BLUEPRINTS,
|
|
}
|
|
|
|
export type Read = keyof typeof ANSWERS
|
|
|
|
/** Which of the reads a request is, or null for any other. */
|
|
const which = (url: string): string | null => {
|
|
const path = new URL(url).pathname
|
|
return path in ANSWERS ? path : null
|
|
}
|
|
|
|
/**
|
|
* Answer the page's reads: the fixtures above, `with` in place of any of them.
|
|
* Returns the paths the page asked for, in order, so a gate can say the page
|
|
* read the backend rather than something it carried.
|
|
*/
|
|
export async function serve(page: Page, edits: Partial<Record<Read, unknown>> = {}): Promise<string[]> {
|
|
const asked: string[] = []
|
|
await page.route(
|
|
(u) => which(u.toString()) !== null,
|
|
(route: Route) => {
|
|
const path = which(route.request().url())!
|
|
asked.push(path)
|
|
return route.fulfill({ json: path in edits ? edits[path as Read] : ANSWERS[path] })
|
|
},
|
|
)
|
|
return asked
|
|
}
|
|
|
|
/** No answer at all, as when api.hanzo.ai is down: every read fails at the network. */
|
|
export async function block(page: Page): Promise<void> {
|
|
await page.route(
|
|
(u) => which(u.toString()) !== null,
|
|
(route) => route.abort('connectionrefused'),
|
|
)
|
|
}
|