@convex-dev/ai-budget 0.0.2-alpha.3 → 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.
@@ -1,7 +1,7 @@
1
1
  import { v } from "convex/values";
2
2
  import { mutation, internalMutation, query, } from "./_generated/server";
3
3
  import { api, internal, components } from "./_generated/api";
4
- import { vMessage } from "./schema";
4
+ import { vMessage, vTag } from "./schema";
5
5
  import { ShardedCounter } from "@convex-dev/sharded-counter";
6
6
  // All money is integer **nanodollars** (1 USD = 1e9 nano). Integers avoid the
7
7
  // rounding drift that floating-point cents accumulate over millions of
@@ -11,6 +11,12 @@ import { ShardedCounter } from "@convex-dev/sharded-counter";
11
11
  // currency code alongside these amounts and convert here, at the one boundary.
12
12
  const NANOS_PER_DOLLAR = 1e9;
13
13
  const fmtUsd = (nanos) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
14
+ // Built-in attribution dimensions. `user` and `action` are always populated
15
+ // from a request's userId/actionName; apps can add any other dimensions
16
+ // (team, project, customer, env, …) as tags. These two names are reserved —
17
+ // tags carrying them are ignored in favor of the first-class fields.
18
+ const USER_DIM = "user";
19
+ const ACTION_DIM = "action";
14
20
  // Deployment-wide spend totals (nanodollars), sharded for high write throughput.
15
21
  // Keyed "total" (lifetime) and "day:<UTC date>" (natural daily reset).
16
22
  const globalSpend = new ShardedCounter(components.shardedCounter);
@@ -83,9 +89,44 @@ function estimateUsage(messages, price) {
83
89
  tokens: inputTokens + ESTIMATED_OUTPUT_TOKENS,
84
90
  };
85
91
  }
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.
92
+ // The full set of attribution buckets a request touches: the built-in `user`
93
+ // and `action` dimensions plus any extra tags. Reserved dimensions in `extra`
94
+ // are dropped (userId/actionName own them), and (dimension, value) pairs are
95
+ // de-duplicated. Used identically at reserve time (startRequest) and settle
96
+ // time (foldOne), so a request always settles exactly the buckets it reserved.
97
+ function requestBuckets(userId, actionName, extra) {
98
+ const out = [{ dimension: USER_DIM, value: userId }];
99
+ if (actionName !== undefined)
100
+ out.push({ dimension: ACTION_DIM, value: actionName });
101
+ for (const t of extra ?? []) {
102
+ if (t.dimension === USER_DIM || t.dimension === ACTION_DIM)
103
+ continue;
104
+ if (!t.dimension || !t.value)
105
+ continue;
106
+ if (out.some((x) => x.dimension === t.dimension && x.value === t.value))
107
+ continue;
108
+ out.push({ dimension: t.dimension, value: t.value });
109
+ }
110
+ return out;
111
+ }
112
+ // Drop reserved/empty/duplicate tags from a caller-supplied list, leaving the
113
+ // "extra" dimensions stored on the request row.
114
+ function sanitizeExtraTags(extra) {
115
+ const out = [];
116
+ for (const t of extra ?? []) {
117
+ if (t.dimension === USER_DIM || t.dimension === ACTION_DIM)
118
+ continue;
119
+ if (!t.dimension || !t.value)
120
+ continue;
121
+ if (out.some((x) => x.dimension === t.dimension && x.value === t.value))
122
+ continue;
123
+ out.push({ dimension: t.dimension, value: t.value });
124
+ }
125
+ return out;
126
+ }
127
+ // Evaluate a bucket's spend + token budgets against committed + reserved + this
128
+ // request's estimate. Returns a hard rejection (block) or a list of soft
129
+ // warnings (allow), per the bucket's enforcement.
89
130
  function evaluateCaps(o) {
90
131
  const violations = [];
91
132
  const push = (code, reason) => violations.push({ code: `${o.label}_${code}`, reason });
@@ -114,32 +155,19 @@ const hasAnyCap = (e) => e.dailySpendLimitNanos !== undefined ||
114
155
  e.lifetimeSpendLimitNanos !== undefined ||
115
156
  e.dailyTokenLimit !== undefined ||
116
157
  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))
