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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
ai-cost-tracker.ts14.4 KB · 452 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
/**
 * Per-call AI cost tracker. Every Claude caller in the codebase pipes a
 * single line through `recordAiCost(...)` after a successful response so
 * the /billing/usage dashboard can attribute spend back to a user, repo,
 * agent session, and feature category.
 *
 * Design notes:
 *
 *   - Best-effort. The caller wraps `recordAiCost` in try/catch and the
 *     function itself never throws — a DB blip or a malformed usage
 *     object must NOT escape into the request path.
 *
 *   - Cents are computed at insert time from a hardcoded pricing table
 *     and persisted on the row, so historical aggregates stay stable
 *     across price changes. Operators can override the table at runtime
 *     by editing `MODEL_PRICING` and re-deploying, or by calling
 *     `setPricingOverride(model, prices)` from a future admin surface
 *     (system-config-backed; intentionally not wired here).
 *
 *   - Per-1k pricing matches Anthropic's published Claude rates. Numbers
 *     are in CENTS for downstream arithmetic safety — Postgres `int`s
 *     avoid float drift over a month of rollups.
 *
 *   - Summary helpers (`summarizeCostsForUser`, ...ForRepo, ...ForAgent)
 *     return the shape consumed by `routes/billing-usage.tsx` and by the
 *     `/api/v2/usage/*` JSON endpoints, so the UI and API never diverge.
 */

import { and, asc, desc, eq, gte, lte, sql } from "drizzle-orm";
import { db } from "../db";
import { aiCostEvents } from "../db/schema";
import type { AiCostEvent } from "../db/schema";

/**
 * Canonical Claude pricing table (cents per 1k tokens). Keep this aligned
 * with https://www.anthropic.com/pricing. The keys match the model IDs we
 * pass to `client.messages.create({ model })`. Unknown models fall back to
 * `DEFAULT_PRICING` so a new release never silently records $0.
 */
export interface ModelPrice {
  /** USD cents per 1k input tokens. */
  inputCentsPer1k: number;
  /** USD cents per 1k output tokens. */
  outputCentsPer1k: number;
}

export const MODEL_PRICING: Record<string, ModelPrice> = {
  // Sonnet 4 (May 2025) — $3 / $15 per 1M tokens.
  "claude-sonnet-4-20250514": { inputCentsPer1k: 0.3, outputCentsPer1k: 1.5 },
  // Newer Sonnet 4 family checkpoints (4.5, 4.6, 4.7) — same price tier.
  "claude-sonnet-4-5": { inputCentsPer1k: 0.3, outputCentsPer1k: 1.5 },
  "claude-sonnet-4-6": { inputCentsPer1k: 0.3, outputCentsPer1k: 1.5 },
  "claude-sonnet-4-7": { inputCentsPer1k: 0.3, outputCentsPer1k: 1.5 },
  // Haiku 4.5 — $1 / $5 per 1M tokens.
  "claude-haiku-4-5-20251001": {
    inputCentsPer1k: 0.1,
    outputCentsPer1k: 0.5,
  },
  "claude-haiku-4-5": { inputCentsPer1k: 0.1, outputCentsPer1k: 0.5 },
  // Opus 4 family — $15 / $75 per 1M tokens.
  "claude-opus-4": { inputCentsPer1k: 1.5, outputCentsPer1k: 7.5 },
  "claude-opus-4-7": { inputCentsPer1k: 1.5, outputCentsPer1k: 7.5 },
};

/** Fallback pricing if we get a model id we don't recognise. Conservative
 * "Sonnet-tier" estimate so unknown spend is at least visible in $$. */
export const DEFAULT_PRICING: ModelPrice = {
  inputCentsPer1k: 0.3,
  outputCentsPer1k: 1.5,
};

/** Runtime override slot — future /admin/integrations surface can call
 * this without touching the source file. Not currently wired to a UI. */
export function setPricingOverride(model: string, prices: ModelPrice): void {
  MODEL_PRICING[model] = prices;
}

