| 1 | /** |
| 2 | * Agents & models, as the page reads its forms and states its figures: |
| 3 | * prices per million tokens, each purpose's default, and what a change |
| 4 | * does to the cost of a typical run. Billing checks every value again. No |
| 5 | * Workers imports, so it can be tested under Node. |
| 6 | */ |
| 7 | import type { CatalogueModel, ModelDefault, ModelPrices, ResolvedModel } from "@g1t/contracts"; |
| 8 | |
| 9 | import type { Parsed } from "./forms.ts"; |
| 10 | |
| 11 | /** The longest reason billing keeps. */ |
| 12 | export const MAX_REASON = 500; |
| 13 | |
| 14 | /** A model purpose: what it is called and what it is for. */ |
| 15 | export type PurposeInfo = { purpose: string; label: string; about: string }; |
| 16 | |
| 17 | /** The model purposes, in the order the page shows them. */ |
| 18 | export const MODEL_PURPOSES: PurposeInfo[] = [ |
| 19 | { purpose: "tier_small", label: "Fast", about: "Auto's fast tier: catching up, answers, plans, small reviews." }, |
| 20 | { purpose: "tier_large", label: "Standard", about: "Auto's standard tier: making and revising changes, most reviews." }, |
| 21 | { purpose: "tier_frontier", label: "Most capable", about: "Auto's top tier: large reviews, architecture, work that failed twice." }, |
| 22 | { purpose: "background", label: "Background", about: "The harness's own small tasks in every run, such as titles and summaries." }, |
| 23 | { |
| 24 | purpose: "gateway_first", |
| 25 | label: "AI Gateway's first Claude", |
| 26 | about: "Listed first by GET /openai/v1/models, the one people start with.", |
| 27 | }, |
| 28 | ]; |
| 29 | |
| 30 | /** Each kind of agent job, as the page names it. */ |
| 31 | export const JOBS: { kind: string; label: string }[] = [ |
| 32 | { kind: "implement", label: "Making a change" }, |
| 33 | { kind: "revise", label: "Revising a change" }, |
| 34 | { kind: "answer", label: "Answering a question" }, |
| 35 | { kind: "review", label: "Reviewing" }, |
| 36 | { kind: "update", label: "Catching up" }, |
| 37 | { kind: "plan", label: "Planning" }, |
| 38 | ]; |
| 39 | |
| 40 | export const TIER_LABELS: Record<string, string> = { |
| 41 | small: "Fast", |
| 42 | large: "Standard", |
| 43 | frontier: "Most capable", |
| 44 | change: "By the change's size", |
| 45 | }; |
| 46 | |
| 47 | export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; |
| 48 | |
| 49 | /** What a purpose is called anywhere on the page or in a notice. */ |
| 50 | export function purposeLabel(purpose: string): string { |
| 51 | const model = MODEL_PURPOSES.find((p) => p.purpose === purpose); |
| 52 | if (model) return model.label; |
| 53 | const job = JOBS.find((j) => `job_${j.kind}` === purpose); |
| 54 | return job ? job.label : purpose; |
| 55 | } |
| 56 | |
| 57 | /** The catalogue models a purpose may be set to: available, priced Claude chat models. */ |
| 58 | export function choicesFor(catalogue: CatalogueModel[]): CatalogueModel[] { |
| 59 | return catalogue.filter((m) => m.status === "available" && m.priced && m.provider === "anthropic" && (m.kind ?? "chat") === "chat"); |
| 60 | } |
| 61 | |
| 62 | /** A price per million tokens, in dollars, to show or to fill a field: `0.125`, `2`, `12.50`. */ |
| 63 | export function perMillion(micros: number | null | undefined): string { |
| 64 | if (micros == null) return ""; |
| 65 | const text = (Math.max(0, micros) / 1_000_000).toFixed(6).replace(/0+$/, "").replace(/\.$/, ""); |
| 66 | const [whole, cents] = text.split("."); |
| 67 | return cents && cents.length === 1 ? `${whole}.${cents}0` : text; |
| 68 | } |
| 69 | |
| 70 | /** |
| 71 | * Micros from a price per million tokens as typed: `0.125`, `$2`, `12.50`. |
| 72 | * Up to six decimal places (a millionth of a dollar per million tokens); |
| 73 | * an empty field is 0. |
| 74 | */ |
| 75 | export function parsePerMillion(input: string): number | null { |
| 76 | const text = input.trim().replace(/^\$/, "").replace(/,/g, ""); |
| 77 | if (text === "") return 0; |
| 78 | const match = /^(\d{1,4})(?:\.(\d{1,6}))?$/.exec(text); |
| 79 | if (!match) return null; |
| 80 | return Number(match[1]) * 1_000_000 + Number((match[2] ?? "").padEnd(6, "0")); |
| 81 | } |
| 82 | |
| 83 | /** The price fields of the approval form, by the name each is posted as. */ |
| 84 | export const PRICE_FIELDS: { name: string; key: keyof ModelPrices; label: string }[] = [ |
| 85 | { name: "input", key: "inputMicros", label: "Input" }, |
| 86 | { name: "output", key: "outputMicros", label: "Output" }, |
| 87 | { name: "cache_read", key: "cacheReadMicros", label: "Cache read" }, |
| 88 | { name: "cache_write", key: "cacheWriteMicros", label: "Cache write, 5 min" }, |
| 89 | { name: "cache_write_1h", key: "cacheWrite1hMicros", label: "Cache write, 1 h" }, |
| 90 | ]; |
| 91 | |
| 92 | export const OVER_FIELDS: { name: string; key: keyof ModelPrices; label: string }[] = [ |
| 93 | { name: "over_input", key: "overInputMicros", label: "Input" }, |
| 94 | { name: "over_output", key: "overOutputMicros", label: "Output" }, |
| 95 | { name: "over_cache_read", key: "overCacheReadMicros", label: "Cache read" }, |
| 96 | { name: "over_cache_write", key: "overCacheWriteMicros", label: "Cache write, 5 min" }, |
| 97 | { name: "over_cache_write_1h", key: "overCacheWrite1hMicros", label: "Cache write, 1 h" }, |
| 98 | ]; |
| 99 | |
| 100 | /** A model's prices as the catalogue has them, for the approval form. */ |
| 101 | export function pricesOf(model: CatalogueModel): ModelPrices { |
| 102 | return { |
| 103 | inputMicros: model.inputMicros, |
| 104 | outputMicros: model.outputMicros, |
| 105 | cacheReadMicros: model.cacheReadMicros, |
| 106 | cacheWriteMicros: model.cacheWriteMicros, |
| 107 | cacheWrite1hMicros: model.cacheWrite1hMicros ?? 0, |
| 108 | threshold: model.threshold ?? 0, |
| 109 | overInputMicros: model.overInputMicros ?? 0, |
| 110 | overOutputMicros: model.overOutputMicros ?? 0, |
| 111 | overCacheReadMicros: model.overCacheReadMicros ?? 0, |
| 112 | overCacheWriteMicros: model.overCacheWriteMicros ?? 0, |
| 113 | overCacheWrite1hMicros: model.overCacheWrite1hMicros ?? 0, |
| 114 | }; |
| 115 | } |
| 116 | |
| 117 | /** A reason as the forms take it: required, kept short. */ |
| 118 | export function parseReason(raw: string): Parsed<string> { |
| 119 | const reason = raw.trim(); |
| 120 | if (!reason) return { ok: false, error: "Say why, for whoever looks next." }; |
| 121 | if ([...reason].length > MAX_REASON) return { ok: false, error: `Keep the reason to ${MAX_REASON} characters.` }; |
| 122 | return { ok: true, value: reason }; |
| 123 | } |
| 124 | |
| 125 | export type Approval = { name: string; tierHint: string; prices: ModelPrices; reason: string }; |
| 126 | |
| 127 | /** |
| 128 | * The approval form: the name people see, the tier it suits, its prices |
| 129 | * per million tokens (and above a long-prompt threshold, if it has one), |
| 130 | * and why. Input is required; a chat model needs an output price too. |
| 131 | */ |
| 132 | export function parseApproval(form: FormData, kind: string): Parsed<Approval> { |
| 133 | const get = (name: string) => { |
| 134 | const value = form.get(name); |
| 135 | return typeof value === "string" ? value : ""; |
| 136 | }; |
| 137 | const name = get("name").trim(); |
| 138 | if (!name) return { ok: false, error: "Give the name people see, such as Claude Haiku 6." }; |
| 139 | if (name.length > 120) return { ok: false, error: "Keep the name to 120 characters." }; |
| 140 | const tierHint = get("tier_hint").trim(); |
| 141 | if (tierHint && !["small", "large", "frontier"].includes(tierHint)) return { ok: false, error: "Choose a tier it suits, or none." }; |
| 142 | const prices = {} as ModelPrices; |
| 143 | for (const field of [...PRICE_FIELDS, ...OVER_FIELDS]) { |
| 144 | const micros = parsePerMillion(get(field.name)); |
| 145 | if (micros == null) return { ok: false, error: `${field.label}: a price in dollars per million tokens, such as 0.125.` }; |
| 146 | prices[field.key] = micros; |
| 147 | } |
| 148 | const threshold = get("threshold").trim().replace(/,/g, ""); |
| 149 | if (threshold && !/^\d{1,9}$/.test(threshold)) return { ok: false, error: "The long-prompt threshold is a number of tokens, such as 200000." }; |
| 150 | prices.threshold = threshold ? Number(threshold) : 0; |
| 151 | if (prices.inputMicros === 0) return { ok: false, error: "Give the input price per million tokens." }; |
| 152 | if (kind === "chat" && prices.outputMicros === 0) return { ok: false, error: "Give the output price per million tokens." }; |
| 153 | // An hour-long write with no price of its own costs what a five-minute one does. |
| 154 | if (prices.cacheWrite1hMicros === 0) prices.cacheWrite1hMicros = prices.cacheWriteMicros; |
| 155 | const over = prices.overInputMicros + prices.overOutputMicros + prices.overCacheReadMicros + prices.overCacheWriteMicros; |
| 156 | if (prices.threshold === 0 && over > 0) return { ok: false, error: "Long-prompt prices need the prompt length they start above." }; |
| 157 | if (prices.threshold > 0 && prices.overInputMicros === 0) return { ok: false, error: "With a long-prompt threshold, give the prices above it." }; |
| 158 | if (prices.threshold > 0 && prices.overCacheWrite1hMicros === 0) prices.overCacheWrite1hMicros = prices.overCacheWriteMicros; |
| 159 | const reason = parseReason(get("reason")); |
| 160 | if (!reason.ok) return reason; |
| 161 | return { ok: true, value: { name, tierHint, prices, reason: reason.value } }; |
| 162 | } |
| 163 | |
| 164 | /** A default as the forms post it: a model, or a job's tier and effort. */ |
| 165 | export type DefaultChange = { purpose: string; model: string | null; tier: string | null; effort: string | null; reason: string }; |
| 166 | |
| 167 | /** |
| 168 | * One default's form: a model purpose takes one of `choices`; a job takes |
| 169 | * a tier (`change` only for reviews) and an effort, or none for the |
| 170 | * harness's own. |
| 171 | */ |
| 172 | export function parseDefault(form: FormData, choices: CatalogueModel[]): Parsed<DefaultChange> { |
| 173 | const get = (name: string) => { |
| 174 | const value = form.get(name); |
| 175 | return typeof value === "string" ? value.trim() : ""; |
| 176 | }; |
| 177 | const purpose = get("purpose"); |
| 178 | const reason = parseReason(get("reason")); |
| 179 | if (MODEL_PURPOSES.some((p) => p.purpose === purpose)) { |
| 180 | const model = get("model"); |
| 181 | if (!choices.some((m) => m.model === model)) return { ok: false, error: "Choose an available Claude model." }; |
| 182 | if (!reason.ok) return reason; |
| 183 | return { ok: true, value: { purpose, model, tier: null, effort: null, reason: reason.value } }; |
| 184 | } |
| 185 | const job = JOBS.find((j) => `job_${j.kind}` === purpose); |
| 186 | if (!job) return { ok: false, error: "That is not something a default is chosen for." }; |
| 187 | const tier = get("tier"); |
| 188 | const tiers = job.kind === "review" ? ["small", "large", "frontier", "change"] : ["small", "large", "frontier"]; |
| 189 | if (!tiers.includes(tier)) return { ok: false, error: `Choose ${job.kind === "review" ? "a tier, or by the change's size" : "a tier"}.` }; |
| 190 | const effort = get("effort"); |
| 191 | if (effort && !(EFFORTS as readonly string[]).includes(effort)) return { ok: false, error: "Effort is low, medium, high, xhigh or max, or the harness's own." }; |
| 192 | if (!reason.ok) return reason; |
| 193 | return { ok: true, value: { purpose, model: null, tier, effort: effort || null, reason: reason.value } }; |
| 194 | } |
| 195 | |
| 196 | /** How a default reads: a model's name, or `Fast, high effort`. */ |
| 197 | export function describeDefault(value: { model: string | null; tier: string | null; effort: string | null }, catalogue: CatalogueModel[]): string { |
| 198 | if (value.model) return catalogue.find((m) => m.model === value.model)?.name ?? value.model; |
| 199 | const tier = TIER_LABELS[value.tier ?? ""] ?? value.tier ?? "none"; |
| 200 | return value.effort ? `${tier}, ${value.effort} effort` : `${tier}, the harness's own effort`; |
| 201 | } |
| 202 | |
| 203 | /** What a change to a default does to a typical run's cost, in a sentence; null for a job. */ |
| 204 | export function impact(before: ResolvedModel | undefined, after: CatalogueModel | undefined, catalogue: CatalogueModel[]): string | null { |
| 205 | if (!after) return null; |
| 206 | const was = before?.model ? catalogue.find((m) => m.model === before.model!.model) : undefined; |
| 207 | const now = after.typicalRunMicros; |
| 208 | const money = (micros: number) => (micros < 1_000_000 ? `$${(micros / 1_000_000).toFixed(3)}` : `$${(micros / 1_000_000).toFixed(2)}`); |
| 209 | if (!was || was.typicalRunMicros <= 0) return `A typical run would cost about ${money(now)} on ${after.name}.`; |
| 210 | if (was.model === after.model) return `No change: ${after.name} already runs it, at about ${money(now)} a typical run.`; |
| 211 | const ratio = now / was.typicalRunMicros; |
| 212 | const change = Math.round((ratio - 1) * 100); |
| 213 | const direction = |
| 214 | ratio >= 2 |
| 215 | ? `${Number(ratio.toFixed(1))} times` |
| 216 | : change === 0 |
| 217 | ? "the same as" |
| 218 | : change > 0 |
| 219 | ? `${change}% more than` |
| 220 | : `${-change}% less than`; |
| 221 | return `A typical run: about ${money(now)} on ${after.name}, ${direction} ${money(was.typicalRunMicros)} on ${was.name}.`; |
| 222 | } |
| 223 | |
| 224 | /** The current default for each purpose, by purpose. */ |
| 225 | export function defaultsByPurpose(defaults: ModelDefault[]): Map<string, ModelDefault> { |
| 226 | return new Map(defaults.map((d) => [d.purpose, d])); |
| 227 | } |
| 228 | |
| 229 | /** A context window or output limit: `1M`, `200k`, or a dash when not known. */ |
| 230 | export function tokens(n: number): string { |
| 231 | if (!n) return "—"; |
| 232 | if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(1))}M`; |
| 233 | if (n >= 1_000) return `${Math.round(n / 1_000)}k`; |
| 234 | return String(n); |
| 235 | } |