@convex-dev/ai-budget 0.0.2-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,994 @@
1
+ import { v } from "convex/values";
2
+ import {
3
+ mutation,
4
+ internalMutation,
5
+ query,
6
+ type MutationCtx,
7
+ } from "./_generated/server";
8
+ import { api, internal, components } from "./_generated/api";
9
+ import { vMessage } from "./schema";
10
+ import type { Doc } from "./_generated/dataModel";
11
+ import { ShardedCounter } from "@convex-dev/sharded-counter";
12
+
13
+ // All money is integer **nanodollars** (1 USD = 1e9 nano). Integers avoid the
14
+ // rounding drift that floating-point cents accumulate over millions of
15
+ // requests, and keep cap comparisons exact. Nanodollars up to ~$9M are exact in
16
+ // a JS number (2^53); beyond that you'd move to int64. This is the single fixed
17
+ // currency (USD) for now — a future multi-currency version would carry a
18
+ // currency code alongside these amounts and convert here, at the one boundary.
19
+ const NANOS_PER_DOLLAR = 1e9;
20
+ const fmtUsd = (nanos: number) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
21
+
22
+ // Deployment-wide spend totals (nanodollars), sharded for high write throughput.
23
+ // Keyed "total" (lifetime) and "day:<UTC date>" (natural daily reset).
24
+ const globalSpend = new ShardedCounter(components.shardedCounter);
25
+ const GLOBAL_TOTAL = "total";
26
+ const globalDayKey = (stamp: string) => `day:${stamp}`;
27
+
28
+ // Fallback prices in NANODOLLARS per million tokens, used when no override is
29
+ // stored (e.g. gpt-4o-mini = $0.15 in / $0.60 out per Mtok).
30
+ const DEFAULT_PRICES: Record<string, { input: number; output: number }> = {
31
+ "anthropic/claude-sonnet-4.5": { input: 3_000_000_000, output: 15_000_000_000 },
32
+ "anthropic/claude-haiku-4.5": { input: 1_000_000_000, output: 5_000_000_000 },
33
+ "openai/gpt-4o": { input: 2_500_000_000, output: 10_000_000_000 },
34
+ "openai/gpt-4o-mini": { input: 150_000_000, output: 600_000_000 },
35
+ "openai/gpt-5": { input: 1_250_000_000, output: 10_000_000_000 },
36
+ "openai/gpt-5-mini": { input: 250_000_000, output: 2_000_000_000 },
37
+ };
38
+
39
+ // Pessimistic assumed output length when reserving budget up front. Reservations
40
+ // only need to be an upper bound often enough to keep a hard cap honest; the
41
+ // actual cost replaces the estimate the moment the request settles.
42
+ const ESTIMATED_OUTPUT_TOKENS = 800;
43
+ // A request still "pending" after this long is presumed dead (its action
44
+ // crashed before settling); the reconciler releases its reservation. Set well
45
+ // above any real call duration — a long reasoning/agent generation that is
46
+ // still billing must not be swept and mis-recorded as free. A late
47
+ // finishRequest is a no-op once swept (see finishRequest's terminal guard), so
48
+ // the only cost of a generous timeout is a briefly-held reservation.
49
+ const STALE_PENDING_MS = 30 * 60 * 1000;
50
+ // Default retention for request rows (full prompts + responses). Terminal,
51
+ // fully-accounted rows older than this are swept by the reconciler. Keeps the
52
+ // audit table — and the sensitive content in it — from growing without bound.
53
+ // Override per-deployment via setRetention.
54
+ const DEFAULT_RETENTION_MS = 60 * 60 * 1000; // 1 hour
55
+
56
+ const dayStamp = () => new Date().toISOString().slice(0, 10);
57
+
58
+ // Conservative fallback for any model not in the price table: the max of every
59
+ // known price dimension. Falling back to 0 would be fail-open — an unpriced
60
+ // model would reserve 0, pass every cap, and log 0¢ while the AI Gateway still
61
+ // bills real money. Charging the conservative max instead keeps the caps honest
62
+ // (over-counting is the safe direction); admins can pin an exact price via
63
+ // setPrice, which also clears the `unpricedModel` flag on future requests.
64
+ const CONSERVATIVE_PRICE = Object.values(DEFAULT_PRICES).reduce(
65
+ (m, p) => ({
66
+ input: Math.max(m.input, p.input),
67
+ output: Math.max(m.output, p.output),
68
+ }),
69
+ { input: 0, output: 0 }
70
+ );
71
+
72
+ async function getPrice(ctx: MutationCtx, model: string) {
73
+ const override = await ctx.db
74
+ .query("prices")
75
+ .withIndex("model", (q) => q.eq("model", model))
76
+ .unique();
77
+ if (override) {
78
+ return {
79
+ input: override.inputNanosPerMTok,
80
+ output: override.outputNanosPerMTok,
81
+ known: true,
82
+ };
83
+ }
84
+ const known = DEFAULT_PRICES[model];
85
+ if (known) return { ...known, known: true };
86
+ return { ...CONSERVATIVE_PRICE, known: false };
87
+ }
88
+
89
+ // Integer nanodollars. Divide-before-multiply keeps the intermediate product
90
+ // within 2^53 even for large token counts × large per-Mtok prices.
91
+ const costOf = (
92
+ inputTokens: number,
93
+ outputTokens: number,
94
+ price: { input: number; output: number }
95
+ ) =>
96
+ Math.round(
97
+ (inputTokens / 1e6) * price.input + (outputTokens / 1e6) * price.output
98
+ );
99
+
100
+ // Up-front estimate of a request's cost and token count, reserved before the
101
+ // call so concurrent in-flight requests are visible to each other's caps.
102
+ function estimateUsage(
103
+ messages: { content: string }[],
104
+ price: { input: number; output: number }
105
+ ) {
106
+ const chars = messages.reduce((s, m) => s + (m.content?.length ?? 0), 0);
107
+ const inputTokens = Math.ceil(chars / 4);
108
+ return {
109
+ cost: costOf(inputTokens, ESTIMATED_OUTPUT_TOKENS, price),
110
+ tokens: inputTokens + ESTIMATED_OUTPUT_TOKENS,
111
+ };
112
+ }
113
+
114
+ // Evaluate an entity's (user or action) spend + token budgets against
115
+ // committed + reserved + this request's estimate. Returns a hard rejection
116
+ // (block) or a list of soft warnings (allow), per the entity's enforcement.
117
+ function evaluateCaps(o: {
118
+ label: string;
119
+ name: string;
120
+ enforcement: "hard" | "soft";
121
+ estCost: number;
122
+ estTokens: number;
123
+ spendToday: number;
124
+ reservedSpendToday: number;
125
+ totalSpend: number;
126
+ reservedSpendTotal: number;
127
+ tokensToday: number;
128
+ reservedTokensToday: number;
129
+ totalTokens: number;
130
+ reservedTokensTotal: number;
131
+ dailySpendLimitNanos?: number;
132
+ lifetimeSpendLimitNanos?: number;
133
+ dailyTokenLimit?: number;
134
+ lifetimeTokenLimit?: number;
135
+ }): { hard?: { code: string; reason: string }; warnings: string[] } {
136
+ const violations: { code: string; reason: string }[] = [];
137
+ const push = (code: string, reason: string) =>
138
+ violations.push({ code: `${o.label}_${code}`, reason });
139
+
140
+ if (
141
+ o.dailySpendLimitNanos !== undefined &&
142
+ o.spendToday + o.reservedSpendToday + o.estCost > o.dailySpendLimitNanos
143
+ )
144
+ push("daily_spend_limit", `Daily spend limit reached for ${o.label} "${o.name}" (${fmtUsd(o.dailySpendLimitNanos)}/day)`);
145
+ if (
146
+ o.lifetimeSpendLimitNanos !== undefined &&
147
+ o.totalSpend + o.reservedSpendTotal + o.estCost > o.lifetimeSpendLimitNanos
148
+ )
149
+ push("lifetime_spend_limit", `Lifetime spend limit reached for ${o.label} "${o.name}"`);
150
+ if (
151
+ o.dailyTokenLimit !== undefined &&
152
+ o.tokensToday + o.reservedTokensToday + o.estTokens > o.dailyTokenLimit
153
+ )
154
+ push("daily_token_limit", `Daily token limit reached for ${o.label} "${o.name}" (${o.dailyTokenLimit}/day)`);
155
+ if (
156
+ o.lifetimeTokenLimit !== undefined &&
157
+ o.totalTokens + o.reservedTokensTotal + o.estTokens > o.lifetimeTokenLimit
158
+ )
159
+ push("lifetime_token_limit", `Lifetime token limit reached for ${o.label} "${o.name}"`);
160
+
161
+ if (violations.length === 0) return { warnings: [] };
162
+ if (o.enforcement === "soft") return { warnings: violations.map((v) => v.reason) };
163
+ return { hard: violations[0], warnings: [] };
164
+ }
165
+
166
+ // A cap plus any one-time bump. Returns undefined when there's no base cap
167
+ // (a bump alone never creates a cap).
168
+ const withBump = (base: number | undefined, bump: number | undefined) =>
169
+ base === undefined ? undefined : base + (bump ?? 0);
170
+
171
+ const hasAnyCap = (e: {
172
+ dailySpendLimitNanos?: number;
173
+ lifetimeSpendLimitNanos?: number;
174
+ dailyTokenLimit?: number;
175
+ lifetimeTokenLimit?: number;
176
+ }) =>
177
+ e.dailySpendLimitNanos !== undefined ||
178
+ e.lifetimeSpendLimitNanos !== undefined ||
179
+ e.dailyTokenLimit !== undefined ||
180
+ e.lifetimeTokenLimit !== undefined;
181
+
182
+ async function getOrCreateUser(ctx: MutationCtx, userId: string) {
183
+ const existing = await ctx.db
184
+ .query("users")
185
+ .withIndex("userId", (q) => q.eq("userId", userId))
186
+ .unique();
187
+ if (existing) return existing;
188
+ const id = await ctx.db.insert("users", {
189
+ userId,
190
+ totalSpendNanos: 0,
191
+ totalRequests: 0,
192
+ totalTokens: 0,
193
+ dayStamp: dayStamp(),
194
+ spendTodayNanos: 0,
195
+ });
196
+ return (await ctx.db.get(id))!;
197
+ }
198
+
199
+ async function getOrCreateAction(ctx: MutationCtx, name: string) {
200
+ const existing = await ctx.db
201
+ .query("actions")
202
+ .withIndex("name", (q) => q.eq("name", name))
203
+ .unique();
204
+ if (existing) return existing;
205
+ const id = await ctx.db.insert("actions", {
206
+ name,
207
+ totalSpendNanos: 0,
208
+ totalRequests: 0,
209
+ totalTokens: 0,
210
+ dayStamp: dayStamp(),
211
+ spendTodayNanos: 0,
212
+ });
213
+ return (await ctx.db.get(id))!;
214
+ }
215
+
216
+ async function getSettings(ctx: MutationCtx) {
217
+ return await ctx.db
218
+ .query("settings")
219
+ .withIndex("key", (q) => q.eq("key", "singleton"))
220
+ .unique();
221
+ }
222
+
223
+ const vStartResult = v.union(
224
+ v.object({
225
+ allowed: v.literal(true),
226
+ requestId: v.id("requests"),
227
+ warnings: v.array(v.string()),
228
+ }),
229
+ v.object({
230
+ allowed: v.literal(false),
231
+ code: v.string(),
232
+ reason: v.string(),
233
+ })
234
+ );
235
+
236
+ export const startRequest = mutation({
237
+ args: {
238
+ userId: v.string(),
239
+ actionName: v.optional(v.string()),
240
+ model: v.string(),
241
+ messages: v.array(vMessage),
242
+ rerunOf: v.optional(v.id("requests")),
243
+ },
244
+ returns: vStartResult,
245
+ handler: async (ctx, args) => {
246
+ const user = await getOrCreateUser(ctx, args.userId);
247
+ // Record the blocked attempt and return a rejection (throwing would roll
248
+ // back the record). `persist` is false for the high-frequency-by-design
249
+ // rejections (rate limit, blocked user) that a client retries in a tight
250
+ // loop — persisting those would grow the requests table without bound and
251
+ // bloat the 60s rate-limit window read below.
252
+ const reject = async (code: string, reason: string, persist = true) => {
253
+ if (persist) {
254
+ await ctx.db.insert("requests", {
255
+ ...args,
256
+ status: "blocked" as const,
257
+ error: reason,
258
+ });
259
+ }
260
+ return { allowed: false as const, code, reason };
261
+ };
262
+
263
+ const today = dayStamp();
264
+ const priceInfo = await getPrice(ctx, args.model);
265
+ const est = estimateUsage(args.messages, priceInfo);
266
+ const warnings: string[] = [];
267
+
268
+ if (user.blocked) {
269
+ return reject("blocked", `User "${args.userId}" is blocked`, false);
270
+ }
271
+ // Model allow/deny policy (component-wide).
272
+ const policy = await getSettings(ctx);
273
+ if (policy) {
274
+ const mode = policy.modelMode ?? "open";
275
+ const list = policy.models ?? [];
276
+ if (mode === "allowlist" && !list.includes(args.model)) {
277
+ return reject(
278
+ "model_not_allowed",
279
+ `Model "${args.model}" is not on the allowlist`
280
+ );
281
+ }
282
+ if (mode === "denylist" && list.includes(args.model)) {
283
+ return reject("model_denied", `Model "${args.model}" is denied`);
284
+ }
285
+ }
286
+ if (user.requestsPerMinute !== undefined) {
287
+ // Bounded read: we only need to know whether non-blocked requests in the
288
+ // window have reached the limit. Reading limit+1 non-blocked rows is
289
+ // enough, and .take caps the scan even if the window is flooded. Blocked
290
+ // rows aren't persisted for rate-limit/blocked rejections (see reject),
291
+ // so the window stays small.
292
+ const recent = await ctx.db
293
+ .query("requests")
294
+ .withIndex("userId", (q) =>
295
+ q.eq("userId", args.userId).gt("_creationTime", Date.now() - 60_000)
296
+ )
297
+ .take(user.requestsPerMinute + 50);
298
+ if (
299
+ recent.filter((r) => r.status !== "blocked").length >=
300
+ user.requestsPerMinute
301
+ ) {
302
+ return reject(
303
+ "rate_limit",
304
+ `Rate limit exceeded for "${args.userId}" (${user.requestsPerMinute}/min)`,
305
+ false
306
+ );
307
+ }
308
+ }
309
+ // Committed + already-reserved in-flight usage + this estimate must fit
310
+ // under each cap. Decided and reserved in one transaction, so Convex's
311
+ // serializable isolation makes it a true atomic check-and-reserve. Soft
312
+ // enforcement turns a violation into a warning instead of a block.
313
+ const userSameDay = user.dayStamp === today;
314
+ const userEval = evaluateCaps({
315
+ label: "user",
316
+ name: args.userId,
317
+ enforcement: user.enforcement ?? "hard",
318
+ estCost: est.cost,
319
+ estTokens: est.tokens,
320
+ spendToday: userSameDay ? user.spendTodayNanos : 0,
321
+ reservedSpendToday: userSameDay ? user.reservedTodayNanos ?? 0 : 0,
322
+ totalSpend: user.totalSpendNanos,
323
+ reservedSpendTotal: user.reservedTotalNanos ?? 0,
324
+ tokensToday: userSameDay ? user.tokensToday ?? 0 : 0,
325
+ reservedTokensToday: userSameDay ? user.reservedTodayTokens ?? 0 : 0,
326
+ totalTokens: user.totalTokens,
327
+ reservedTokensTotal: user.reservedTotalTokens ?? 0,
328
+ dailySpendLimitNanos: withBump(
329
+ user.dailySpendLimitNanos,
330
+ user.bumpDayStamp === today ? user.dailyBumpNanos : 0
331
+ ),
332
+ lifetimeSpendLimitNanos: withBump(
333
+ user.lifetimeSpendLimitNanos,
334
+ user.lifetimeBumpNanos
335
+ ),
336
+ dailyTokenLimit: user.dailyTokenLimit,
337
+ lifetimeTokenLimit: user.lifetimeTokenLimit,
338
+ });
339
+ if (userEval.hard) return reject(userEval.hard.code, userEval.hard.reason);
340
+ warnings.push(...userEval.warnings);
341
+
342
+ let action: Doc<"actions"> | null = null;
343
+ if (args.actionName !== undefined) {
344
+ action = await getOrCreateAction(ctx, args.actionName);
345
+ if (action.disabled) {
346
+ return reject("action_disabled", `Action "${action.name}" is disabled`);
347
+ }
348
+ const aSameDay = action.dayStamp === today;
349
+ const actionEval = evaluateCaps({
350
+ label: "action",
351
+ name: action.name,
352
+ enforcement: action.enforcement ?? "hard",
353
+ estCost: est.cost,
354
+ estTokens: est.tokens,
355
+ spendToday: aSameDay ? action.spendTodayNanos : 0,
356
+ reservedSpendToday: aSameDay ? action.reservedTodayNanos ?? 0 : 0,
357
+ totalSpend: action.totalSpendNanos,
358
+ reservedSpendTotal: action.reservedTotalNanos ?? 0,
359
+ tokensToday: aSameDay ? action.tokensToday ?? 0 : 0,
360
+ reservedTokensToday: aSameDay ? action.reservedTodayTokens ?? 0 : 0,
361
+ totalTokens: action.totalTokens,
362
+ reservedTokensTotal: action.reservedTotalTokens ?? 0,
363
+ dailySpendLimitNanos: withBump(
364
+ action.dailySpendLimitNanos,
365
+ action.bumpDayStamp === today ? action.dailyBumpNanos : 0
366
+ ),
367
+ lifetimeSpendLimitNanos: withBump(
368
+ action.lifetimeSpendLimitNanos,
369
+ action.lifetimeBumpNanos
370
+ ),
371
+ dailyTokenLimit: action.dailyTokenLimit,
372
+ lifetimeTokenLimit: action.lifetimeTokenLimit,
373
+ });
374
+ if (actionEval.hard) return reject(actionEval.hard.code, actionEval.hard.reason);
375
+ warnings.push(...actionEval.warnings);
376
+ }
377
+
378
+ // Deployment-wide ("global") spend cap — same reserve-then-settle model and
379
+ // the SAME evaluateCaps logic as the per-entity caps above, so the guarantee
380
+ // statement is uniform: a request is admitted only if
381
+ // committed + reserved + estimate <= cap. The ONE difference is the holder:
382
+ // per-user/per-action reserve on a single row (an exact atomic
383
+ // check-and-reserve), while the global holder is a sharded counter for
384
+ // throughput — its committed total is read as an eventually-consistent sum
385
+ // with no cross-request reservation, so a hard global cap can overshoot by a
386
+ // bounded amount under burst. That's the deliberate exactness/throughput
387
+ // trade for a deployment-wide killswitch; it's the only approximate scope.
388
+ if (
389
+ policy &&
390
+ (policy.globalDailySpendLimitNanos !== undefined ||
391
+ policy.globalLifetimeSpendLimitNanos !== undefined)
392
+ ) {
393
+ const globalEval = evaluateCaps({
394
+ label: "global",
395
+ name: "deployment",
396
+ enforcement: policy.globalEnforcement ?? "hard",
397
+ estCost: est.cost,
398
+ estTokens: est.tokens,
399
+ spendToday: await globalSpend.count(ctx, globalDayKey(today)),
400
+ reservedSpendToday: 0, // sharded holder: no cross-request reservation
401
+ totalSpend: await globalSpend.count(ctx, GLOBAL_TOTAL),
402
+ reservedSpendTotal: 0,
403
+ tokensToday: 0,
404
+ reservedTokensToday: 0,
405
+ totalTokens: 0,
406
+ reservedTokensTotal: 0,
407
+ dailySpendLimitNanos: withBump(
408
+ policy.globalDailySpendLimitNanos,
409
+ policy.globalBumpDayStamp === today ? policy.globalDailyBumpNanos : 0
410
+ ),
411
+ lifetimeSpendLimitNanos: withBump(
412
+ policy.globalLifetimeSpendLimitNanos,
413
+ policy.globalLifetimeBumpNanos
414
+ ),
415
+ });
416
+ if (globalEval.hard) return reject(globalEval.hard.code, globalEval.hard.reason);
417
+ warnings.push(...globalEval.warnings);
418
+ }
419
+
420
+ // Passed — reserve, but ONLY on entities that actually have a cap. Writing
421
+ // an uncapped entity's row here would serialize every request that shares it
422
+ // (e.g. all callers of one action); with no cap there's no reserved amount
423
+ // to consult. Totals are still accrued later, asynchronously, in foldOne.
424
+ if (hasAnyCap(user)) {
425
+ await ctx.db.patch(user._id, {
426
+ dayStamp: today,
427
+ spendTodayNanos: userSameDay ? user.spendTodayNanos : 0,
428
+ tokensToday: userSameDay ? user.tokensToday ?? 0 : 0,
429
+ reservedTodayNanos: (userSameDay ? user.reservedTodayNanos ?? 0 : 0) + est.cost,
430
+ reservedTotalNanos: (user.reservedTotalNanos ?? 0) + est.cost,
431
+ reservedTodayTokens: (userSameDay ? user.reservedTodayTokens ?? 0 : 0) + est.tokens,
432
+ reservedTotalTokens: (user.reservedTotalTokens ?? 0) + est.tokens,
433
+ pendingCount: (user.pendingCount ?? 0) + 1,
434
+ });
435
+ }
436
+ if (action && hasAnyCap(action)) {
437
+ const aSameDay = action.dayStamp === today;
438
+ await ctx.db.patch(action._id, {
439
+ dayStamp: today,
440
+ spendTodayNanos: aSameDay ? action.spendTodayNanos : 0,
441
+ tokensToday: aSameDay ? action.tokensToday ?? 0 : 0,
442
+ reservedTodayNanos: (aSameDay ? action.reservedTodayNanos ?? 0 : 0) + est.cost,
443
+ reservedTotalNanos: (action.reservedTotalNanos ?? 0) + est.cost,
444
+ reservedTodayTokens: (aSameDay ? action.reservedTodayTokens ?? 0 : 0) + est.tokens,
445
+ reservedTotalTokens: (action.reservedTotalTokens ?? 0) + est.tokens,
446
+ pendingCount: (action.pendingCount ?? 0) + 1,
447
+ });
448
+ }
449
+ const requestId = await ctx.db.insert("requests", {
450
+ ...args,
451
+ status: "pending",
452
+ estimatedNanos: est.cost,
453
+ estimatedTokens: est.tokens,
454
+ ...(priceInfo.known ? {} : { unpricedModel: true }),
455
+ ...(warnings.length > 0 ? { overBudget: true } : {}),
456
+ });
457
+ return { allowed: true as const, requestId, warnings };
458
+ },
459
+ });
460
+
461
+ export const finishRequest = mutation({
462
+ args: {
463
+ requestId: v.id("requests"),
464
+ responseText: v.optional(v.string()),
465
+ error: v.optional(v.string()),
466
+ promptTokens: v.optional(v.number()),
467
+ completionTokens: v.optional(v.number()),
468
+ cachedTokens: v.optional(v.number()),
469
+ latencyMs: v.optional(v.number()),
470
+ },
471
+ returns: v.object({ costNanos: v.number() }),
472
+ handler: async (ctx, args) => {
473
+ const request = await ctx.db.get(args.requestId);
474
+ if (!request) throw new Error("Unknown request");
475
+
476
+ // Exactly-once settlement. A request that already reached a terminal state
477
+ // — finished normally, or expired by the reconciler's stale sweep — must
478
+ // not be settled again. Without this, a merely-slow request that the sweep
479
+ // already folded would be re-opened and folded a SECOND time when it
480
+ // finally completes: totals double-count and the reservation is released
481
+ // twice, dropping the reserved pool below reality and letting the atomic
482
+ // check-and-reserve admit requests it should block.
483
+ if (request.status !== "pending") {
484
+ return { costNanos: request.costNanos ?? 0 };
485
+ }
486
+
487
+ // Clamp caller-supplied token counts: negatives would produce negative cost
488
+ // and could refund a user below their cap.
489
+ const promptTokens = Math.max(0, args.promptTokens ?? 0);
490
+ const completionTokens = Math.max(0, args.completionTokens ?? 0);
491
+ const cachedTokens = Math.min(promptTokens, Math.max(0, args.cachedTokens ?? 0));
492
+ const costNanos = Math.max(
493
+ 0,
494
+ costOf(promptTokens, completionTokens, await getPrice(ctx, request.model))
495
+ );
496
+
497
+ // Durable write to the request's OWN row only — uncontended, so it always
498
+ // lands. `settled: false` hands it to the fold step; the row is never left
499
+ // orphaned in "pending" even if the totals update below fails and retries.
500
+ await ctx.db.patch(args.requestId, {
501
+ status: args.error ? "error" : "success",
502
+ responseText: args.responseText,
503
+ error: args.error,
504
+ promptTokens,
505
+ completionTokens,
506
+ ...(cachedTokens > 0 ? { cachedTokens } : {}),
507
+ costNanos,
508
+ latencyMs: args.latencyMs,
509
+ settled: false,
510
+ });
511
+
512
+ // Fold into the (hot) user/action counters in a separate mutation. If it
513
+ // exhausts retries under contention, the cron reconciler picks it up.
514
+ await ctx.scheduler.runAfter(0, internal.lib.foldTotals, {
515
+ requestId: args.requestId,
516
+ });
517
+ return { costNanos };
518
+ },
519
+ });
520
+
521
+ // Fold one finished request into the user/action running totals, releasing its
522
+ // reservation. Idempotent: guarded by `settled` so the scheduler and the cron
523
+ // reconciler can never double-count.
524
+ async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
525
+ if (!req || req.settled !== false) return;
526
+ const actual = req.costNanos ?? 0;
527
+ const estCost = req.estimatedNanos ?? 0;
528
+ const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
529
+ const estTokens = req.estimatedTokens ?? 0;
530
+ const today = dayStamp();
531
+
532
+ const user = await getOrCreateUser(ctx, req.userId);
533
+ const uSameDay = user.dayStamp === today;
534
+ await ctx.db.patch(user._id, {
535
+ totalSpendNanos: user.totalSpendNanos + actual,
536
+ totalRequests: user.totalRequests + 1,
537
+ totalTokens: user.totalTokens + tokens,
538
+ dayStamp: today,
539
+ spendTodayNanos: (uSameDay ? user.spendTodayNanos : 0) + actual,
540
+ tokensToday: (uSameDay ? user.tokensToday ?? 0 : 0) + tokens,
541
+ reservedTodayNanos: Math.max(0, (uSameDay ? user.reservedTodayNanos ?? 0 : 0) - estCost),
542
+ reservedTotalNanos: Math.max(0, (user.reservedTotalNanos ?? 0) - estCost),
543
+ reservedTodayTokens: Math.max(0, (uSameDay ? user.reservedTodayTokens ?? 0 : 0) - estTokens),
544
+ reservedTotalTokens: Math.max(0, (user.reservedTotalTokens ?? 0) - estTokens),
545
+ pendingCount: Math.max(0, (user.pendingCount ?? 0) - 1),
546
+ });
547
+
548
+ if (req.actionName !== undefined) {
549
+ const action = await getOrCreateAction(ctx, req.actionName);
550
+ const aSameDay = action.dayStamp === today;
551
+ await ctx.db.patch(action._id, {
552
+ totalSpendNanos: action.totalSpendNanos + actual,
553
+ totalRequests: action.totalRequests + 1,
554
+ totalTokens: action.totalTokens + tokens,
555
+ dayStamp: today,
556
+ spendTodayNanos: (aSameDay ? action.spendTodayNanos : 0) + actual,
557
+ tokensToday: (aSameDay ? action.tokensToday ?? 0 : 0) + tokens,
558
+ reservedTodayNanos: Math.max(0, (aSameDay ? action.reservedTodayNanos ?? 0 : 0) - estCost),
559
+ reservedTotalNanos: Math.max(0, (action.reservedTotalNanos ?? 0) - estCost),
560
+ reservedTodayTokens: Math.max(0, (aSameDay ? action.reservedTodayTokens ?? 0 : 0) - estTokens),
561
+ reservedTotalTokens: Math.max(0, (action.reservedTotalTokens ?? 0) - estTokens),
562
+ pendingCount: Math.max(0, (action.pendingCount ?? 0) - 1),
563
+ });
564
+ }
565
+ // Deployment-wide totals via the sharded counter (only when a global cap is
566
+ // configured — otherwise skip the writes entirely). Distributed across shards,
567
+ // so this does not serialize on a single row.
568
+ if (actual > 0) {
569
+ const settings = await ctx.db
570
+ .query("settings")
571
+ .withIndex("key", (q) => q.eq("key", "singleton"))
572
+ .unique();
573
+ if (
574
+ settings &&
575
+ (settings.globalDailySpendLimitNanos !== undefined ||
576
+ settings.globalLifetimeSpendLimitNanos !== undefined)
577
+ ) {
578
+ await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
579
+ await globalSpend.add(ctx, globalDayKey(today), actual);
580
+ }
581
+ }
582
+ await ctx.db.patch(req._id, { settled: true });
583
+ }
584
+
585
+ export const foldTotals = internalMutation({
586
+ args: { requestId: v.id("requests") },
587
+ returns: v.null(),
588
+ handler: async (ctx, { requestId }) => {
589
+ await foldOne(ctx, await ctx.db.get(requestId));
590
+ return null;
591
+ },
592
+ });
593
+
594
+ // Backstop for both failure modes: folds finished requests whose scheduled fold
595
+ // lost the retry race, and releases reservations for requests that never
596
+ // settled (their action crashed). Runs on a cron.
597
+ export const reconcile = internalMutation({
598
+ args: {},
599
+ returns: v.object({
600
+ folded: v.number(),
601
+ expired: v.number(),
602
+ purged: v.number(),
603
+ }),
604
+ handler: async (ctx) => {
605
+ const toFold = await ctx.db
606
+ .query("requests")
607
+ .withIndex("settled", (q) => q.eq("settled", false))
608
+ .take(200);
609
+ for (const req of toFold) await foldOne(ctx, req);
610
+
611
+ const cutoff = Date.now() - STALE_PENDING_MS;
612
+ const stale = await ctx.db
613
+ .query("requests")
614
+ .withIndex("status", (q) =>
615
+ q.eq("status", "pending").lt("_creationTime", cutoff)
616
+ )
617
+ .take(200);
618
+ for (const req of stale) {
619
+ await ctx.db.patch(req._id, {
620
+ status: "error",
621
+ error: "Timed out before settling; reservation released",
622
+ costNanos: 0,
623
+ settled: false,
624
+ });
625
+ await foldOne(ctx, await ctx.db.get(req._id));
626
+ }
627
+
628
+ // Retention: delete terminal, fully-accounted request rows past the window.
629
+ const settings = await getSettings(ctx);
630
+ const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
631
+ let purged = 0;
632
+ if (retentionMs > 0) {
633
+ const retentionCutoff = Date.now() - retentionMs;
634
+ const old = await ctx.db
635
+ .query("requests")
636
+ .withIndex("by_creation_time", (q) =>
637
+ q.lt("_creationTime", retentionCutoff)
638
+ )
639
+ .take(500);
640
+ for (const req of old) {
641
+ // Only rows that are done and accounted: folded (settled === true) or a
642
+ // blocked attempt (never needs folding). Never a pending/unfolded row.
643
+ if (req.settled === true || req.status === "blocked") {
644
+ await ctx.db.delete(req._id);
645
+ purged++;
646
+ }
647
+ }
648
+ }
649
+ return { folded: toFold.length, expired: stale.length, purged };
650
+ },
651
+ });
652
+
653
+ export const setRetention = mutation({
654
+ args: { retentionMs: v.number() },
655
+ returns: v.null(),
656
+ handler: async (ctx, { retentionMs }) => {
657
+ const existing = await getSettings(ctx);
658
+ if (existing) await ctx.db.patch(existing._id, { retentionMs });
659
+ else await ctx.db.insert("settings", { key: "singleton", retentionMs });
660
+ return null;
661
+ },
662
+ });
663
+
664
+ export const lineage = query({
665
+ args: { requestId: v.id("requests") },
666
+ handler: async (ctx, { requestId }) => {
667
+ // Walk up to the root of the re-run chain.
668
+ const ancestors = [];
669
+ let cursor = await ctx.db.get(requestId);
670
+ while (cursor?.rerunOf) {
671
+ const parent = await ctx.db.get(cursor.rerunOf);
672
+ if (!parent) break;
673
+ ancestors.unshift(parent);
674
+ cursor = parent;
675
+ }
676
+ const reruns = await ctx.db
677
+ .query("requests")
678
+ .withIndex("rerunOf", (q) => q.eq("rerunOf", requestId))
679
+ .collect();
680
+ return { ancestors, reruns };
681
+ },
682
+ });
683
+
684
+ export const getRequest = query({
685
+ args: { requestId: v.id("requests") },
686
+ handler: async (ctx, args) => ctx.db.get(args.requestId),
687
+ });
688
+
689
+ export const listRequests = query({
690
+ args: { userId: v.optional(v.string()), limit: v.optional(v.number()) },
691
+ handler: async (ctx, args) => {
692
+ const limit = args.limit ?? 50;
693
+ if (args.userId !== undefined) {
694
+ const userId = args.userId;
695
+ return await ctx.db
696
+ .query("requests")
697
+ .withIndex("userId", (q) => q.eq("userId", userId))
698
+ .order("desc")
699
+ .take(limit);
700
+ }
701
+ return await ctx.db.query("requests").order("desc").take(limit);
702
+ },
703
+ });
704
+
705
+ const ADMIN_LIST_CAP = 2000;
706
+ export const listUsers = query({
707
+ args: {},
708
+ handler: async (ctx) => {
709
+ // Bounded to avoid an unbounded full-table scan on this reactive query.
710
+ // Paginate (ctx.db.query("users").paginate(...)) for larger deployments.
711
+ const users = await ctx.db.query("users").take(ADMIN_LIST_CAP);
712
+ const today = dayStamp();
713
+ return users.map((u) => ({
714
+ ...u,
715
+ spendTodayNanos: u.dayStamp === today ? u.spendTodayNanos : 0,
716
+ }));
717
+ },
718
+ });
719
+
720
+ export const setLimits = mutation({
721
+ args: {
722
+ userId: v.string(),
723
+ requestsPerMinute: v.optional(v.number()),
724
+ dailySpendLimitNanos: v.optional(v.number()),
725
+ lifetimeSpendLimitNanos: v.optional(v.number()),
726
+ dailyTokenLimit: v.optional(v.number()),
727
+ lifetimeTokenLimit: v.optional(v.number()),
728
+ enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
729
+ blocked: v.optional(v.boolean()),
730
+ },
731
+ returns: v.null(),
732
+ handler: async (ctx, args) => {
733
+ const user = await getOrCreateUser(ctx, args.userId);
734
+ const { userId: _userId, ...limits } = args;
735
+ await ctx.db.patch(user._id, limits);
736
+ return null;
737
+ },
738
+ });
739
+
740
+ // Delete a user and all their request rows (e.g. account deletion / GDPR).
741
+ // Deletes in bounded batches and self-reschedules so it never exceeds the
742
+ // per-transaction document limit — a user with millions of rows still deletes.
743
+ const DELETE_BATCH = 500;
744
+ export const deleteUser = mutation({
745
+ args: { userId: v.string() },
746
+ returns: v.object({ deletedThisBatch: v.number(), done: v.boolean() }),
747
+ handler: async (ctx, { userId }) => {
748
+ const rows = await ctx.db
749
+ .query("requests")
750
+ .withIndex("userId", (q) => q.eq("userId", userId))
751
+ .take(DELETE_BATCH);
752
+ for (const r of rows) await ctx.db.delete(r._id);
753
+ if (rows.length === DELETE_BATCH) {
754
+ // More to go — continue in a fresh transaction.
755
+ await ctx.scheduler.runAfter(0, api.lib.deleteUser, { userId });
756
+ return { deletedThisBatch: rows.length, done: false };
757
+ }
758
+ // Last batch: remove the user row itself.
759
+ const user = await ctx.db
760
+ .query("users")
761
+ .withIndex("userId", (q) => q.eq("userId", userId))
762
+ .unique();
763
+ if (user) await ctx.db.delete(user._id);
764
+ return { deletedThisBatch: rows.length + (user ? 1 : 0), done: true };
765
+ },
766
+ });
767
+
768
+ export const listActions = query({
769
+ args: {},
770
+ handler: async (ctx) => {
771
+ const actions = await ctx.db.query("actions").take(ADMIN_LIST_CAP);
772
+ const today = dayStamp();
773
+ return actions.map((a) => ({
774
+ ...a,
775
+ spendTodayNanos: a.dayStamp === today ? a.spendTodayNanos : 0,
776
+ }));
777
+ },
778
+ });
779
+
780
+ const vBumpArgs = {
781
+ dailyNanos: v.optional(v.number()),
782
+ lifetimeNanos: v.optional(v.number()),
783
+ };
784
+
785
+ // One-time "approve another $X" bumps, added on top of the standing cap without
786
+ // changing it. Daily bumps apply to today only; lifetime bumps are permanent.
787
+ export const bumpUser = mutation({
788
+ args: { userId: v.string(), ...vBumpArgs },
789
+ returns: v.null(),
790
+ handler: async (ctx, { userId, dailyNanos, lifetimeNanos }) => {
791
+ const user = await getOrCreateUser(ctx, userId);
792
+ const today = dayStamp();
793
+ const curDaily = user.bumpDayStamp === today ? user.dailyBumpNanos ?? 0 : 0;
794
+ await ctx.db.patch(user._id, {
795
+ bumpDayStamp: today,
796
+ dailyBumpNanos: curDaily + (dailyNanos ?? 0),
797
+ lifetimeBumpNanos: (user.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
798
+ });
799
+ return null;
800
+ },
801
+ });
802
+
803
+ export const bumpAction = mutation({
804
+ args: { name: v.string(), ...vBumpArgs },
805
+ returns: v.null(),
806
+ handler: async (ctx, { name, dailyNanos, lifetimeNanos }) => {
807
+ const action = await getOrCreateAction(ctx, name);
808
+ const today = dayStamp();
809
+ const curDaily = action.bumpDayStamp === today ? action.dailyBumpNanos ?? 0 : 0;
810
+ await ctx.db.patch(action._id, {
811
+ bumpDayStamp: today,
812
+ dailyBumpNanos: curDaily + (dailyNanos ?? 0),
813
+ lifetimeBumpNanos: (action.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
814
+ });
815
+ return null;
816
+ },
817
+ });
818
+
819
+ export const bumpGlobal = mutation({
820
+ args: vBumpArgs,
821
+ returns: v.null(),
822
+ handler: async (ctx, { dailyNanos, lifetimeNanos }) => {
823
+ const today = dayStamp();
824
+ const s = await getSettings(ctx);
825
+ const curDaily = s?.globalBumpDayStamp === today ? s?.globalDailyBumpNanos ?? 0 : 0;
826
+ const patch = {
827
+ globalBumpDayStamp: today,
828
+ globalDailyBumpNanos: curDaily + (dailyNanos ?? 0),
829
+ globalLifetimeBumpNanos: (s?.globalLifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
830
+ };
831
+ if (s) await ctx.db.patch(s._id, patch);
832
+ else await ctx.db.insert("settings", { key: "singleton", ...patch });
833
+ return null;
834
+ },
835
+ });
836
+
837
+ export const setActionLimits = mutation({
838
+ args: {
839
+ name: v.string(),
840
+ dailySpendLimitNanos: v.optional(v.number()),
841
+ lifetimeSpendLimitNanos: v.optional(v.number()),
842
+ dailyTokenLimit: v.optional(v.number()),
843
+ lifetimeTokenLimit: v.optional(v.number()),
844
+ enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
845
+ disabled: v.optional(v.boolean()),
846
+ },
847
+ returns: v.null(),
848
+ handler: async (ctx, args) => {
849
+ const action = await getOrCreateAction(ctx, args.name);
850
+ const { name: _name, ...limits } = args;
851
+ await ctx.db.patch(action._id, limits);
852
+ return null;
853
+ },
854
+ });
855
+
856
+ export const getModelPolicy = query({
857
+ args: {},
858
+ returns: v.object({
859
+ mode: v.union(
860
+ v.literal("open"),
861
+ v.literal("allowlist"),
862
+ v.literal("denylist")
863
+ ),
864
+ models: v.array(v.string()),
865
+ }),
866
+ handler: async (ctx) => {
867
+ const s = await ctx.db
868
+ .query("settings")
869
+ .withIndex("key", (q) => q.eq("key", "singleton"))
870
+ .unique();
871
+ return { mode: s?.modelMode ?? "open", models: s?.models ?? [] };
872
+ },
873
+ });
874
+
875
+ export const getGlobalStatus = query({
876
+ args: {},
877
+ returns: v.object({
878
+ dailySpendLimitNanos: v.union(v.number(), v.null()),
879
+ lifetimeSpendLimitNanos: v.union(v.number(), v.null()),
880
+ enforcement: v.union(v.literal("hard"), v.literal("soft")),
881
+ spentTodayNanos: v.number(),
882
+ spentTotalNanos: v.number(),
883
+ }),
884
+ handler: async (ctx) => {
885
+ const s = await ctx.db
886
+ .query("settings")
887
+ .withIndex("key", (q) => q.eq("key", "singleton"))
888
+ .unique();
889
+ return {
890
+ dailySpendLimitNanos: s?.globalDailySpendLimitNanos ?? null,
891
+ lifetimeSpendLimitNanos: s?.globalLifetimeSpendLimitNanos ?? null,
892
+ enforcement: s?.globalEnforcement ?? "hard",
893
+ spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
894
+ spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
895
+ };
896
+ },
897
+ });
898
+
899
+ export const setGlobalLimits = mutation({
900
+ args: {
901
+ dailySpendLimitNanos: v.optional(v.number()),
902
+ lifetimeSpendLimitNanos: v.optional(v.number()),
903
+ enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
904
+ },
905
+ returns: v.null(),
906
+ handler: async (ctx, args) => {
907
+ // The settings fields are "global"-prefixed; map the friendly arg names.
908
+ const patch = {
909
+ globalDailySpendLimitNanos: args.dailySpendLimitNanos,
910
+ globalLifetimeSpendLimitNanos: args.lifetimeSpendLimitNanos,
911
+ globalEnforcement: args.enforcement,
912
+ };
913
+ const existing = await getSettings(ctx);
914
+ if (existing) {
915
+ await ctx.db.patch(existing._id, patch);
916
+ } else {
917
+ await ctx.db.insert("settings", { key: "singleton", ...patch });
918
+ }
919
+ return null;
920
+ },
921
+ });
922
+
923
+ export const setModelPolicy = mutation({
924
+ args: {
925
+ mode: v.union(
926
+ v.literal("open"),
927
+ v.literal("allowlist"),
928
+ v.literal("denylist")
929
+ ),
930
+ models: v.array(v.string()),
931
+ },
932
+ returns: v.null(),
933
+ handler: async (ctx, args) => {
934
+ const existing = await getSettings(ctx);
935
+ if (existing) {
936
+ await ctx.db.patch(existing._id, {
937
+ modelMode: args.mode,
938
+ models: args.models,
939
+ });
940
+ } else {
941
+ await ctx.db.insert("settings", {
942
+ key: "singleton",
943
+ modelMode: args.mode,
944
+ models: args.models,
945
+ });
946
+ }
947
+ return null;
948
+ },
949
+ });
950
+
951
+ export const setPrice = mutation({
952
+ args: {
953
+ model: v.string(),
954
+ inputNanosPerMTok: v.number(),
955
+ outputNanosPerMTok: v.number(),
956
+ },
957
+ returns: v.null(),
958
+ handler: async (ctx, args) => {
959
+ // Negative prices would make costOf return a negative cost, which folds
960
+ // into totals as a spend *refund* — pushing a user back under their cap.
961
+ if (args.inputNanosPerMTok < 0 || args.outputNanosPerMTok < 0) {
962
+ throw new Error("Prices must be non-negative");
963
+ }
964
+ const existing = await ctx.db
965
+ .query("prices")
966
+ .withIndex("model", (q) => q.eq("model", args.model))
967
+ .unique();
968
+ if (existing) {
969
+ await ctx.db.patch(existing._id, args);
970
+ } else {
971
+ await ctx.db.insert("prices", args);
972
+ }
973
+ return null;
974
+ },
975
+ });
976
+
977
+ export const listPrices = query({
978
+ args: {},
979
+ handler: async (ctx) => {
980
+ const overrides = await ctx.db.query("prices").collect();
981
+ const merged: Record<string, { input: number; output: number; overridden: boolean }> = {};
982
+ for (const [model, p] of Object.entries(DEFAULT_PRICES)) {
983
+ merged[model] = { ...p, overridden: false };
984
+ }
985
+ for (const o of overrides) {
986
+ merged[o.model] = {
987
+ input: o.inputNanosPerMTok,
988
+ output: o.outputNanosPerMTok,
989
+ overridden: true,
990
+ };
991
+ }
992
+ return merged;
993
+ },
994
+ });