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

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.
@@ -6,7 +6,7 @@ import {
6
6
  type MutationCtx,
7
7
  } from "./_generated/server";
8
8
  import { api, internal, components } from "./_generated/api";
9
- import { vMessage } from "./schema";
9
+ import { vMessage, vTag } from "./schema";
10
10
  import type { Doc } from "./_generated/dataModel";
11
11
  import { ShardedCounter } from "@convex-dev/sharded-counter";
12
12
 
@@ -19,6 +19,13 @@ import { ShardedCounter } from "@convex-dev/sharded-counter";
19
19
  const NANOS_PER_DOLLAR = 1e9;
20
20
  const fmtUsd = (nanos: number) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
21
21
 
22
+ // Built-in attribution dimensions. `user` and `action` are always populated
23
+ // from a request's userId/actionName; apps can add any other dimensions
24
+ // (team, project, customer, env, …) as tags. These two names are reserved —
25
+ // tags carrying them are ignored in favor of the first-class fields.
26
+ const USER_DIM = "user";
27
+ const ACTION_DIM = "action";
28
+
22
29
  // Deployment-wide spend totals (nanodollars), sharded for high write throughput.
23
30
  // Keyed "total" (lifetime) and "day:<UTC date>" (natural daily reset).
24
31
  const globalSpend = new ShardedCounter(components.shardedCounter);
@@ -111,9 +118,48 @@ function estimateUsage(
111
118
  };
112
119
  }