158
+ async function getBucketDoc(ctx, dimension, value) {
159
+ return await ctx.db
160
+ .query("buckets")
161
+ .withIndex("dim_value", (q) => q.eq("dimension", dimension).eq("value", value))
121
162
  .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
163
  }
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();
164
+ async function getOrCreateBucket(ctx, dimension, value) {
165
+ const existing = await getBucketDoc(ctx, dimension, value);
139
166
  if (existing)
140
167
  return existing;
141
- const id = await ctx.db.insert("actions", {
142
- name,
168
+ const id = await ctx.db.insert("buckets", {
169
+ dimension,
170
+ value,
143
171
  totalSpendNanos: 0,
144
172
  totalRequests: 0,
145
173
  totalTokens: 0,
@@ -167,13 +195,16 @@ export const startRequest = mutation({
167
195
  args: {
168
196
  userId: v.string(),
169
197
  actionName: v.optional(v.string()),
198
+ // Extra attribution dimensions to bill/limit (team, customer, env, …).
199
+ // `user` and `action` are reserved (owned by userId/actionName).
200
+ tags: v.optional(v.array(vTag)),
170
201
  model: v.string(),
171
202
  messages: v.array(vMessage),
172
203
  rerunOf: v.optional(v.id("requests")),
173
204
  },
174
205
  returns: vStartResult,
175
206
  handler: async (ctx, args) => {
176
- const user = await getOrCreateUser(ctx, args.userId);
207
+ const extraTags = sanitizeExtraTags(args.tags);
177
208
  // Record the blocked attempt and return a rejection (throwing would roll
178
209
  // back the record). `persist` is false for the high-frequency-by-design
179
210
  // rejections (rate limit, blocked user) that a client retries in a tight
@@ -182,7 +213,12 @@ export const startRequest = mutation({
182
213
  const reject = async (code, reason, persist = true) => {
183
214
  if (persist) {
184
215
  await ctx.db.insert("requests", {
185
- ...args,
216
+ userId: args.userId,
217
+ actionName: args.actionName,
218
+ ...(extraTags.length ? { tags: extraTags } : {}),
219
+ model: args.model,
220
+ messages: args.messages,
221
+ rerunOf: args.rerunOf,
186
222
  status: "blocked",
187
223
  error: reason,
188
224
  });
@@ -193,14 +229,11 @@ export const startRequest = mutation({
193
229
  const priceInfo = await getPrice(ctx, args.model);
194
230
  const est = estimateUsage(args.messages, priceInfo);
195
231
  const warnings = [];
196
- if (user.blocked) {
197
- return reject("blocked", `User "${args.userId}" is blocked`, false);
198
- }
199
232
  // 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 ?? [];
233
+ const settings = await getSettings(ctx);
234
+ if (settings) {
235
+ const mode = settings.modelMode ?? "open";
236
+ const list = settings.models ?? [];
204
237
  if (mode === "allowlist" && !list.includes(args.model)) {
205
238
  return reject("model_not_allowed", `Model "${args.model}" is not on the allowlist`);
206
239
  }
@@ -208,95 +241,87 @@ export const startRequest = mutation({
208
241
  return reject("model_denied", `Model "${args.model}" is denied`);
209
242
  }
210
243
  }
211
- if (user.requestsPerMinute !== undefined) {
244
+ // Fetch/create every bucket this request is attributed to (user, action,
245
+ // and any extra tags). Each may carry its own budget.
246
+ const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
247
+ const buckets = [];
248
+ for (const t of bucketTags) {
249
+ buckets.push(await getOrCreateBucket(ctx, t.dimension, t.value));
250
+ }
251
+ // A hard block on ANY bucket rejects the request. The user dimension's block
252
+ // isn't persisted (retried in a loop); config-level blocks on other
253
+ // dimensions are rarer, so they persist for the audit log.
254
+ for (const b of buckets) {
255
+ if (b.blocked) {
256
+ const label = b.dimension === USER_DIM ? "User" : b.dimension;
257
+ return reject(`${b.dimension}_blocked`, `${label} "${b.value}" is blocked`, b.dimension !== USER_DIM);
258
+ }
259
+ }
260
+ // Rate limit is enforced on the `user` dimension (the requests table is
261
+ // indexed by userId, so the 60s window is a bounded index read).
262
+ const userBucket = buckets.find((b) => b.dimension === USER_DIM);
263
+ if (userBucket.requestsPerMinute !== undefined) {
212
264
  // 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
265
+ // window have reached the limit. Reading limit+50 non-blocked rows is
214
266
  // enough, and .take caps the scan even if the window is flooded. Blocked
215
267
  // rows aren't persisted for rate-limit/blocked rejections (see reject),
216
268
  // so the window stays small.
217
269
  const recent = await ctx.db
218
270
  .query("requests")
219
271
  .withIndex("userId", (q) => q.eq("userId", args.userId).gt("_creationTime", Date.now() - 60_000))
220
- .take(user.requestsPerMinute + 50);
272
+ .take(userBucket.requestsPerMinute + 50);
221
273
  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);
274
+ userBucket.requestsPerMinute) {
275
+ return reject("rate_limit", `Rate limit exceeded for "${args.userId}" (${userBucket.requestsPerMinute}/min)`, false);
224
276
  }
225
277
  }
226
278
  // 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",
279
+ // under EACH bucket's cap. Decided and reserved in one transaction, so
280
+ // Convex's serializable isolation makes it a true atomic check-and-reserve
281
+ // across every capped bucket. Soft enforcement turns a violation into a
282
+ // warning instead of a block.
283
+ for (const b of buckets) {
284
+ const sameDay = b.dayStamp === today;
285
+ const ev = evaluateCaps({
286
+ label: b.dimension,
287
+ name: b.value,
288
+ enforcement: b.enforcement ?? "hard",
264
289
  estCost: est.cost,
265
290
  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,
291
+ spendToday: sameDay ? b.spendTodayNanos : 0,
292
+ reservedSpendToday: sameDay ? b.reservedTodayNanos ?? 0 : 0,
293
+ totalSpend: b.totalSpendNanos,
294
+ reservedSpendTotal: b.reservedTotalNanos ?? 0,
295
+ tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
296
+ reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
297
+ totalTokens: b.totalTokens,
298
+ reservedTokensTotal: b.reservedTotalTokens ?? 0,
299
+ dailySpendLimitNanos: withBump(b.dailySpendLimitNanos, b.bumpDayStamp === today ? b.dailyBumpNanos : 0),
300
+ lifetimeSpendLimitNanos: withBump(b.lifetimeSpendLimitNanos, b.lifetimeBumpNanos),
301
+ dailyTokenLimit: b.dailyTokenLimit,
302
+ lifetimeTokenLimit: b.lifetimeTokenLimit,
278
303
  });
279
- if (actionEval.hard)
280
- return reject(actionEval.hard.code, actionEval.hard.reason);
281
- warnings.push(...actionEval.warnings);
304
+ if (ev.hard)
305
+ return reject(ev.hard.code, ev.hard.reason);
306
+ warnings.push(...ev.warnings);
282
307
  }
283
308
  // 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
309
+ // the SAME evaluateCaps logic as the per-bucket caps above, so the guarantee
285
310
  // statement is uniform: a request is admitted only if
286
311
  // committed + reserved + estimate <= cap. The ONE difference is the holder:
287
- // per-user/per-action reserve on a single row (an exact atomic
312
+ // per-bucket caps reserve on a single row (an exact atomic
288
313
  // check-and-reserve), while the global holder is a sharded counter for
289
314
  // throughput — its committed total is read as an eventually-consistent sum
290
315
  // with no cross-request reservation, so a hard global cap can overshoot by a
291
316
  // bounded amount under burst. That's the deliberate exactness/throughput
292
317
  // trade for a deployment-wide killswitch; it's the only approximate scope.
293
- if (policy &&
294
- (policy.globalDailySpendLimitNanos !== undefined ||
295
- policy.globalLifetimeSpendLimitNanos !== undefined)) {
318
+ if (settings &&
319
+ (settings.globalDailySpendLimitNanos !== undefined ||
320
+ settings.globalLifetimeSpendLimitNanos !== undefined)) {
296
321
  const globalEval = evaluateCaps({
297
322
  label: "global",
298
323
  name: "deployment",
299
- enforcement: policy.globalEnforcement ?? "hard",
324
+ enforcement: settings.globalEnforcement ?? "hard",
300
325
  estCost: est.cost,
301
326
  estTokens: est.tokens,
302
327
  spendToday: await globalSpend.count(ctx, globalDayKey(today)),
@@ -307,44 +332,40 @@ export const startRequest = mutation({
307
332
  reservedTokensToday: 0,
308
333
  totalTokens: 0,
309
334
  reservedTokensTotal: 0,
310
- dailySpendLimitNanos: withBump(policy.globalDailySpendLimitNanos, policy.globalBumpDayStamp === today ? policy.globalDailyBumpNanos : 0),
311
- lifetimeSpendLimitNanos: withBump(policy.globalLifetimeSpendLimitNanos, policy.globalLifetimeBumpNanos),
335
+ dailySpendLimitNanos: withBump(settings.globalDailySpendLimitNanos, settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0),
336
+ lifetimeSpendLimitNanos: withBump(settings.globalLifetimeSpendLimitNanos, settings.globalLifetimeBumpNanos),
312
337
  });
313
338
  if (globalEval.hard)
314
339
  return reject(globalEval.hard.code, globalEval.hard.reason);
315
340
  warnings.push(...globalEval.warnings);
316
341
  }
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, {
342
+ // Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
343
+ // uncapped bucket's row here would serialize every request that shares it
344
+ // (e.g. all callers of one action, or every request in one env); with no cap
345
+ // there's no reserved amount to consult. Totals are still accrued later,
346
+ // asynchronously, in foldOne — for every bucket, capped or not.
347
+ for (const b of buckets) {
348
+ if (!hasAnyCap(b))
349
+ continue;
350
+ const sameDay = b.dayStamp === today;
351
+ await ctx.db.patch(b._id, {
323
352
  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,
353
+ spendTodayNanos: sameDay ? b.spendTodayNanos : 0,
354
+ tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
355
+ reservedTodayNanos: (sameDay ? b.reservedTodayNanos ?? 0 : 0) + est.cost,
356
+ reservedTotalNanos: (b.reservedTotalNanos ?? 0) + est.cost,
357
+ reservedTodayTokens: (sameDay ? b.reservedTodayTokens ?? 0 : 0) + est.tokens,
358
+ reservedTotalTokens: (b.reservedTotalTokens ?? 0) + est.tokens,
359
+ pendingCount: (b.pendingCount ?? 0) + 1,
344
360
  });
345
361
  }
346
362
  const requestId = await ctx.db.insert("requests", {
347
- ...args,
363
+ userId: args.userId,
364
+ actionName: args.actionName,
365
+ ...(extraTags.length ? { tags: extraTags } : {}),
366
+ model: args.model,
367
+ messages: args.messages,
368
+ rerunOf: args.rerunOf,
348
369
  status: "pending",
349
370
  estimatedNanos: est.cost,
350
371
  estimatedTokens: est.tokens,
@@ -399,7 +420,7 @@ export const finishRequest = mutation({
399
420
  latencyMs: args.latencyMs,
400
421
  settled: false,
401
422
  });
402
- // Fold into the (hot) user/action counters in a separate mutation. If it
423
+ // Fold into the (hot) per-bucket counters in a separate mutation. If it
403
424
  // exhausts retries under contention, the cron reconciler picks it up.
404
425
  await ctx.scheduler.runAfter(0, internal.lib.foldTotals, {
405
426
  requestId: args.requestId,
@@ -407,9 +428,9 @@ export const finishRequest = mutation({
407
428
  return { costNanos };
408
429
  },
409
430
  });
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.
431
+ // Fold one finished request into every attributed bucket's running totals,
432
+ // releasing its reservation. Idempotent: guarded by `settled` so the scheduler
433
+ // and the cron reconciler can never double-count.
413
434
  async function foldOne(ctx, req) {
414
435
  if (!req || req.settled !== false)
415
436
  return;
@@ -418,46 +439,31 @@ async function foldOne(ctx, req) {
418
439
  const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
419
440
  const estTokens = req.estimatedTokens ?? 0;
420
441
  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,
442
+ // Accrue into every attributed bucket (user, action, and each tag) — capped
443
+ // or not. Buckets that never held a reservation have their reserved fields
444
+ // clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
445
+ for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
446
+ const b = await getOrCreateBucket(ctx, t.dimension, t.value);
447
+ const sameDay = b.dayStamp === today;
448
+ await ctx.db.patch(b._id, {
449
+ totalSpendNanos: b.totalSpendNanos + actual,
450
+ totalRequests: b.totalRequests + 1,
451
+ totalTokens: b.totalTokens + tokens,
443
452
  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),
453
+ spendTodayNanos: (sameDay ? b.spendTodayNanos : 0) + actual,
454
+ tokensToday: (sameDay ? b.tokensToday ?? 0 : 0) + tokens,
455
+ reservedTodayNanos: Math.max(0, (sameDay ? b.reservedTodayNanos ?? 0 : 0) - estCost),
456
+ reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
457
+ reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
458
+ reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
459
+ pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
451
460
  });
452
461
  }
453
462
  // Deployment-wide totals via the sharded counter (only when a global cap is
454
463
  // configured — otherwise skip the writes entirely). Distributed across shards,
455
464
  // so this does not serialize on a single row.
456
465
  if (actual > 0) {
457
- const settings = await ctx.db
458
- .query("settings")
459
- .withIndex("key", (q) => q.eq("key", "singleton"))
460
- .unique();
466
+ const settings = await getSettings(ctx);
461
467
  if (settings &&
462
468
  (settings.globalDailySpendLimitNanos !== undefined ||
463
469
  settings.globalLifetimeSpendLimitNanos !== undefined)) {
@@ -580,22 +586,42 @@ export const listRequests = query({
580
586
  },
581
587
  });
582
588
  const ADMIN_LIST_CAP = 2000;
583
- export const listUsers = query({
584
- args: {},
585
- handler: async (ctx) => {
589
+ // List budget buckets, optionally filtered to one dimension ("user", "action",
590
+ // or any custom tag dimension). Today's spend is zeroed for stale day windows.
591
+ export const listBuckets = query({
592
+ args: { dimension: v.optional(v.string()) },
593
+ handler: async (ctx, args) => {
586
594
  // 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);
595
+ // Paginate (ctx.db.query("buckets").paginate(...)) for larger deployments.
596
+ const rows = args.dimension !== undefined
597
+ ? await ctx.db
598
+ .query("buckets")
599
+ .withIndex("dimension", (q) => q.eq("dimension", args.dimension))
600
+ .take(ADMIN_LIST_CAP)
601
+ : await ctx.db.query("buckets").take(ADMIN_LIST_CAP);
589
602
  const today = dayStamp();
590
- return users.map((u) => ({
591
- ...u,
592
- spendTodayNanos: u.dayStamp === today ? u.spendTodayNanos : 0,
603
+ return rows.map((b) => ({
604
+ ...b,
605
+ spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
593
606
  }));
594
607
  },
595
608
  });
596
- export const setLimits = mutation({
609
+ export const getBucket = query({
610
+ args: { dimension: v.string(), value: v.string() },
611
+ handler: async (ctx, args) => {
612
+ const b = await getBucketDoc(ctx, args.dimension, args.value);
613
+ if (!b)
614
+ return null;
615
+ const today = dayStamp();
616
+ return { ...b, spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0 };
617
+ },
618
+ });
619
+ // Set a bucket's limits/controls. `user` and `action` are just dimensions here;
620
+ // the client's ai.users / ai.actions namespaces are thin wrappers over this.
621
+ export const setBucketLimits = mutation({
597
622
  args: {
598
- userId: v.string(),
623
+ dimension: v.string(),
624
+ value: v.string(),
599
625
  requestsPerMinute: v.optional(v.number()),
600
626
  dailySpendLimitNanos: v.optional(v.number()),
601
627
  lifetimeSpendLimitNanos: v.optional(v.number()),
@@ -606,123 +632,66 @@ export const setLimits = mutation({
606
632
  },
607
633
  returns: v.null(),
608
634
  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);
635
+ const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
636
+ const { dimension: _d, value: _v, ...limits } = args;
637
+ await ctx.db.patch(bucket._id, limits);
612
638
  return null;
613
639
  },
614
640
  });
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
641
  const vBumpArgs = {
656
642
  dailyNanos: v.optional(v.number()),
657
643
  lifetimeNanos: v.optional(v.number()),
658
644
  };
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 },
645
+ // One-time "approve another $X" bumps, added on top of a bucket's standing cap
646
+ // without changing it. Daily bumps apply to today only; lifetime bumps persist.
647
+ export const bumpBucket = mutation({
648
+ args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
678
649
  returns: v.null(),
679
- handler: async (ctx, { name, dailyNanos, lifetimeNanos }) => {
680
- const action = await getOrCreateAction(ctx, name);
650
+ handler: async (ctx, { dimension, value, dailyNanos, lifetimeNanos }) => {
651
+ const bucket = await getOrCreateBucket(ctx, dimension, value);
681
652
  const today = dayStamp();
682
- const curDaily = action.bumpDayStamp === today ? action.dailyBumpNanos ?? 0 : 0;
683
- await ctx.db.patch(action._id, {
653
+ const curDaily = bucket.bumpDayStamp === today ? bucket.dailyBumpNanos ?? 0 : 0;
654
+ await ctx.db.patch(bucket._id, {
684
655
  bumpDayStamp: today,
685
656
  dailyBumpNanos: curDaily + (dailyNanos ?? 0),
686
- lifetimeBumpNanos: (action.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
657
+ lifetimeBumpNanos: (bucket.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
687
658
  });
688
659
  return null;
689
660
  },
690
661
  });
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;
662
+ // Delete a bucket and (for the `user` dimension) all of that user's request
663
+ // rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
664
+ // self-reschedules so it never exceeds the per-transaction document limit.
665
+ const DELETE_BATCH = 500;
666
+ export const deleteBucket = mutation({
667
+ args: { dimension: v.string(), value: v.string() },
668
+ returns: v.object({ deletedThisBatch: v.number(), done: v.boolean() }),
669
+ handler: async (ctx, { dimension, value }) => {
670
+ // Only the user dimension owns request rows (indexed by userId). Other
671
+ // dimensions just drop their budget-holder row.
672
+ if (dimension === USER_DIM) {
673
+ const rows = await ctx.db
674
+ .query("requests")
675
+ .withIndex("userId", (q) => q.eq("userId", value))
676
+ .take(DELETE_BATCH);
677
+ for (const r of rows)
678
+ await ctx.db.delete(r._id);
679
+ if (rows.length === DELETE_BATCH) {
680
+ await ctx.scheduler.runAfter(0, api.lib.deleteBucket, {
681
+ dimension,
682
+ value,
683
+ });
684
+ return { deletedThisBatch: rows.length, done: false };
685
+ }
686
+ const bucket = await getBucketDoc(ctx, dimension, value);
687
+ if (bucket)
688
+ await ctx.db.delete(bucket._id);
689
+ return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
690
+ }
691
+ const bucket = await getBucketDoc(ctx, dimension, value);
692
+ if (bucket)
693
+ await ctx.db.delete(bucket._id);
694
+ return { deletedThisBatch: bucket ? 1 : 0, done: true };
726
695
  },
727
696
  });
728
697
  export const getModelPolicy = query({
@@ -786,6 +755,25 @@ export const setGlobalLimits = mutation({
786
755
  return null;
787
756
  },
788
757
  });
758
+ export const bumpGlobal = mutation({
759
+ args: vBumpArgs,
760
+ returns: v.null(),
761
+ handler: async (ctx, { dailyNanos, lifetimeNanos }) => {
762
+ const today = dayStamp();
763
+ const s = await getSettings(ctx);
764
+ const curDaily = s?.globalBumpDayStamp === today ? s?.globalDailyBumpNanos ?? 0 : 0;
765
+ const patch = {
766
+ globalBumpDayStamp: today,
767
+ globalDailyBumpNanos: curDaily + (dailyNanos ?? 0),
768
+ globalLifetimeBumpNanos: (s?.globalLifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
769
+ };
770
+ if (s)
771
+ await ctx.db.patch(s._id, patch);
772
+ else
773
+ await ctx.db.insert("settings", { key: "singleton", ...patch });
774
+ return null;
775
+ },
776
+ });
789
777
  export const setModelPolicy = mutation({
790
778
  args: {
791
779
  mode: v.union(v.literal("open"), v.literal("allowlist"), v.literal("denylist")),