/** Recognised feature buckets. The dashboard groups + colours by this. */
export type AiCostCategory =
  | "ai_review"
  | "ai_patch"
  | "ci_healer"
  | "spec_to_pr"
  | "standup"
  | "chat"
  | "voice"
  | "test_gen"
  | "refactor"
  | "other";

const CATEGORIES: readonly AiCostCategory[] = [
  "ai_review",
  "ai_patch",
  "ci_healer",
  "spec_to_pr",
  "standup",
  "chat",
  "voice",
  "test_gen",
  "refactor",
  "other",
];

/**
 * Compute a cost estimate in CENTS (rounded UP to the next cent so a
 * tiny call still registers as >0¢ in the dashboard). Pure function;
 * exported so tests can pin the arithmetic.
 */
export function computeCentsForCall(
  model: string,
  inputTokens: number,
  outputTokens: number
): number {
  const price = MODEL_PRICING[model] || DEFAULT_PRICING;
  const inT = Math.max(0, Math.floor(inputTokens || 0));
  const outT = Math.max(0, Math.floor(outputTokens || 0));
  const cents =
    (inT / 1000) * price.inputCentsPer1k +
    (outT / 1000) * price.outputCentsPer1k;
  if (cents <= 0) return 0;
  return Math.max(1, Math.ceil(cents));
}

export interface RecordAiCostArgs {
  ownerUserId?: string | null;
  repositoryId?: string | null;
  agentSessionId?: string | null;
  model: string;
  inputTokens: number;
  outputTokens: number;
  category: AiCostCategory;
  sourceId?: string | null;
  sourceKind?: string | null;
}

/**
 * Insert one cost row. Never throws — DB failures are logged at debug
 * level and swallowed so the calling AI feature continues unaffected.
 */
export async function recordAiCost(args: RecordAiCostArgs): Promise<void> {
  try {
    const cents = computeCentsForCall(
      args.model,
      args.inputTokens,
      args.outputTokens
    );
    const category: AiCostCategory = CATEGORIES.includes(
      args.category as AiCostCategory
    )
      ? args.category
      : "other";
    await db.insert(aiCostEvents).values({
      ownerUserId: args.ownerUserId ?? null,
      repositoryId: args.repositoryId ?? null,
      agentSessionId: args.agentSessionId ?? null,
      model: args.model || "unknown",
      inputTokens: Math.max(0, Math.floor(args.inputTokens || 0)),
      outputTokens: Math.max(0, Math.floor(args.outputTokens || 0)),
      centsEstimate: cents,
      category,
      sourceId: args.sourceId ?? null,
      sourceKind: args.sourceKind ?? null,
    });
  } catch (err) {
    // Swallow. The dashboard is observational; missing rows are not a
    // user-visible failure mode.
    if (process.env.DEBUG_AI_COST === "1") {
      console.warn(
        "[ai-cost-tracker] insert failed:",
        err instanceof Error ? err.message : err
      );
    }
  }
}

/**
 * Convenience: read `.usage` from an Anthropic message response and call
 * `recordAiCost`. Tolerant of unknown shapes — extracts whatever it can
 * find, defaults the rest to 0.
 */
export async function recordFromAnthropicResponse(
  message: unknown,
  rest: Omit<RecordAiCostArgs, "inputTokens" | "outputTokens" | "model"> & {
    model: string;
  }
): Promise<void> {
  const usage = extractUsage(message);
  await recordAiCost({
    ...rest,
    inputTokens: usage.input,
    outputTokens: usage.output,
  });
}

/** Best-effort usage extractor. Anthropic SDK shape:
 * `{ usage: { input_tokens, output_tokens, ... } }`. Returns zeros on
 * anything unexpected. */
export function extractUsage(message: unknown): {
  input: number;
  output: number;
} {
  if (!message || typeof message !== "object") return { input: 0, output: 0 };
  const m = message as Record<string, unknown>;
  const usage = m.usage as Record<string, unknown> | undefined;
  if (!usage) return { input: 0, output: 0 };
  const input = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
  const output =
    typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
  return { input, output };
}