113
120
 
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.
121
+ // The full set of attribution buckets a request touches: the built-in `user`
122
+ // and `action` dimensions plus any extra tags. Reserved dimensions in `extra`
123
+ // are dropped (userId/actionName own them), and (dimension, value) pairs are
124
+ // de-duplicated. Used identically at reserve time (startRequest) and settle
125
+ // time (foldOne), so a request always settles exactly the buckets it reserved.
126
+ function requestBuckets(
127
+ userId: string,
128
+ actionName: string | undefined,
129
+ extra: { dimension: string; value: string }[] | undefined
130
+ ): { dimension: string; value: string }[] {
131
+ const out = [{ dimension: USER_DIM, value: userId }];
132
+ if (actionName !== undefined)
133
+ out.push({ dimension: ACTION_DIM, value: actionName });
134
+ for (const t of extra ?? []) {
135
+ if (t.dimension === USER_DIM || t.dimension === ACTION_DIM) continue;
136
+ if (!t.dimension || !t.value) continue;
137
+ if (out.some((x) => x.dimension === t.dimension && x.value === t.value))
138
+ continue;
139
+ out.push({ dimension: t.dimension, value: t.value });
140
+ }
141
+ return out;
142
+ }
143
+
144
+ // Drop reserved/empty/duplicate tags from a caller-supplied list, leaving the
145
+ // "extra" dimensions stored on the request row.
146
+ function sanitizeExtraTags(
147
+ extra: { dimension: string; value: string }[] | undefined
148
+ ): { dimension: string; value: string }[] {
149
+ const out: { dimension: string; value: string }[] = [];
150
+ for (const t of extra ?? []) {
151
+ if (t.dimension === USER_DIM || t.dimension === ACTION_DIM) continue;
152
+ if (!t.dimension || !t.value) continue;
153
+ if (out.some((x) => x.dimension === t.dimension && x.value === t.value))
154
+ continue;
155
+ out.push({ dimension: t.dimension, value: t.value });
156
+ }
157
+ return out;
158
+ }
159
+
160
+ // Evaluate a bucket's spend + token budgets against committed + reserved + this
161
+ // request's estimate. Returns a hard rejection (block) or a list of soft
162
+ // warnings (allow), per the bucket's enforcement.
117
163
  function evaluateCaps(o: {
118
164
  label: string;
119
165
  name: string;
@@ -179,31 +225,25 @@ const hasAnyCap = (e: {
179
225
  e.dailyTokenLimit !== undefined ||
180
226
  e.lifetimeTokenLimit !== undefined;
181
227
 
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))
228
+ async function getBucketDoc(ctx: MutationCtx, dimension: string, value: string) {
229
+ return await ctx.db
230
+ .query("buckets")
231
+ .withIndex("dim_value", (q) =>
232
+ q.eq("dimension", dimension).eq("value", value)
233
+ )
186
234
  .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
235
  }
198
236
 
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();
237
+ async function getOrCreateBucket(
238
+ ctx: MutationCtx,
239
+ dimension: string,
240
+ value: string
241
+ ) {
242
+ const existing = await getBucketDoc(ctx, dimension, value);
204
243
  if (existing) return existing;
205
- const id = await ctx.db.insert("actions", {
206
- name,
244
+ const id = await ctx.db.insert("buckets", {
245
+ dimension,
246
+ value,
207
247
  totalSpendNanos: 0,
208
248
  totalRequests: 0,
209
249
  totalTokens: 0,
@@ -237,13 +277,16 @@ export const startRequest = mutation({
237
277
  args: {
238
278
  userId: v.string(),
239
279
  actionName: v.optional(v.string()),
280
+ // Extra attribution dimensions to bill/limit (team, customer, env, …).
281
+ // `user` and `action` are reserved (owned by userId/actionName).
282
+ tags: v.optional(v.array(vTag)),
240
283
  model: v.string(),
241
284
  messages: v.array(vMessage),
242
285
  rerunOf: v.optional(v.id("requests")),
243
286
  },
244
287
  returns: vStartResult,
245
288
  handler: async (ctx, args) => {
246
- const user = await getOrCreateUser(ctx, args.userId);
289
+ const extraTags = sanitizeExtraTags(args.tags);
247
290
  // Record the blocked attempt and return a rejection (throwing would roll
248
291
  // back the record). `persist` is false for the high-frequency-by-design
249
292
  // rejections (rate limit, blocked user) that a client retries in a tight
@@ -252,7 +295,12 @@ export const startRequest = mutation({
252
295
  const reject = async (code: string, reason: string, persist = true) => {
253
296
  if (persist) {
254
297
  await ctx.db.insert("requests", {
255
- ...args,
298
+ userId: args.userId,
299
+ actionName: args.actionName,
300
+ ...(extraTags.length ? { tags: extraTags } : {}),
301
+ model: args.model,
302
+ messages: args.messages,
303
+ rerunOf: args.rerunOf,
256
304
  status: "blocked" as const,
257
305
  error: reason,
258
306
  });
@@ -265,14 +313,11 @@ export const startRequest = mutation({
265
313
  const est = estimateUsage(args.messages, priceInfo);
266
314
  const warnings: string[] = [];
267
315
 
268
- if (user.blocked) {
269
- return reject("blocked", `User "${args.userId}" is blocked`, false);
270
- }
271
316
  // 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 ?? [];
317
+ const settings = await getSettings(ctx);
318
+ if (settings) {
319
+ const mode = settings.modelMode ?? "open";
320
+ const list = settings.models ?? [];
276
321
  if (mode === "allowlist" && !list.includes(args.model)) {
277
322
  return reject(
278
323
  "model_not_allowed",
@@ -283,9 +328,35 @@ export const startRequest = mutation({
283
328
  return reject("model_denied", `Model "${args.model}" is denied`);
284
329
  }
285
330
  }
286
- if (user.requestsPerMinute !== undefined) {
331
+
332
+ // Fetch/create every bucket this request is attributed to (user, action,
333
+ // and any extra tags). Each may carry its own budget.
334
+ const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
335
+ const buckets: Doc<"buckets">[] = [];
336
+ for (const t of bucketTags) {
337
+ buckets.push(await getOrCreateBucket(ctx, t.dimension, t.value));
338
+ }
339
+
340
+ // A hard block on ANY bucket rejects the request. The user dimension's block
341
+ // isn't persisted (retried in a loop); config-level blocks on other
342
+ // dimensions are rarer, so they persist for the audit log.
343
+ for (const b of buckets) {
344
+ if (b.blocked) {
345
+ const label = b.dimension === USER_DIM ? "User" : b.dimension;
346
+ return reject(
347
+ `${b.dimension}_blocked`,
348
+ `${label} "${b.value}" is blocked`,
349
+ b.dimension !== USER_DIM
350
+ );
351
+ }
352
+ }
353
+
354
+ // Rate limit is enforced on the `user` dimension (the requests table is
355
+ // indexed by userId, so the 60s window is a bounded index read).
356
+ const userBucket = buckets.find((b) => b.dimension === USER_DIM)!;
357
+ if (userBucket.requestsPerMinute !== undefined) {
287
358
  // 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
359
+ // window have reached the limit. Reading limit+50 non-blocked rows is
289
360
  // enough, and .take caps the scan even if the window is flooded. Blocked
290
361
  // rows aren't persisted for rate-limit/blocked rejections (see reject),
291
362
  // so the window stays small.
@@ -294,106 +365,74 @@ export const startRequest = mutation({
294
365
  .withIndex("userId", (q) =>
295
366
  q.eq("userId", args.userId).gt("_creationTime", Date.now() - 60_000)
296
367
  )
297
- .take(user.requestsPerMinute + 50);
368
+ .take(userBucket.requestsPerMinute + 50);
298
369
  if (
299
370
  recent.filter((r) => r.status !== "blocked").length >=
300
- user.requestsPerMinute
371
+ userBucket.requestsPerMinute
301
372
  ) {
302
373
  return reject(
303
374
  "rate_limit",
304
- `Rate limit exceeded for "${args.userId}" (${user.requestsPerMinute}/min)`,
375
+ `Rate limit exceeded for "${args.userId}" (${userBucket.requestsPerMinute}/min)`,
305
376
  false
306
377
  );
307
378
  }
308
379
  }
380
+
309
381
  // 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",
382
+ // under EACH bucket's cap. Decided and reserved in one transaction, so
383
+ // Convex's serializable isolation makes it a true atomic check-and-reserve
384
+ // across every capped bucket. Soft enforcement turns a violation into a
385
+ // warning instead of a block.
386
+ for (const b of buckets) {
387
+ const sameDay = b.dayStamp === today;
388
+ const ev = evaluateCaps({
389
+ label: b.dimension,
390
+ name: b.value,
391
+ enforcement: b.enforcement ?? "hard",
353
392
  estCost: est.cost,
354
393
  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,
394
+ spendToday: sameDay ? b.spendTodayNanos : 0,
395
+ reservedSpendToday: sameDay ? b.reservedTodayNanos ?? 0 : 0,
396
+ totalSpend: b.totalSpendNanos,
397
+ reservedSpendTotal: b.reservedTotalNanos ?? 0,
398
+ tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
399
+ reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
400
+ totalTokens: b.totalTokens,
401
+ reservedTokensTotal: b.reservedTotalTokens ?? 0,
363
402
  dailySpendLimitNanos: withBump(
364
- action.dailySpendLimitNanos,
365
- action.bumpDayStamp === today ? action.dailyBumpNanos : 0
403
+ b.dailySpendLimitNanos,
404
+ b.bumpDayStamp === today ? b.dailyBumpNanos : 0
366
405
  ),
367
406
  lifetimeSpendLimitNanos: withBump(
368
- action.lifetimeSpendLimitNanos,
369
- action.lifetimeBumpNanos
407
+ b.lifetimeSpendLimitNanos,
408
+ b.lifetimeBumpNanos
370
409
  ),
371
- dailyTokenLimit: action.dailyTokenLimit,
372
- lifetimeTokenLimit: action.lifetimeTokenLimit,
410
+ dailyTokenLimit: b.dailyTokenLimit,
411
+ lifetimeTokenLimit: b.lifetimeTokenLimit,
373
412
  });
374
- if (actionEval.hard) return reject(actionEval.hard.code, actionEval.hard.reason);
375
- warnings.push(...actionEval.warnings);
413
+ if (ev.hard) return reject(ev.hard.code, ev.hard.reason);
414
+ warnings.push(...ev.warnings);
376
415
  }
377
416
 
378
417
  // 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
418
+ // the SAME evaluateCaps logic as the per-bucket caps above, so the guarantee
380
419
  // statement is uniform: a request is admitted only if
381
420
  // committed + reserved + estimate <= cap. The ONE difference is the holder:
382
- // per-user/per-action reserve on a single row (an exact atomic
421
+ // per-bucket caps reserve on a single row (an exact atomic
383
422
  // check-and-reserve), while the global holder is a sharded counter for
384
423
  // throughput — its committed total is read as an eventually-consistent sum
385
424
  // with no cross-request reservation, so a hard global cap can overshoot by a
386
425
  // bounded amount under burst. That's the deliberate exactness/throughput
387
426
  // trade for a deployment-wide killswitch; it's the only approximate scope.
388
427
  if (
389
- policy &&
390
- (policy.globalDailySpendLimitNanos !== undefined ||
391
- policy.globalLifetimeSpendLimitNanos !== undefined)
428
+ settings &&
429
+ (settings.globalDailySpendLimitNanos !== undefined ||
430
+ settings.globalLifetimeSpendLimitNanos !== undefined)
392
431
  ) {
393
432
  const globalEval = evaluateCaps({
394
433
  label: "global",
395
434
  name: "deployment",
396
- enforcement: policy.globalEnforcement ?? "hard",
435
+ enforcement: settings.globalEnforcement ?? "hard",
397
436
  estCost: est.cost,
398
437
  estTokens: est.tokens,
399
438
  spendToday: await globalSpend.count(ctx, globalDayKey(today)),
@@ -405,49 +444,44 @@ export const startRequest = mutation({
405
444
  totalTokens: 0,
406
445
  reservedTokensTotal: 0,
407
446
  dailySpendLimitNanos: withBump(
408
- policy.globalDailySpendLimitNanos,
409
- policy.globalBumpDayStamp === today ? policy.globalDailyBumpNanos : 0
447
+ settings.globalDailySpendLimitNanos,
448
+ settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0
410
449
  ),
411
450
  lifetimeSpendLimitNanos: withBump(
412
- policy.globalLifetimeSpendLimitNanos,
413
- policy.globalLifetimeBumpNanos
451
+ settings.globalLifetimeSpendLimitNanos,
452
+ settings.globalLifetimeBumpNanos
414
453
  ),
415
454
  });
416
455
  if (globalEval.hard) return reject(globalEval.hard.code, globalEval.hard.reason);
417
456
  warnings.push(...globalEval.warnings);
418
457
  }
419
458
 
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, {
459
+ // Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
460
+ // uncapped bucket's row here would serialize every request that shares it
461
+ // (e.g. all callers of one action, or every request in one env); with no cap
462
+ // there's no reserved amount to consult. Totals are still accrued later,
463
+ // asynchronously, in foldOne — for every bucket, capped or not.
464
+ for (const b of buckets) {
465
+ if (!hasAnyCap(b)) continue;
466
+ const sameDay = b.dayStamp === today;
467
+ await ctx.db.patch(b._id, {
439
468
  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,
469
+ spendTodayNanos: sameDay ? b.spendTodayNanos : 0,
470
+ tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
471
+ reservedTodayNanos: (sameDay ? b.reservedTodayNanos ?? 0 : 0) + est.cost,
472
+ reservedTotalNanos: (b.reservedTotalNanos ?? 0) + est.cost,
473
+ reservedTodayTokens: (sameDay ? b.reservedTodayTokens ?? 0 : 0) + est.tokens,
474
+ reservedTotalTokens: (b.reservedTotalTokens ?? 0) + est.tokens,
475
+ pendingCount: (b.pendingCount ?? 0) + 1,
447
476
  });
448
477
  }
449
478
  const requestId = await ctx.db.insert("requests", {
450
- ...args,
479
+ userId: args.userId,
480
+ actionName: args.actionName,
481
+ ...(extraTags.length ? { tags: extraTags } : {}),
482
+ model: args.model,
483
+ messages: args.messages,
484
+ rerunOf: args.rerunOf,
451
485
  status: "pending",
452
486
  estimatedNanos: est.cost,
453
487
  estimatedTokens: est.tokens,
@@ -509,7 +543,7 @@ export const finishRequest = mutation({
509
543
  settled: false,
510
544
  });
511
545
 
512
- // Fold into the (hot) user/action counters in a separate mutation. If it
546
+ // Fold into the (hot) per-bucket counters in a separate mutation. If it
513
547
  // exhausts retries under contention, the cron reconciler picks it up.
514
548
  await ctx.scheduler.runAfter(0, internal.lib.foldTotals, {
515
549
  requestId: args.requestId,
@@ -518,9 +552,9 @@ export const finishRequest = mutation({
518
552
  },
519
553
  });
520
554
 
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.
555
+ // Fold one finished request into every attributed bucket's running totals,
556
+ // releasing its reservation. Idempotent: guarded by `settled` so the scheduler
557
+ // and the cron reconciler can never double-count.
524
558
  async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
525
559
  if (!req || req.settled !== false) return;
526
560
  const actual = req.costNanos ?? 0;
@@ -529,47 +563,32 @@ async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
529
563
  const estTokens = req.estimatedTokens ?? 0;
530
564
  const today = dayStamp();
531
565
 
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,
566
+ // Accrue into every attributed bucket (user, action, and each tag) — capped
567
+ // or not. Buckets that never held a reservation have their reserved fields
568
+ // clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
569
+ for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
570
+ const b = await getOrCreateBucket(ctx, t.dimension, t.value);
571
+ const sameDay = b.dayStamp === today;
572
+ await ctx.db.patch(b._id, {
573
+ totalSpendNanos: b.totalSpendNanos + actual,
574
+ totalRequests: b.totalRequests + 1,
575
+ totalTokens: b.totalTokens + tokens,
555
576
  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),
577
+ spendTodayNanos: (sameDay ? b.spendTodayNanos : 0) + actual,
578
+ tokensToday: (sameDay ? b.tokensToday ?? 0 : 0) + tokens,
579
+ reservedTodayNanos: Math.max(0, (sameDay ? b.reservedTodayNanos ?? 0 : 0) - estCost),
580
+ reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
581
+ reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
582
+ reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
583
+ pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
563
584
  });
564
585
  }
586
+
565
587
  // Deployment-wide totals via the sharded counter (only when a global cap is
566
588
  // configured — otherwise skip the writes entirely). Distributed across shards,
567
589
  // so this does not serialize on a single row.
568
590
  if (actual > 0) {
569
- const settings = await ctx.db
570
- .query("settings")
571
- .withIndex("key", (q) => q.eq("key", "singleton"))
572
- .unique();
591
+ const settings = await getSettings(ctx);
573
592
  if (
574
593
  settings &&
575
594
  (settings.globalDailySpendLimitNanos !== undefined ||
@@ -703,23 +722,45 @@ export const listRequests = query({
703
722
  });
704
723
 
705
724
  const ADMIN_LIST_CAP = 2000;
706
- export const listUsers = query({
707
- args: {},
708
- handler: async (ctx) => {
725
+
726
+ // List budget buckets, optionally filtered to one dimension ("user", "action",
727
+ // or any custom tag dimension). Today's spend is zeroed for stale day windows.
728
+ export const listBuckets = query({
729
+ args: { dimension: v.optional(v.string()) },
730
+ handler: async (ctx, args) => {
709
731
  // 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);
732
+ // Paginate (ctx.db.query("buckets").paginate(...)) for larger deployments.
733
+ const rows =
734
+ args.dimension !== undefined
735
+ ? await ctx.db
736
+ .query("buckets")
737
+ .withIndex("dimension", (q) => q.eq("dimension", args.dimension!))
738
+ .take(ADMIN_LIST_CAP)
739
+ : await ctx.db.query("buckets").take(ADMIN_LIST_CAP);
712
740
  const today = dayStamp();
713
- return users.map((u) => ({
714
- ...u,
715
- spendTodayNanos: u.dayStamp === today ? u.spendTodayNanos : 0,
741
+ return rows.map((b) => ({
742
+ ...b,
743
+ spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
716
744
  }));
717
745
  },
718
746
  });
719
747
 
720
- export const setLimits = mutation({
748
+ export const getBucket = query({
749
+ args: { dimension: v.string(), value: v.string() },
750
+ handler: async (ctx, args) => {
751
+ const b = await getBucketDoc(ctx as any, args.dimension, args.value);
752
+ if (!b) return null;
753
+ const today = dayStamp();
754
+ return { ...b, spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0 };
755
+ },
756
+ });
757
+
758
+ // Set a bucket's limits/controls. `user` and `action` are just dimensions here;
759
+ // the client's ai.users / ai.actions namespaces are thin wrappers over this.
760
+ export const setBucketLimits = mutation({
721
761
  args: {
722
- userId: v.string(),
762
+ dimension: v.string(),
763
+ value: v.string(),
723
764
  requestsPerMinute: v.optional(v.number()),
724
765
  dailySpendLimitNanos: v.optional(v.number()),
725
766
  lifetimeSpendLimitNanos: v.optional(v.number()),
@@ -730,126 +771,67 @@ export const setLimits = mutation({
730
771
  },
731
772
  returns: v.null(),
732
773
  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);
774
+ const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
775
+ const { dimension: _d, value: _v, ...limits } = args;
776
+ await ctx.db.patch(bucket._id, limits);
736
777
  return null;
737
778
  },
738
779
  });
739
780
 
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
781
  const vBumpArgs = {
781
782
  dailyNanos: v.optional(v.number()),
782
783
  lifetimeNanos: v.optional(v.number()),
783
784
  };
784
785
 
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 },
786
+ // One-time "approve another $X" bumps, added on top of a bucket's standing cap
787
+ // without changing it. Daily bumps apply to today only; lifetime bumps persist.
788
+ export const bumpBucket = mutation({
789
+ args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
789
790
  returns: v.null(),
790
- handler: async (ctx, { userId, dailyNanos, lifetimeNanos }) => {
791
- const user = await getOrCreateUser(ctx, userId);
791
+ handler: async (ctx, { dimension, value, dailyNanos, lifetimeNanos }) => {
792
+ const bucket = await getOrCreateBucket(ctx, dimension, value);
792
793
  const today = dayStamp();
793
- const curDaily = user.bumpDayStamp === today ? user.dailyBumpNanos ?? 0 : 0;
794
- await ctx.db.patch(user._id, {
794
+ const curDaily =
795
+ bucket.bumpDayStamp === today ? bucket.dailyBumpNanos ?? 0 : 0;
796
+ await ctx.db.patch(bucket._id, {
795
797
  bumpDayStamp: today,
796
798
  dailyBumpNanos: curDaily + (dailyNanos ?? 0),
797
- lifetimeBumpNanos: (user.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
799
+ lifetimeBumpNanos: (bucket.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
798
800
  });
799
801
  return null;
800
802
  },
801
803
  });
802
804
 
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;
805
+ // Delete a bucket and (for the `user` dimension) all of that user's request
806
+ // rows e.g. account deletion / GDPR. Deletes requests in bounded batches and
807
+ // self-reschedules so it never exceeds the per-transaction document limit.
808
+ const DELETE_BATCH = 500;
809
+ export const deleteBucket = mutation({
810
+ args: { dimension: v.string(), value: v.string() },
811
+ returns: v.object({ deletedThisBatch: v.number(), done: v.boolean() }),
812
+ handler: async (ctx, { dimension, value }) => {
813
+ // Only the user dimension owns request rows (indexed by userId). Other
814
+ // dimensions just drop their budget-holder row.
815
+ if (dimension === USER_DIM) {
816
+ const rows = await ctx.db
817
+ .query("requests")
818
+ .withIndex("userId", (q) => q.eq("userId", value))
819
+ .take(DELETE_BATCH);
820
+ for (const r of rows) await ctx.db.delete(r._id);
821
+ if (rows.length === DELETE_BATCH) {
822
+ await ctx.scheduler.runAfter(0, api.lib.deleteBucket, {
823
+ dimension,
824
+ value,
825
+ });
826
+ return { deletedThisBatch: rows.length, done: false };
827
+ }
828
+ const bucket = await getBucketDoc(ctx, dimension, value);
829
+ if (bucket) await ctx.db.delete(bucket._id);
830
+ return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
831
+ }
832
+ const bucket = await getBucketDoc(ctx, dimension, value);
833
+ if (bucket) await ctx.db.delete(bucket._id);
834
+ return { deletedThisBatch: bucket ? 1 : 0, done: true };
853
835
  },
854
836
  });
855
837
 
@@ -920,6 +902,24 @@ export const setGlobalLimits = mutation({
920
902
  },
921
903
  });
922
904
 
905
+ export const bumpGlobal = mutation({
906
+ args: vBumpArgs,
907
+ returns: v.null(),
908
+ handler: async (ctx, { dailyNanos, lifetimeNanos }) => {
909
+ const today = dayStamp();
910
+ const s = await getSettings(ctx);
911
+ const curDaily = s?.globalBumpDayStamp === today ? s?.globalDailyBumpNanos ?? 0 : 0;
912
+ const patch = {
913
+ globalBumpDayStamp: today,
914
+ globalDailyBumpNanos: curDaily + (dailyNanos ?? 0),
915
+ globalLifetimeBumpNanos: (s?.globalLifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
916
+ };
917
+ if (s) await ctx.db.patch(s._id, patch);
918
+ else await ctx.db.insert("settings", { key: "singleton", ...patch });
919
+ return null;
920
+ },
921
+ });
922
+
923
923
  export const setModelPolicy = mutation({
924
924
  args: {
925
925
  mode: v.union(