Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

352 lines · 17.5 KBRaw History
1/**
2 * Token usage from model responses, read by the model proxy as they stream
3 * through it, so a run's token budget is charged by Diffinery and not trusted
4 * to the agent. Understands Anthropic Messages, OpenAI Responses and Chat
5 * Completions, streamed (SSE) or not.
6 */
7
8export interface Usage {
9 /** Input tokens read without a cache. */
10 input: number;
11 output: number;
12 cacheRead: number;
13 cacheWrite: number;
14}
15
16export const NO_USAGE: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
17
18/**
19 * Tokens as a budget counts them: cache reads cost about a tenth of other
20 * input with every provider, so they count as a tenth.
21 */
22export const weighted = (u: Usage): number => u.input + u.cacheWrite + u.output + Math.ceil(u.cacheRead / 10);
23
24const n = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
25
26/** The usage one response object (or stream event) reports, if any. */
27export function usageOf(o: unknown): Usage | null {
28 if (!o || typeof o !== "object") return null;
29 const x = o as Record<string, unknown>;
30 const u = (x.usage ?? (x.message as Record<string, unknown> | undefined)?.usage ?? (x.response as Record<string, unknown> | undefined)?.usage) as Record<string, unknown> | undefined;
31 if (!u || typeof u !== "object") return null;
32 if ("prompt_tokens" in u || "completion_tokens" in u) {
33 // Chat Completions: prompt_tokens includes cached ones.
34 const cached = n((u.prompt_tokens_details as Record<string, unknown> | undefined)?.cached_tokens);
35 return { input: Math.max(n(u.prompt_tokens) - cached, 0), output: n(u.completion_tokens), cacheRead: cached, cacheWrite: 0 };
36 }
37 const details = u.input_tokens_details as Record<string, unknown> | undefined;
38 if (details && "cached_tokens" in details) {
39 // OpenAI Responses: input_tokens includes cached ones.
40 const cached = n(details.cached_tokens);
41 return { input: Math.max(n(u.input_tokens) - cached, 0), output: n(u.output_tokens), cacheRead: cached, cacheWrite: 0 };
42 }
43 // Anthropic: input_tokens excludes cache reads and writes.
44 return { input: n(u.input_tokens), output: n(u.output_tokens), cacheRead: n(u.cache_read_input_tokens), cacheWrite: n(u.cache_creation_input_tokens) };
45}
46
47/** Events of one response report running totals (Anthropic's message_start, then message_delta): keep the largest of each. */
48const fold = (a: Usage | null, b: Usage | null): Usage | null => (!a ? b : b ? { input: Math.max(a.input, b.input), output: Math.max(a.output, b.output), cacheRead: Math.max(a.cacheRead, b.cacheRead), cacheWrite: Math.max(a.cacheWrite, b.cacheWrite) } : a);
49
50const parse = (s: string): unknown => {
51 try {
52 return JSON.parse(s);
53 } catch {
54 return null;
55 }
56};
57
58/** The most of a non-streamed body that is read for its usage. */
59const MAX_JSON = 16 * 1024 * 1024;
60
61/** An SSE line longer than this can't be a usage event: it is dropped rather than buffered. */
62const MAX_SSE_LINE = 1024 * 1024;
63
64/** Read a response body (a tee of what goes back to the agent) for the usage it reports; null when it reports none. */
65export async function readUsage(body: ReadableStream<Uint8Array>, contentType: string): Promise<Usage | null> {
66 const sse = contentType.includes("text/event-stream");
67 const reader = body.pipeThrough(new TextDecoderStream()).getReader();
68 let usage: Usage | null = null;
69 let buf = "";
70 let size = 0;
71 try {
72 for (;;) {
73 const { done, value } = await reader.read();
74 if (done) break;
75 if (!sse) {
76 size += value.length;
77 if (size <= MAX_JSON) buf += value;
78 continue;
79 }
80 buf += value;
81 let nl: number;
82 while ((nl = buf.indexOf("\n")) >= 0) {
83 const line = buf.slice(0, nl).trim();
84 buf = buf.slice(nl + 1);
85 if (line.startsWith("data:")) usage = fold(usage, usageOf(parse(line.slice(5).trim())));
86 }
87 if (buf.length > MAX_SSE_LINE) buf = "";
88 }
89 } finally {
90 reader.releaseLock();
91 }
92 if (sse) return buf.trim().startsWith("data:") ? fold(usage, usageOf(parse(buf.trim().slice(5).trim()))) : usage;
93 return size <= MAX_JSON ? fold(usage, usageOf(parse(buf))) : usage;
94}
95
96export const formatTokens = (t: number): string => (t >= 1_000_000 ? `${+(t / 1_000_000).toFixed(1)}M` : t >= 1000 ? `${Math.round(t / 1000)}k` : String(t));
97
98/**
99 * What a CLI reports about its own usage in one line of its output (phase 5a), for runs whose model
100 * requests don't pass Diffinery: a terminal's signed-in subscription. Claude Code's `result` event
101 * gives its run's totals and cost (`set`); each Codex `turn.completed` gives one turn's tokens (`add`).
102 */
103export function reportedUsage(runtime: string, line: string): { usage: Usage; costUsd: number | null; mode: "set" | "add" } | null {
104 const claude = runtime === "claude-code" && line.startsWith('{"type":"result"');
105 const codex = runtime === "codex" && line.startsWith('{"type":"turn.completed"');
106 if (!claude && !codex) return null;
107 const j = parse(line) as Record<string, unknown> | null;
108 if (!j || typeof j !== "object") return null;
109 const u = j.usage as Record<string, unknown> | undefined;
110 if (!u || typeof u !== "object") return null;
111 if (codex) {
112 // Codex: input_tokens includes the cached ones.
113 const cached = n(u.cached_input_tokens);
114 return { usage: { input: Math.max(n(u.input_tokens) - cached, 0), output: n(u.output_tokens), cacheRead: cached, cacheWrite: 0 }, costUsd: null, mode: "add" };
115 }
116 const cost = typeof j.total_cost_usd === "number" && Number.isFinite(j.total_cost_usd) && j.total_cost_usd >= 0 ? j.total_cost_usd : null;
117 return { usage: { input: n(u.input_tokens), output: n(u.output_tokens), cacheRead: n(u.cache_read_input_tokens), cacheWrite: n(u.cache_creation_input_tokens) }, costUsd: cost, mode: "set" };
118}
119
120/**
121 * A run's usage as people see it (phase 5a). Requests through the model proxy are counted by Diffinery
122 * (`proxy`); a run with none, in a terminal on a subscription, has only what its CLI reported (`runtime`).
123 * The cost is always the CLI's own figure (Claude Code's), never computed here.
124 */
125export interface RunUsage {
126 source: "proxy" | "runtime";
127 requests: number;
128 input: number;
129 output: number;
130 cacheRead: number;
131 cacheWrite: number;
132 costUsd: number | null;
133}
134
135export function runUsage(r: {
136 model_calls: number;
137 input_tokens: number;
138 output_tokens: number;
139 cache_read_tokens: number;
140 cache_write_tokens: number;
141 reported_input: number;
142 reported_output: number;
143 reported_cache_read: number;
144 reported_cache_write: number;
145 cost_usd: number | null;
146}): RunUsage | null {
147 const costUsd = r.cost_usd ?? null;
148 if (r.model_calls > 0) return { source: "proxy", requests: r.model_calls, input: r.input_tokens, output: r.output_tokens, cacheRead: r.cache_read_tokens, cacheWrite: r.cache_write_tokens, costUsd };
149 if (r.reported_input || r.reported_output || r.reported_cache_read || r.reported_cache_write || costUsd !== null)
150 return { source: "runtime", requests: 0, input: r.reported_input, output: r.reported_output, cacheRead: r.reported_cache_read, cacheWrite: r.reported_cache_write, costUsd };
151 return null;
152}
153
154/** Several runs' usage added up; the cost counts the runs that reported one, and is null when none did. */
155export function totalUsage(list: Array<RunUsage | null>): Omit<RunUsage, "source"> | null {
156 const xs = list.filter((u): u is RunUsage => !!u);
157 if (!xs.length) return null;
158 const costs = xs.map((u) => u.costUsd).filter((c): c is number => c !== null);
159 return {
160 requests: xs.reduce((a, u) => a + u.requests, 0),
161 input: xs.reduce((a, u) => a + u.input, 0),
162 output: xs.reduce((a, u) => a + u.output, 0),
163 cacheRead: xs.reduce((a, u) => a + u.cacheRead, 0),
164 cacheWrite: xs.reduce((a, u) => a + u.cacheWrite, 0),
165 costUsd: costs.length ? costs.reduce((a, c) => a + c, 0) : null,
166 };
167}
168
169/** Usage as the REST API and MCP give it, with GitHub-style snake_case names. */
170export const usageJson = (u: Omit<RunUsage, "source"> & { source?: RunUsage["source"] }) => ({
171 ...(u.source ? { source: u.source } : {}),
172 requests: u.requests,
173 input_tokens: u.input,
174 output_tokens: u.output,
175 cache_read_tokens: u.cacheRead,
176 cache_write_tokens: u.cacheWrite,
177 cost_usd: u.costUsd === null ? null : Math.round(u.costUsd * 10_000) / 10_000,
178});
179
180/** A cost in dollars as a person reads it: cents, or less than a cent. */
181export const formatCost = (usd: number): string => (usd > 0 && usd < 0.01 ? "<$0.01" : `$${usd.toFixed(2)}`);
182
183/** The output an estimate assumes when a request doesn't cap it. */
184export const DEFAULT_MAX_OUTPUT = 32_000;
185/** What a compaction may write back, which its request can't cap: the most any current OpenAI model emits. */
186export const COMPACT_MAX_OUTPUT = 128_000;
187
188/** Endpoints that generate tokens, so each request books an estimate, with the fields that cap their output. */
189const GENERATES: Record<string, string[]> = {
190 "/v1/messages": ["max_tokens"],
191 "/v1/responses": ["max_output_tokens"],
192 "/v1/responses/compact": [],
193 "/v1/chat/completions": ["max_completion_tokens", "max_tokens"],
194 // OpenRouter's legacy text completions.
195 "/v1/completions": ["max_tokens"],
196};
197
198export interface PreparedRequest {
199 /** The body to forward (changed only to ask for usage). */
200 body: string;
201 /** What the request may cost at most, booked before it goes upstream; null for requests that generate nothing. */
202 estimate: Usage | null;
203 /** Why a review may not send this request, if it may not. */
204 reviewRefuse?: string;
205 /** The body a review sends instead: every output cap set to the booked one, so the estimate holds. */
206 reviewBody?: string;
207 /** The model the request asks for (null when it names none), for runs pinned to one. */
208 model?: string | null;
209 /** A routing field the request sets, which a pinned run may not. */
210 routing?: string;
211}
212
213/**
214 * Request fields that choose other models, providers or paid extras (OpenRouter's fallbacks and routing,
215 * plugins, web search, and more answers to pick from): a review or a run pinned to a model may not set them,
216 * or its code could switch to a far pricier model than the one its budget was set for.
217 */
218const ROUTING = ["models", "route", "provider", "plugins", "web_search_options", "speed", "best_of"] as const;
219
220/**
221 * Why a run may not send a prepared request: a review never sets routing fields, and a run pinned to a model
222 * (one that goes through OpenRouter with a model chosen in Diffinery) asks only for that model.
223 */
224export function pinRefusal(p: Pick<PreparedRequest, "estimate" | "model" | "routing">, run: { review: boolean; model: string | null }): string | undefined {
225 const who = run.review ? "agent reviews and other runs with a token budget" : "this task";
226 if ((run.review || run.model) && p.routing) return `${p.routing} isn't available to ${who}: Diffinery pins the model and its routing`;
227 if (run.model && p.estimate && p.model !== run.model) return `this ${run.review ? "review" : "task"} runs on ${run.model}${p.model ? `, not ${p.model}` : ""}`;
228 return undefined;
229}
230
231/**
232 * The tools a review's requests may carry: tools the agent runs itself. Function and custom tools (and Anthropic's,
233 * which have no type), and the client-run built-ins, versioned or not (bash_20250124, text_editor_20250728, OpenAI's
234 * local_shell and apply_patch). Every other tool type runs on the provider and may cost extra beyond tokens (web
235 * search, web fetch, code execution, file search, MCP, and whatever providers add next), so it is dropped, with
236 * `mcp_servers`: a review's token budget then bounds what it spends.
237 */
238const CLIENT_TOOL = /^(function|custom|(bash|text_editor|computer|memory|local_shell|shell|apply_patch)(_\d+)?)$/;
239
240const typeOf = (t: unknown): unknown => (t && typeof t === "object" ? (t as { type?: unknown }).type : undefined);
241const clientTool = (t: unknown): boolean => {
242 const type = typeOf(t);
243 return !!t && typeof t === "object" && (type === undefined || (typeof type === "string" && CLIENT_TOOL.test(type)));
244};
245/** The name a tool or a tool choice gives: Anthropic's and Responses' `name`, or Chat Completions' `function.name`. */
246const nameOf = (t: unknown): unknown => (t && typeof t === "object" ? ((t as { name?: unknown }).name ?? ((t as { function?: { name?: unknown } }).function?.name)) : undefined);
247
248/** A request's fields with only client-run tools; the same object when it has nothing else. */
249function withoutPaidTools(x: Record<string, unknown>): Record<string, unknown> {
250 const tools = Array.isArray(x.tools) ? (x.tools as unknown[]) : null;
251 const kept = tools?.filter(clientTool) ?? [];
252 const dropTools = !!tools && kept.length !== tools.length;
253 if (!dropTools && x.mcp_servers === undefined) return x;
254 const out: Record<string, unknown> = { ...x };
255 delete out.mcp_servers;
256 if (dropTools) {
257 if (kept.length) out.tools = kept;
258 else delete out.tools;
259 // A tool choice that names a tool, or a tool type, that isn't kept would be refused upstream, as would any choice once no tools are left.
260 const choice = out.tool_choice;
261 if (!kept.length) delete out.tool_choice;
262 else if (choice && typeof choice === "object") {
263 const type = typeOf(choice);
264 const name = nameOf(choice);
265 const general = type === "auto" || type === "any" || type === "none";
266 const keptName = name !== undefined && kept.some((t) => nameOf(t) === name);
267 const keptType = typeof type === "string" && type !== "function" && type !== "custom" && type !== "tool" && kept.some((t) => typeOf(t) === type);
268 if (!general && !keptName && !keptType) delete out.tool_choice;
269 }
270 }
271 return out;
272}
273
274/** Responses API fields that pull in context stored at the provider, which the request's size doesn't show. */
275const STORED_CONTEXT = ["previous_response_id", "conversation", "prompt"] as const;
276
277/**
278 * Look at a model request before it goes upstream, so that every answer can
279 * be charged: its estimate (a token per 3 bytes of request, plus the output
280 * it allows) is booked up front and settled to the usage the answer reports,
281 * and streamed Chat Completions are asked to include usage. A review may not
282 * start a background response, which it could leave unfetched and so
283 * uncharged, pull in stored context its estimate can't see, or ask for more
284 * than one answer; a review's request that doesn't cap its output is
285 * capped at the estimate's DEFAULT_MAX_OUTPUT, and paid provider-side tools
286 * are stripped from it. A compaction can't be capped,
287 * so it books COMPACT_MAX_OUTPUT.
288 */
289export function prepareRequest(method: string, path: string, body: string, size: number): PreparedRequest {
290 const caps = method === "POST" && Object.hasOwn(GENERATES, path) ? GENERATES[path]! : null;
291 if (!caps) return { body, estimate: null };
292 const json = parse(body);
293 const x = json && typeof json === "object" && !Array.isArray(json) ? (json as Record<string, unknown>) : null;
294 // A request can set several caps (Chat Completions takes two): book the largest.
295 const capped = x ? caps.map((k) => n(x[k])).filter((v) => v > 0) : [];
296 const cap = capped.length ? Math.max(...capped) : undefined;
297 const estimate: Usage = { input: Math.ceil(size / 3), output: cap ?? (caps.length ? DEFAULT_MAX_OUTPUT : COMPACT_MAX_OUTPUT), cacheRead: 0, cacheWrite: 0 };
298 if (!x) return { body, estimate };
299 const stored = path.startsWith("/v1/responses") ? STORED_CONTEXT.find((k) => x[k] != null) : undefined;
300 const reviewRefuse =
301 x.background === true
302 ? "background responses aren't available to agent reviews and other runs with a token budget"
303 : stored
304 ? `${stored} isn't available to agent reviews and other runs with a token budget: send the whole input instead`
305 : x.n != null && x.n !== 1
306 ? "more than one answer per request (n) isn't available to agent reviews and other runs with a token budget"
307 : undefined;
308 let fields = x;
309 if (path === "/v1/chat/completions" && x.stream === true) {
310 const so = x.stream_options && typeof x.stream_options === "object" ? (x.stream_options as Record<string, unknown>) : {};
311 if (so.include_usage !== true) fields = { ...x, stream_options: { ...so, include_usage: true } };
312 }
313 const out: PreparedRequest = { body: fields === x ? body : JSON.stringify(fields), estimate, model: typeof x.model === "string" ? x.model : null };
314 if (reviewRefuse) out.reviewRefuse = reviewRefuse;
315 const routing = ROUTING.find((k) => x[k] != null);
316 if (routing) out.routing = routing;
317 // A review sends every cap as the one booked (the default when it sets none), and no paid provider-side tools.
318 const set = caps.filter((k) => x[k] !== undefined);
319 const recap = caps[0] && (!set.length || set.some((k) => x[k] !== estimate.output));
320 const unpaid = withoutPaidTools(fields);
321 if (recap || unpaid !== fields) out.reviewBody = JSON.stringify(recap ? { ...unpaid, ...Object.fromEntries((set.length ? set : [caps[0]!]).map((k) => [k, estimate.output])) } : unpaid);
322 return out;
323}
324
325/** Read a request body up to `max` bytes, counting as it reads; null when it is larger. */
326export async function readBody(body: ReadableStream<Uint8Array> | null, max: number): Promise<Uint8Array | null> {
327 if (!body) return new Uint8Array();
328 const chunks: Uint8Array[] = [];
329 let size = 0;
330 const reader = body.getReader();
331 try {
332 for (;;) {
333 const { done, value } = await reader.read();
334 if (done) break;
335 size += value.byteLength;
336 if (size > max) {
337 await reader.cancel().catch(() => undefined);
338 return null;
339 }
340 chunks.push(value);
341 }
342 } finally {
343 reader.releaseLock();
344 }
345 const out = new Uint8Array(size);
346 let at = 0;
347 for (const c of chunks) {
348 out.set(c, at);
349 at += c.byteLength;
350 }
351 return out;
352}