// ---------------------------------------------------------------------------
// Summary helpers
// ---------------------------------------------------------------------------

export interface CostSummary {
  totalCents: number;
  totalInputTokens: number;
  totalOutputTokens: number;
  byCategory: Array<{
    category: string;
    cents: number;
    inputTokens: number;
    outputTokens: number;
  }>;
  byModel: Array<{
    model: string;
    cents: number;
    inputTokens: number;
    outputTokens: number;
  }>;
  byRepo: Array<{
    repositoryId: string | null;
    cents: number;
    inputTokens: number;
    outputTokens: number;
  }>;
  byAgent: Array<{
    agentSessionId: string | null;
    cents: number;
    inputTokens: number;
    outputTokens: number;
  }>;
  byDay: Array<{
    day: string; // YYYY-MM-DD UTC
    cents: number;
  }>;
}

export interface SummaryWindow {
  fromDate?: Date;
  toDate?: Date;
}

/** Pure aggregator — kept separate from the DB read so tests can pin it
 * deterministically against synthetic rows. */
export function aggregateEvents(
  rows: Pick<
    AiCostEvent,
    | "occurredAt"
    | "model"
    | "category"
    | "repositoryId"
    | "agentSessionId"
    | "centsEstimate"
    | "inputTokens"
    | "outputTokens"
  >[]
): CostSummary {
  const byCat = new Map<
    string,
    { cents: number; inputTokens: number; outputTokens: number }
  >();
  const byModel = new Map<
    string,
    { cents: number; inputTokens: number; outputTokens: number }
  >();
  const byRepo = new Map<
    string | null,
    { cents: number; inputTokens: number; outputTokens: number }
  >();
  const byAgent = new Map<
    string | null,
    { cents: number; inputTokens: number; outputTokens: number }
  >();
  const byDay = new Map<string, number>();
  let totalCents = 0;
  let totalIn = 0;
  let totalOut = 0;
  for (const r of rows) {
    const cents = r.centsEstimate || 0;
    const inT = r.inputTokens || 0;
    const outT = r.outputTokens || 0;
    totalCents += cents;
    totalIn += inT;
    totalOut += outT;
    const bucketAdd = (
      map: Map<string | null, { cents: number; inputTokens: number; outputTokens: number }>,
      key: string | null
    ) => {
      const cur = map.get(key) || { cents: 0, inputTokens: 0, outputTokens: 0 };
      cur.cents += cents;
      cur.inputTokens += inT;
      cur.outputTokens += outT;
      map.set(key, cur);
    };
    bucketAdd(byCat as Map<string | null, { cents: number; inputTokens: number; outputTokens: number }>, r.category);
    bucketAdd(byModel as Map<string | null, { cents: number; inputTokens: number; outputTokens: number }>, r.model);
    bucketAdd(byRepo, r.repositoryId ?? null);
    bucketAdd(byAgent, r.agentSessionId ?? null);
    const day = toUtcDayKey(r.occurredAt);
    byDay.set(day, (byDay.get(day) || 0) + cents);
  }
  return {
    totalCents,
    totalInputTokens: totalIn,
    totalOutputTokens: totalOut,
    byCategory: Array.from(byCat.entries())
      .map(([category, v]) => ({ category, ...v }))
      .sort((a, b) => b.cents - a.cents),
    byModel: Array.from(byModel.entries())
      .map(([model, v]) => ({ model, ...v }))
      .sort((a, b) => b.cents - a.cents),
    byRepo: Array.from(byRepo.entries())
      .map(([repositoryId, v]) => ({ repositoryId, ...v }))
      .sort((a, b) => b.cents - a.cents),
    byAgent: Array.from(byAgent.entries())
      .map(([agentSessionId, v]) => ({ agentSessionId, ...v }))
      .sort((a, b) => b.cents - a.cents),
    byDay: Array.from(byDay.entries())
      .map(([day, cents]) => ({ day, cents }))
      .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)),
  };
}

/** Render a Date as a YYYY-MM-DD UTC key. Stable across timezones so day
 * rollups don't double-count rows around midnight. */
