Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ai-cost-tracker.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

ai-cost-tracker.tsBlame452 lines · 1 contributor
0c3eee5Claude1/**
2 * Per-call AI cost tracker. Every Claude caller in the codebase pipes a
3 * single line through `recordAiCost(...)` after a successful response so
4 * the /billing/usage dashboard can attribute spend back to a user, repo,
5 * agent session, and feature category.
6 *
7 * Design notes:
8 *
9 * - Best-effort. The caller wraps `recordAiCost` in try/catch and the
10 * function itself never throws — a DB blip or a malformed usage
11 * object must NOT escape into the request path.
12 *
13 * - Cents are computed at insert time from a hardcoded pricing table
14 * and persisted on the row, so historical aggregates stay stable
15 * across price changes. Operators can override the table at runtime
16 * by editing `MODEL_PRICING` and re-deploying, or by calling
17 * `setPricingOverride(model, prices)` from a future admin surface
18 * (system-config-backed; intentionally not wired here).
19 *
20 * - Per-1k pricing matches Anthropic's published Claude rates. Numbers
21 * are in CENTS for downstream arithmetic safety — Postgres `int`s
22 * avoid float drift over a month of rollups.
23 *
24 * - Summary helpers (`summarizeCostsForUser`, ...ForRepo, ...ForAgent)
25 * return the shape consumed by `routes/billing-usage.tsx` and by the
26 * `/api/v2/usage/*` JSON endpoints, so the UI and API never diverge.
27 */
28
29import { and, asc, desc, eq, gte, lte, sql } from "drizzle-orm";
30import { db } from "../db";
31import { aiCostEvents } from "../db/schema";
32import type { AiCostEvent } from "../db/schema";
33
34/**
35 * Canonical Claude pricing table (cents per 1k tokens). Keep this aligned
36 * with https://www.anthropic.com/pricing. The keys match the model IDs we
37 * pass to `client.messages.create({ model })`. Unknown models fall back to
38 * `DEFAULT_PRICING` so a new release never silently records $0.
39 */
40export interface ModelPrice {
41 /** USD cents per 1k input tokens. */
42 inputCentsPer1k: number;
43 /** USD cents per 1k output tokens. */
44 outputCentsPer1k: number;
45}
46
47export const MODEL_PRICING: Record<string, ModelPrice> = {
48 // Sonnet 4 (May 2025) — $3 / $15 per 1M tokens.
49 "claude-sonnet-4-20250514": { inputCentsPer1k: 0.3, outputCentsPer1k: 1.5 },
50 // Newer Sonnet 4 family checkpoints (4.5, 4.6, 4.7) — same price tier.
51 "claude-sonnet-4-5": { inputCentsPer1k: 0.3, outputCentsPer1k: 1.5 },
52 "claude-sonnet-4-6": { inputCentsPer1k: 0.3, outputCentsPer1k: 1.5 },
53 "claude-sonnet-4-7": { inputCentsPer1k: 0.3, outputCentsPer1k: 1.5 },
54 // Haiku 4.5 — $1 / $5 per 1M tokens.
55 "claude-haiku-4-5-20251001": {
56 inputCentsPer1k: 0.1,
57 outputCentsPer1k: 0.5,
58 },
59 "claude-haiku-4-5": { inputCentsPer1k: 0.1, outputCentsPer1k: 0.5 },
60 // Opus 4 family — $15 / $75 per 1M tokens.
61 "claude-opus-4": { inputCentsPer1k: 1.5, outputCentsPer1k: 7.5 },
62 "claude-opus-4-7": { inputCentsPer1k: 1.5, outputCentsPer1k: 7.5 },
63};
64
65/** Fallback pricing if we get a model id we don't recognise. Conservative
66 * "Sonnet-tier" estimate so unknown spend is at least visible in $$. */
67export const DEFAULT_PRICING: ModelPrice = {
68 inputCentsPer1k: 0.3,
69 outputCentsPer1k: 1.5,
70};
71
72/** Runtime override slot — future /admin/integrations surface can call
73 * this without touching the source file. Not currently wired to a UI. */
74export function setPricingOverride(model: string, prices: ModelPrice): void {
75 MODEL_PRICING[model] = prices;
76}
77
78/** Recognised feature buckets. The dashboard groups + colours by this. */
79export type AiCostCategory =
80 | "ai_review"
81 | "ai_patch"
82 | "ci_healer"
83 | "spec_to_pr"
84 | "standup"
85 | "chat"
86 | "voice"
87 | "test_gen"
88 | "refactor"
89 | "other";
90
91const CATEGORIES: readonly AiCostCategory[] = [
92 "ai_review",
93 "ai_patch",
94 "ci_healer",
95 "spec_to_pr",
96 "standup",
97 "chat",
98 "voice",
99 "test_gen",
100 "refactor",
101 "other",
102];
103
104/**
105 * Compute a cost estimate in CENTS (rounded UP to the next cent so a
106 * tiny call still registers as >0¢ in the dashboard). Pure function;
107 * exported so tests can pin the arithmetic.
108 */
109export function computeCentsForCall(
110 model: string,
111 inputTokens: number,
112 outputTokens: number
113): number {
114 const price = MODEL_PRICING[model] || DEFAULT_PRICING;
115 const inT = Math.max(0, Math.floor(inputTokens || 0));
116 const outT = Math.max(0, Math.floor(outputTokens || 0));
117 const cents =
118 (inT / 1000) * price.inputCentsPer1k +
119 (outT / 1000) * price.outputCentsPer1k;
120 if (cents <= 0) return 0;
121 return Math.max(1, Math.ceil(cents));
122}
123
124export interface RecordAiCostArgs {
125 ownerUserId?: string | null;
126 repositoryId?: string | null;
127 agentSessionId?: string | null;
128 model: string;
129 inputTokens: number;
130 outputTokens: number;
131 category: AiCostCategory;
132 sourceId?: string | null;
133 sourceKind?: string | null;
134}
135
136/**
137 * Insert one cost row. Never throws — DB failures are logged at debug
138 * level and swallowed so the calling AI feature continues unaffected.
139 */
140export async function recordAiCost(args: RecordAiCostArgs): Promise<void> {
141 try {
142 const cents = computeCentsForCall(
143 args.model,
144 args.inputTokens,
145 args.outputTokens
146 );
147 const category: AiCostCategory = CATEGORIES.includes(
148 args.category as AiCostCategory
149 )
150 ? args.category
151 : "other";
152 await db.insert(aiCostEvents).values({
153 ownerUserId: args.ownerUserId ?? null,
154 repositoryId: args.repositoryId ?? null,
155 agentSessionId: args.agentSessionId ?? null,
156 model: args.model || "unknown",
157 inputTokens: Math.max(0, Math.floor(args.inputTokens || 0)),
158 outputTokens: Math.max(0, Math.floor(args.outputTokens || 0)),
159 centsEstimate: cents,
160 category,
161 sourceId: args.sourceId ?? null,
162 sourceKind: args.sourceKind ?? null,
163 });
164 } catch (err) {
165 // Swallow. The dashboard is observational; missing rows are not a
166 // user-visible failure mode.
167 if (process.env.DEBUG_AI_COST === "1") {
168 console.warn(
169 "[ai-cost-tracker] insert failed:",
170 err instanceof Error ? err.message : err
171 );
172 }
173 }
174}
175
176/**
177 * Convenience: read `.usage` from an Anthropic message response and call
178 * `recordAiCost`. Tolerant of unknown shapes — extracts whatever it can
179 * find, defaults the rest to 0.
180 */
181export async function recordFromAnthropicResponse(
182 message: unknown,
183 rest: Omit<RecordAiCostArgs, "inputTokens" | "outputTokens" | "model"> & {
184 model: string;
185 }
186): Promise<void> {
187 const usage = extractUsage(message);
188 await recordAiCost({
189 ...rest,
190 inputTokens: usage.input,
191 outputTokens: usage.output,
192 });
193}
194
195/** Best-effort usage extractor. Anthropic SDK shape:
196 * `{ usage: { input_tokens, output_tokens, ... } }`. Returns zeros on
197 * anything unexpected. */
198export function extractUsage(message: unknown): {
199 input: number;
200 output: number;
201} {
202 if (!message || typeof message !== "object") return { input: 0, output: 0 };
203 const m = message as Record<string, unknown>;
204 const usage = m.usage as Record<string, unknown> | undefined;
205 if (!usage) return { input: 0, output: 0 };
206 const input = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
207 const output =
208 typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
209 return { input, output };
210}
211
212// ---------------------------------------------------------------------------
213// Summary helpers
214// ---------------------------------------------------------------------------
215
216export interface CostSummary {
217 totalCents: number;
218 totalInputTokens: number;
219 totalOutputTokens: number;
220 byCategory: Array<{
221 category: string;
222 cents: number;
223 inputTokens: number;
224 outputTokens: number;
225 }>;
226 byModel: Array<{
227 model: string;
228 cents: number;
229 inputTokens: number;
230 outputTokens: number;
231 }>;
232 byRepo: Array<{
233 repositoryId: string | null;
234 cents: number;
235 inputTokens: number;
236 outputTokens: number;
237 }>;
238 byAgent: Array<{
239 agentSessionId: string | null;
240 cents: number;
241 inputTokens: number;
242 outputTokens: number;
243 }>;
244 byDay: Array<{
245 day: string; // YYYY-MM-DD UTC
246 cents: number;
247 }>;
248}
249
250export interface SummaryWindow {
251 fromDate?: Date;
252 toDate?: Date;
253}
254
255/** Pure aggregator — kept separate from the DB read so tests can pin it
256 * deterministically against synthetic rows. */
257export function aggregateEvents(
258 rows: Pick<
259 AiCostEvent,
260 | "occurredAt"
261 | "model"
262 | "category"
263 | "repositoryId"
264 | "agentSessionId"
265 | "centsEstimate"
266 | "inputTokens"
267 | "outputTokens"
268 >[]
269): CostSummary {
270 const byCat = new Map<
271 string,
272 { cents: number; inputTokens: number; outputTokens: number }
273 >();
274 const byModel = new Map<
275 string,
276 { cents: number; inputTokens: number; outputTokens: number }
277 >();
278 const byRepo = new Map<
279 string | null,
280 { cents: number; inputTokens: number; outputTokens: number }
281 >();
282 const byAgent = new Map<
283 string | null,
284 { cents: number; inputTokens: number; outputTokens: number }
285 >();
286 const byDay = new Map<string, number>();
287 let totalCents = 0;
288 let totalIn = 0;
289 let totalOut = 0;
290 for (const r of rows) {
291 const cents = r.centsEstimate || 0;
292 const inT = r.inputTokens || 0;
293 const outT = r.outputTokens || 0;
294 totalCents += cents;
295 totalIn += inT;
296 totalOut += outT;
297 const bucketAdd = (
298 map: Map<string | null, { cents: number; inputTokens: number; outputTokens: number }>,
299 key: string | null
300 ) => {
301 const cur = map.get(key) || { cents: 0, inputTokens: 0, outputTokens: 0 };
302 cur.cents += cents;
303 cur.inputTokens += inT;
304 cur.outputTokens += outT;
305 map.set(key, cur);
306 };
307 bucketAdd(byCat as Map<string | null, { cents: number; inputTokens: number; outputTokens: number }>, r.category);
308 bucketAdd(byModel as Map<string | null, { cents: number; inputTokens: number; outputTokens: number }>, r.model);
309 bucketAdd(byRepo, r.repositoryId ?? null);
310 bucketAdd(byAgent, r.agentSessionId ?? null);
311 const day = toUtcDayKey(r.occurredAt);
312 byDay.set(day, (byDay.get(day) || 0) + cents);
313 }
314 return {
315 totalCents,
316 totalInputTokens: totalIn,
317 totalOutputTokens: totalOut,
318 byCategory: Array.from(byCat.entries())
319 .map(([category, v]) => ({ category, ...v }))
320 .sort((a, b) => b.cents - a.cents),
321 byModel: Array.from(byModel.entries())
322 .map(([model, v]) => ({ model, ...v }))
323 .sort((a, b) => b.cents - a.cents),
324 byRepo: Array.from(byRepo.entries())
325 .map(([repositoryId, v]) => ({ repositoryId, ...v }))
326 .sort((a, b) => b.cents - a.cents),
327 byAgent: Array.from(byAgent.entries())
328 .map(([agentSessionId, v]) => ({ agentSessionId, ...v }))
329 .sort((a, b) => b.cents - a.cents),
330 byDay: Array.from(byDay.entries())
331 .map(([day, cents]) => ({ day, cents }))
332 .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)),
333 };
334}
335
336/** Render a Date as a YYYY-MM-DD UTC key. Stable across timezones so day
337 * rollups don't double-count rows around midnight. */
338export function toUtcDayKey(d: Date | string): string {
339 const dt = typeof d === "string" ? new Date(d) : d;
340 const y = dt.getUTCFullYear();
341 const m = String(dt.getUTCMonth() + 1).padStart(2, "0");
342 const day = String(dt.getUTCDate()).padStart(2, "0");
343 return `${y}-${m}-${day}`;
344}
345
346async function loadEvents(
347 whereSql: ReturnType<typeof and> | ReturnType<typeof eq>
348): Promise<AiCostEvent[]> {
349 try {
350 const rows = await db
351 .select()
352 .from(aiCostEvents)
353 .where(whereSql)
354 .orderBy(asc(aiCostEvents.occurredAt));
355 return rows as AiCostEvent[];
356 } catch (err) {
357 if (process.env.DEBUG_AI_COST === "1") {
358 console.warn(
359 "[ai-cost-tracker] loadEvents failed:",
360 err instanceof Error ? err.message : err
361 );
362 }
363 return [];
364 }
365}
366
367function rangeClause(field: typeof aiCostEvents.occurredAt, win: SummaryWindow) {
368 const clauses = [] as Array<ReturnType<typeof gte> | ReturnType<typeof lte>>;
369 if (win.fromDate) clauses.push(gte(field, win.fromDate));
370 if (win.toDate) clauses.push(lte(field, win.toDate));
371 return clauses;
372}
373
374export async function summarizeCostsForUser(
375 userId: string,
376 win: SummaryWindow = {}
377): Promise<CostSummary> {
378 const rows = await loadEvents(
379 and(eq(aiCostEvents.ownerUserId, userId), ...rangeClause(aiCostEvents.occurredAt, win))
380 );
381 return aggregateEvents(rows);
382}
383
384export async function summarizeCostsForRepo(
385 repoId: string,
386 win: SummaryWindow = {}
387): Promise<CostSummary> {
388 const rows = await loadEvents(
389 and(eq(aiCostEvents.repositoryId, repoId), ...rangeClause(aiCostEvents.occurredAt, win))
390 );
391 return aggregateEvents(rows);
392}
393
394export async function summarizeCostsForAgent(
395 sessionId: string,
396 win: SummaryWindow = {}
397): Promise<CostSummary> {
398 const rows = await loadEvents(
399 and(
400 eq(aiCostEvents.agentSessionId, sessionId),
401 ...rangeClause(aiCostEvents.occurredAt, win)
402 )
403 );
404 return aggregateEvents(rows);
405}
406
407// ---------------------------------------------------------------------------
408// Dashboard-level helpers (month rollups, projections, budget reads).
409// ---------------------------------------------------------------------------
410
411/** UTC month start, e.g. 2026-05-01 00:00:00Z for a 2026-05-25 input. */
412export function startOfUtcMonth(d: Date): Date {
413 return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1, 0, 0, 0, 0));
414}
415
416/** Project month-end cents from elapsed days vs days in the month. */
417export function projectMonthEndCents(
418 centsSoFar: number,
419 now: Date
420): number {
421 const start = startOfUtcMonth(now);
422 const elapsedMs = Math.max(1, now.getTime() - start.getTime());
423 const next = new Date(
424 Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)
425 );
426 const totalMs = next.getTime() - start.getTime();
427 return Math.round((centsSoFar * totalMs) / elapsedMs);
428}
429
430/** Daily average across the elapsed portion of the month. */
431export function dailyAverageCents(centsSoFar: number, now: Date): number {
432 const start = startOfUtcMonth(now);
433 const elapsedDays = Math.max(
434 1,
435 Math.floor((now.getTime() - start.getTime()) / (24 * 3600 * 1000)) + 1
436 );
437 return Math.round(centsSoFar / elapsedDays);
438}
439
440/** Render integer cents as a $1.23 (or $0.04) USD string. */
441export function formatCents(cents: number): string {
442 const sign = cents < 0 ? "-" : "";
443 const abs = Math.abs(cents);
444 const dollars = Math.floor(abs / 100);
445 const remainder = abs % 100;
446 return `${sign}$${dollars.toLocaleString()}.${String(remainder).padStart(2, "0")}`;
447}
448
449/** Quick token formatter — adds thousand separators. */
450export function formatTokens(n: number): string {
451 return Math.max(0, Math.floor(n || 0)).toLocaleString();
452}