Skip to content

g1t/apps/sudo/app/lib/models.ts

235 lines12,279 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Models: g1t keeps up with new models, and staff choose each default in sudo1/**
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 */
7import type { CatalogueModel, ModelDefault, ModelPrices, ResolvedModel } from "@g1t/contracts";
8
9import type { Parsed } from "./forms.ts";
10
11/** The longest reason billing keeps. */
12export const MAX_REASON = 500;
13
14/** A model purpose: what it is called and what it is for. */
15export type PurposeInfo = { purpose: string; label: string; about: string };
16
17/** The model purposes, in the order the page shows them. */
18export 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. */
31export 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
40export 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
47export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
48
49/** What a purpose is called anywhere on the page or in a notice. */
50export 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. */
58export 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`. */
63export 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 */
75export 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. */
84export 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
92export 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. */
101export 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. */
118export 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
125export 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 */
132export 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. */
165export 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 */
172export 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`. */
197export 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. */
204export 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. */
225export 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. */
230export 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}