export function toUtcDayKey(d: Date | string): string {
  const dt = typeof d === "string" ? new Date(d) : d;
  const y = dt.getUTCFullYear();
  const m = String(dt.getUTCMonth() + 1).padStart(2, "0");
  const day = String(dt.getUTCDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}

async function loadEvents(
  whereSql: ReturnType<typeof and> | ReturnType<typeof eq>
): Promise<AiCostEvent[]> {
  try {
    const rows = await db
      .select()
      .from(aiCostEvents)
      .where(whereSql)
      .orderBy(asc(aiCostEvents.occurredAt));
    return rows as AiCostEvent[];
  } catch (err) {
    if (process.env.DEBUG_AI_COST === "1") {
      console.warn(
        "[ai-cost-tracker] loadEvents failed:",
        err instanceof Error ? err.message : err
      );
    }
    return [];
  }
}

function rangeClause(field: typeof aiCostEvents.occurredAt, win: SummaryWindow) {
  const clauses = [] as Array<ReturnType<typeof gte> | ReturnType<typeof lte>>;
  if (win.fromDate) clauses.push(gte(field, win.fromDate));
  if (win.toDate) clauses.push(lte(field, win.toDate));
  return clauses;
}

export async function summarizeCostsForUser(
  userId: string,
  win: SummaryWindow = {}
): Promise<CostSummary> {
  const rows = await loadEvents(
    and(eq(aiCostEvents.ownerUserId, userId), ...rangeClause(aiCostEvents.occurredAt, win))
  );
  return aggregateEvents(rows);
}

export async function summarizeCostsForRepo(
  repoId: string,
  win: SummaryWindow = {}
): Promise<CostSummary> {
  const rows = await loadEvents(
    and(eq(aiCostEvents.repositoryId, repoId), ...rangeClause(aiCostEvents.occurredAt, win))
  );
  return aggregateEvents(rows);
}

export async function summarizeCostsForAgent(
  sessionId: string,
  win: SummaryWindow = {}
): Promise<CostSummary> {
  const rows = await loadEvents(
    and(
      eq(aiCostEvents.agentSessionId, sessionId),
      ...rangeClause(aiCostEvents.occurredAt, win)
    )
  );
  return aggregateEvents(rows);
}

// ---------------------------------------------------------------------------
// Dashboard-level helpers (month rollups, projections, budget reads).
// ---------------------------------------------------------------------------

/** UTC month start, e.g. 2026-05-01 00:00:00Z for a 2026-05-25 input. */
export function startOfUtcMonth(d: Date): Date {
  return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1, 0, 0, 0, 0));
}

/** Project month-end cents from elapsed days vs days in the month. */
export function projectMonthEndCents(
  centsSoFar: number,
  now: Date
): number {
  const start = startOfUtcMonth(now);
  const elapsedMs = Math.max(1, now.getTime() - start.getTime());
  const next = new Date(
    Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)
  );
  const totalMs = next.getTime() - start.getTime();
  return Math.round((centsSoFar * totalMs) / elapsedMs);
}

/** Daily average across the elapsed portion of the month. */
export function dailyAverageCents(centsSoFar: number, now: Date): number {
  const start = startOfUtcMonth(now);
  const elapsedDays = Math.max(
    1,
    Math.floor((now.getTime() - start.getTime()) / (24 * 3600 * 1000)) + 1
  );
  return Math.round(centsSoFar / elapsedDays);
}

/** Render integer cents as a $1.23 (or $0.04) USD string. */
export function formatCents(cents: number): string {
  const sign = cents < 0 ? "-" : "";
  const abs = Math.abs(cents);
  const dollars = Math.floor(abs / 100);
  const remainder = abs % 100;
  return `${sign}$${dollars.toLocaleString()}.${String(remainder).padStart(2, "0")}`;
}

/** Quick token formatter — adds thousand separators. */
export function formatTokens(n: number): string {
  return Math.max(0, Math.floor(n || 0)).toLocaleString();
}