Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame525 lines · 2 contributors
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
489a5acccantynz-alt124/**
125 * Decide who to charge for a call and how many tokens, or null for nothing.
126 *
127 * Split out from the database work so the rule is testable on its own.
128 * Returns null when there is no owner — platform-internal calls (cron
129 * sweeps, system tasks) have nobody to bill and must not land on whichever
130 * user happens to be nearby — and when the call consumed no tokens.
131 */
132export function quotaChargeForCall(args: {
133 ownerUserId?: string | null;
134 inputTokens: number;
135 outputTokens: number;
136}): { userId: string; tokens: number } | null {
137 const userId = args.ownerUserId ?? "";
138 if (!userId) return null;
139
140 const tokens =
141 Math.max(0, Math.floor(args.inputTokens || 0)) +
142 Math.max(0, Math.floor(args.outputTokens || 0));
143 if (tokens <= 0) return null;
144
145 return { userId, tokens };
146}
147
148/**
149 * Add this call's tokens to the owner's monthly quota counter.
150 *
151 * Never throws — quota accounting must not be able to break an AI feature,
152 * exactly like the ledger insert it accompanies.
153 *
154 * Skipped when there is no owner: platform-internal calls (cron sweeps,
155 * system tasks) have nobody to bill and must not be charged to whichever
156 * user happens to be nearby.
157 */
158async function meterAiQuota(args: RecordAiCostArgs): Promise<void> {
159 const charge = quotaChargeForCall(args);
160 if (!charge) return;
161 const { userId, tokens } = charge;
162
163 try {
164 const { bumpUsage, invalidateQuotaCache } = await import("./billing");
165 await bumpUsage(userId, "aiTokensUsedThisMonth", tokens);
166 // Drop the cached verdict so the next call re-reads. Without this the
167 // gate would keep using a stale allow for up to the cache TTL, letting a
168 // burst of calls run well past the cap before it bites.
169 invalidateQuotaCache(userId);
170 } catch (err) {
171 if (process.env.DEBUG_AI_COST === "1") {
172 console.warn(
173 "[ai-cost-tracker] quota meter failed:",
174 err instanceof Error ? err.message : err
175 );
176 }
177 }
178}
179
0c3eee5Claude180export interface RecordAiCostArgs {
181 ownerUserId?: string | null;
182 repositoryId?: string | null;
183 agentSessionId?: string | null;
184 model: string;
185 inputTokens: number;
186 outputTokens: number;
187 category: AiCostCategory;
188 sourceId?: string | null;
189 sourceKind?: string | null;
190}
191
192/**
193 * Insert one cost row. Never throws — DB failures are logged at debug
194 * level and swallowed so the calling AI feature continues unaffected.
195 */
196export async function recordAiCost(args: RecordAiCostArgs): Promise<void> {
489a5acccantynz-alt197 // Meter the spend against the user's monthly AI token quota.
198 //
199 // This is the wire that was missing. assertAiQuota is already called at the
200 // top of the AI features, and it reads userQuotas.aiTokensUsedThisMonth —
201 // but NOTHING incremented that column, so it sat at 0 forever, the gate
202 // always allowed, and the cap could never trip. Token counts were being
203 // recorded faithfully, just into ai_cost_events, which the enforcement path
204 // does not read. Metering and enforcement were wired to different stores.
205 //
206 // recordAiCost is the one funnel every AI call site already goes through
207 // and it already carries the owner and the token counts, so this is the
208 // single correct place to close the loop.
209 //
210 // Deliberately independent of the ledger insert below: a failure to write
211 // the observational dashboard row must not skip metering, and vice versa.
212 await meterAiQuota(args);
213
0c3eee5Claude214 try {
215 const cents = computeCentsForCall(
216 args.model,
217 args.inputTokens,
218 args.outputTokens
219 );
220 const category: AiCostCategory = CATEGORIES.includes(
221 args.category as AiCostCategory
222 )
223 ? args.category
224 : "other";
225 await db.insert(aiCostEvents).values({
226 ownerUserId: args.ownerUserId ?? null,
227 repositoryId: args.repositoryId ?? null,
228 agentSessionId: args.agentSessionId ?? null,
229 model: args.model || "unknown",
230 inputTokens: Math.max(0, Math.floor(args.inputTokens || 0)),
231 outputTokens: Math.max(0, Math.floor(args.outputTokens || 0)),
232 centsEstimate: cents,
233 category,
234 sourceId: args.sourceId ?? null,
235 sourceKind: args.sourceKind ?? null,
236 });
237 } catch (err) {
238 // Swallow. The dashboard is observational; missing rows are not a
239 // user-visible failure mode.
240 if (process.env.DEBUG_AI_COST === "1") {
241 console.warn(
242 "[ai-cost-tracker] insert failed:",
243 err instanceof Error ? err.message : err
244 );
245 }
246 }
247}
248
249/**
250 * Convenience: read `.usage` from an Anthropic message response and call
251 * `recordAiCost`. Tolerant of unknown shapes — extracts whatever it can
252 * find, defaults the rest to 0.
253 */
254export async function recordFromAnthropicResponse(
255 message: unknown,
256 rest: Omit<RecordAiCostArgs, "inputTokens" | "outputTokens" | "model"> & {
257 model: string;
258 }
259): Promise<void> {
260 const usage = extractUsage(message);
261 await recordAiCost({
262 ...rest,
263 inputTokens: usage.input,
264 outputTokens: usage.output,
265 });
266}
267
268/** Best-effort usage extractor. Anthropic SDK shape:
269 * `{ usage: { input_tokens, output_tokens, ... } }`. Returns zeros on
270 * anything unexpected. */
271export function extractUsage(message: unknown): {
272 input: number;
273 output: number;
274} {
275 if (!message || typeof message !== "object") return { input: 0, output: 0 };
276 const m = message as Record<string, unknown>;
277 const usage = m.usage as Record<string, unknown> | undefined;
278 if (!usage) return { input: 0, output: 0 };
279 const input = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
280 const output =
281 typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
282 return { input, output };
283}
284
285// ---------------------------------------------------------------------------
286// Summary helpers
287// ---------------------------------------------------------------------------
288
289export interface CostSummary {
290 totalCents: number;
291 totalInputTokens: number;
292 totalOutputTokens: number;
293 byCategory: Array<{
294 category: string;
295 cents: number;
296 inputTokens: number;
297 outputTokens: number;
298 }>;
299 byModel: Array<{
300 model: string;
301 cents: number;
302 inputTokens: number;
303 outputTokens: number;
304 }>;
305 byRepo: Array<{
306 repositoryId: string | null;
307 cents: number;
308 inputTokens: number;
309 outputTokens: number;
310 }>;
311 byAgent: Array<{
312 agentSessionId: string | null;
313 cents: number;
314 inputTokens: number;
315 outputTokens: number;
316 }>;
317 byDay: Array<{
318 day: string; // YYYY-MM-DD UTC
319 cents: number;
320 }>;
321}
322
323export interface SummaryWindow {
324 fromDate?: Date;
325 toDate?: Date;
326}
327
328/** Pure aggregator — kept separate from the DB read so tests can pin it
329 * deterministically against synthetic rows. */
330export function aggregateEvents(
331 rows: Pick<
332 AiCostEvent,
333 | "occurredAt"
334 | "model"
335 | "category"
336 | "repositoryId"
337 | "agentSessionId"
338 | "centsEstimate"
339 | "inputTokens"
340 | "outputTokens"
341 >[]
342): CostSummary {
343 const byCat = new Map<
344 string,
345 { cents: number; inputTokens: number; outputTokens: number }
346 >();
347 const byModel = new Map<
348 string,
349 { cents: number; inputTokens: number; outputTokens: number }
350 >();
351 const byRepo = new Map<
352 string | null,
353 { cents: number; inputTokens: number; outputTokens: number }
354 >();
355 const byAgent = new Map<
356 string | null,
357 { cents: number; inputTokens: number; outputTokens: number }
358 >();
359 const byDay = new Map<string, number>();
360 let totalCents = 0;
361 let totalIn = 0;
362 let totalOut = 0;
363 for (const r of rows) {
364 const cents = r.centsEstimate || 0;
365 const inT = r.inputTokens || 0;
366 const outT = r.outputTokens || 0;
367 totalCents += cents;
368 totalIn += inT;
369 totalOut += outT;
370 const bucketAdd = (
371 map: Map<string | null, { cents: number; inputTokens: number; outputTokens: number }>,
372 key: string | null
373 ) => {
374 const cur = map.get(key) || { cents: 0, inputTokens: 0, outputTokens: 0 };
375 cur.cents += cents;
376 cur.inputTokens += inT;
377 cur.outputTokens += outT;
378 map.set(key, cur);
379 };
380 bucketAdd(byCat as Map<string | null, { cents: number; inputTokens: number; outputTokens: number }>, r.category);
381 bucketAdd(byModel as Map<string | null, { cents: number; inputTokens: number; outputTokens: number }>, r.model);
382 bucketAdd(byRepo, r.repositoryId ?? null);
383 bucketAdd(byAgent, r.agentSessionId ?? null);
384 const day = toUtcDayKey(r.occurredAt);
385 byDay.set(day, (byDay.get(day) || 0) + cents);
386 }
387 return {
388 totalCents,
389 totalInputTokens: totalIn,
390 totalOutputTokens: totalOut,
391 byCategory: Array.from(byCat.entries())
392 .map(([category, v]) => ({ category, ...v }))
393 .sort((a, b) => b.cents - a.cents),
394 byModel: Array.from(byModel.entries())
395 .map(([model, v]) => ({ model, ...v }))
396 .sort((a, b) => b.cents - a.cents),
397 byRepo: Array.from(byRepo.entries())
398 .map(([repositoryId, v]) => ({ repositoryId, ...v }))
399 .sort((a, b) => b.cents - a.cents),
400 byAgent: Array.from(byAgent.entries())
401 .map(([agentSessionId, v]) => ({ agentSessionId, ...v }))
402 .sort((a, b) => b.cents - a.cents),
403 byDay: Array.from(byDay.entries())
404 .map(([day, cents]) => ({ day, cents }))
405 .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)),
406 };
407}
408
409/** Render a Date as a YYYY-MM-DD UTC key. Stable across timezones so day
410 * rollups don't double-count rows around midnight. */
411export function toUtcDayKey(d: Date | string): string {
412 const dt = typeof d === "string" ? new Date(d) : d;
413 const y = dt.getUTCFullYear();
414 const m = String(dt.getUTCMonth() + 1).padStart(2, "0");
415 const day = String(dt.getUTCDate()).padStart(2, "0");
416 return `${y}-${m}-${day}`;
417}
418
419async function loadEvents(
420 whereSql: ReturnType<typeof and> | ReturnType<typeof eq>
421): Promise<AiCostEvent[]> {
422 try {
423 const rows = await db
424 .select()
425 .from(aiCostEvents)
426 .where(whereSql)
427 .orderBy(asc(aiCostEvents.occurredAt));
428 return rows as AiCostEvent[];
429 } catch (err) {
430 if (process.env.DEBUG_AI_COST === "1") {
431 console.warn(
432 "[ai-cost-tracker] loadEvents failed:",
433 err instanceof Error ? err.message : err
434 );
435 }
436 return [];
437 }
438}
439
440function rangeClause(field: typeof aiCostEvents.occurredAt, win: SummaryWindow) {
441 const clauses = [] as Array<ReturnType<typeof gte> | ReturnType<typeof lte>>;
442 if (win.fromDate) clauses.push(gte(field, win.fromDate));
443 if (win.toDate) clauses.push(lte(field, win.toDate));
444 return clauses;
445}
446
447export async function summarizeCostsForUser(
448 userId: string,
449 win: SummaryWindow = {}
450): Promise<CostSummary> {
451 const rows = await loadEvents(
452 and(eq(aiCostEvents.ownerUserId, userId), ...rangeClause(aiCostEvents.occurredAt, win))
453 );
454 return aggregateEvents(rows);
455}
456
457export async function summarizeCostsForRepo(
458 repoId: string,
459 win: SummaryWindow = {}
460): Promise<CostSummary> {
461 const rows = await loadEvents(
462 and(eq(aiCostEvents.repositoryId, repoId), ...rangeClause(aiCostEvents.occurredAt, win))
463 );
464 return aggregateEvents(rows);
465}
466
467export async function summarizeCostsForAgent(
468 sessionId: string,
469 win: SummaryWindow = {}
470): Promise<CostSummary> {
471 const rows = await loadEvents(
472 and(
473 eq(aiCostEvents.agentSessionId, sessionId),
474 ...rangeClause(aiCostEvents.occurredAt, win)
475 )
476 );
477 return aggregateEvents(rows);
478}
479
480// ---------------------------------------------------------------------------
481// Dashboard-level helpers (month rollups, projections, budget reads).
482// ---------------------------------------------------------------------------
483
484/** UTC month start, e.g. 2026-05-01 00:00:00Z for a 2026-05-25 input. */
485export function startOfUtcMonth(d: Date): Date {
486 return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1, 0, 0, 0, 0));
487}
488
489/** Project month-end cents from elapsed days vs days in the month. */
490export function projectMonthEndCents(
491 centsSoFar: number,
492 now: Date
493): number {
494 const start = startOfUtcMonth(now);
495 const elapsedMs = Math.max(1, now.getTime() - start.getTime());
496 const next = new Date(
497 Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)
498 );
499 const totalMs = next.getTime() - start.getTime();
500 return Math.round((centsSoFar * totalMs) / elapsedMs);
501}
502
503/** Daily average across the elapsed portion of the month. */
504export function dailyAverageCents(centsSoFar: number, now: Date): number {
505 const start = startOfUtcMonth(now);
506 const elapsedDays = Math.max(
507 1,
508 Math.floor((now.getTime() - start.getTime()) / (24 * 3600 * 1000)) + 1
509 );
510 return Math.round(centsSoFar / elapsedDays);
511}
512
513/** Render integer cents as a $1.23 (or $0.04) USD string. */
514export function formatCents(cents: number): string {
515 const sign = cents < 0 ? "-" : "";
516 const abs = Math.abs(cents);
517 const dollars = Math.floor(abs / 100);
518 const remainder = abs % 100;
519 return `${sign}$${dollars.toLocaleString()}.${String(remainder).padStart(2, "0")}`;
520}
521
522/** Quick token formatter — adds thousand separators. */
523export function formatTokens(n: number): string {
524 return Math.max(0, Math.floor(n || 0)).toLocaleString();
525}