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