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

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.
@@ -48,7 +48,12 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
48
48
  // audit table — and the sensitive content in it — from growing without bound.
49
49
  // Override per-deployment via setRetention.
50
50
  const DEFAULT_RETENTION_MS = 60 * 60 * 1000; // 1 hour
51
- const dayStamp = () => new Date().toISOString().slice(0, 10);
51
+ const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
52
+ const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
53
+ // Cached (prompt-cache-read) input tokens are billed far below the normal input
54
+ // rate. When a model's price has no explicit cachedNanosPerMTok, charge this
55
+ // fraction of its input rate (providers commonly discount ~90%).
56
+ const CACHE_DISCOUNT = 0.1;
52
57
  // Conservative fallback for any model not in the price table: the max of every
53
58
  // known price dimension. Falling back to 0 would be fail-open — an unpriced
54
59
  // model would reserve 0, pass every cap, and log 0¢ while the AI Gateway still
@@ -68,17 +73,62 @@ async function getPrice(ctx, model) {
68
73
  return {
69
74
  input: override.inputNanosPerMTok,
70
75
  output: override.outputNanosPerMTok,
76
+ cached: override.cachedNanosPerMTok,
71
77
  known: true,
72
78
  };
73
79
  }
74
80
  const known = DEFAULT_PRICES[model];
75
81
  if (known)
76
- return { ...known, known: true };
77
- return { ...CONSERVATIVE_PRICE, known: false };
82
+ return { ...known, cached: undefined, known: true };
83
+ return { ...CONSERVATIVE_PRICE, cached: undefined, known: false };
78
84
  }
85
+ // The per-Mtok rate for cached (prompt-cache-read) tokens: an explicit override,
86
+ // else a discount off the input rate.
87
+ const cachedRate = (p) => p.cached ?? Math.round(p.input * CACHE_DISCOUNT);
79
88
  // Integer nanodollars. Divide-before-multiply keeps the intermediate product
80
89
  // within 2^53 even for large token counts × large per-Mtok prices.
81
90
  const costOf = (inputTokens, outputTokens, price) => Math.round((inputTokens / 1e6) * price.input + (outputTokens / 1e6) * price.output);
91
+ // Cache-aware settle cost: the cached slice of the prompt is billed at the
92
+ // (discounted) cache rate, the rest of the prompt at the input rate, and
93
+ // completions at the output rate. `cachedTokens` is the gateway's real
94
+ // prompt-cache-read count (usage.inputTokenDetails.cacheReadTokens).
95
+ const settleCost = (promptTokens, cachedTokens, completionTokens, price) => {
96
+ const cached = Math.min(Math.max(0, cachedTokens), Math.max(0, promptTokens));
97
+ const fresh = Math.max(0, promptTokens - cached);
98
+ return Math.round((fresh / 1e6) * price.input +
99
+ (cached / 1e6) * cachedRate(price) +
100
+ (completionTokens / 1e6) * price.output);
101
+ };
102
+ // Upsert-add a settled amount into the durable per-(bucket, period) usage row.
103
+ // These rows are never swept by request retention, so spend history survives.
104
+ async function addUsage(ctx, dimension, value, period, stamp, spendNanos, tokens, requests) {
105
+ const existing = await ctx.db
106
+ .query("usage")
107
+ .withIndex("bucket_period_stamp", (q) => q
108
+ .eq("dimension", dimension)
109
+ .eq("value", value)
110
+ .eq("period", period)
111
+ .eq("stamp", stamp))
112
+ .unique();
113
+ if (existing) {
114
+ await ctx.db.patch(existing._id, {
115
+ spendNanos: existing.spendNanos + spendNanos,
116
+ tokens: existing.tokens + tokens,
117
+ requests: existing.requests + requests,
118
+ });
119
+ }
120
+ else {
121
+ await ctx.db.insert("usage", {
122
+ dimension,
123
+ value,
124
+ period,
125
+ stamp,
126
+ spendNanos,
127
+ tokens,
128
+ requests,
129
+ });
130
+ }
131
+ }
82
132
  // Up-front estimate of a request's cost and token count, reserved before the
83
133
  // call so concurrent in-flight requests are visible to each other's caps.
84
134
  function estimateUsage(messages, price) {
@@ -125,36 +175,56 @@ function sanitizeExtraTags(extra) {
125
175
  return out;
126
176
  }
127
177
  // 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.
178
+ // request's estimate. Returns a hard rejection (block), soft warnings (allow),
179
+ // and threshold notices (approaching a cap see warnAtPct). Each window (daily,
180
+ // monthly, lifetime) × kind (spend, token) is one check.
130
181
  function evaluateCaps(o) {
182
+ // { code, projected usage (incl. this estimate), cap, human window label,
183
+ // whether it's a money cap (formatted as $), spend? for notices }
184
+ const checks = [
185
+ { w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost, cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
186
+ { w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost, cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
187
+ { w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost, cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
188
+ { w: "daily_token_limit", used: o.tokensToday + o.reservedTokensToday + o.estTokens, cap: o.dailyTokenLimit, label: "daily token limit", money: false },
189
+ { w: "monthly_token_limit", used: o.tokensThisMonth + o.reservedTokensMonth + o.estTokens, cap: o.monthlyTokenLimit, label: "monthly token limit", money: false },
190
+ { w: "lifetime_token_limit", used: o.totalTokens + o.reservedTokensTotal + o.estTokens, cap: o.lifetimeTokenLimit, label: "lifetime token limit", money: false },
191
+ ];
131
192
  const violations = [];
132
- const push = (code, reason) => violations.push({ code: `${o.label}_${code}`, reason });
133
- if (o.dailySpendLimitNanos !== undefined &&
134
- o.spendToday + o.reservedSpendToday + o.estCost > o.dailySpendLimitNanos)
135
- push("daily_spend_limit", `Daily spend limit reached for ${o.label} "${o.name}" (${fmtUsd(o.dailySpendLimitNanos)}/day)`);
136
- if (o.lifetimeSpendLimitNanos !== undefined &&
137
- o.totalSpend + o.reservedSpendTotal + o.estCost > o.lifetimeSpendLimitNanos)
138
- push("lifetime_spend_limit", `Lifetime spend limit reached for ${o.label} "${o.name}"`);
139
- if (o.dailyTokenLimit !== undefined &&
140
- o.tokensToday + o.reservedTokensToday + o.estTokens > o.dailyTokenLimit)
141
- push("daily_token_limit", `Daily token limit reached for ${o.label} "${o.name}" (${o.dailyTokenLimit}/day)`);
142
- if (o.lifetimeTokenLimit !== undefined &&
143
- o.totalTokens + o.reservedTokensTotal + o.estTokens > o.lifetimeTokenLimit)
144
- push("lifetime_token_limit", `Lifetime token limit reached for ${o.label} "${o.name}"`);
193
+ const notices = [];
194
+ const pct = o.warnAtPct;
195
+ for (const c of checks) {
196
+ if (c.cap === undefined)
197
+ continue;
198
+ const capStr = c.money ? `${fmtUsd(c.cap)}` : `${c.cap} tokens`;
199
+ if (c.used > c.cap) {
200
+ violations.push({
201
+ code: `${o.label}_${c.w}`,
202
+ reason: `${cap(c.label)} reached for ${o.label} "${o.name}" (${capStr})`,
203
+ });
204
+ }
205
+ else if (pct !== undefined && pct > 0 && pct < 1 && c.used >= pct * c.cap) {
206
+ notices.push(`${o.label} "${o.name}" at ${Math.round((c.used / c.cap) * 100)}% of ${c.label} (${capStr})`);
207
+ }
208
+ }
145
209
  if (violations.length === 0)
146
- return { warnings: [] };
210
+ return { warnings: [], notices };
147
211
  if (o.enforcement === "soft")
148
- return { warnings: violations.map((v) => v.reason) };
149
- return { hard: violations[0], warnings: [] };
212
+ return { warnings: violations.map((v) => v.reason), notices };
213
+ return { hard: violations[0], warnings: [], notices };
150
214
  }
215
+ const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
151
216
  // A cap plus any one-time bump. Returns undefined when there's no base cap
152
217
  // (a bump alone never creates a cap).
153
218
  const withBump = (base, bump) => base === undefined ? undefined : base + (bump ?? 0);
154
219
  const hasAnyCap = (e) => e.dailySpendLimitNanos !== undefined ||
220
+ e.monthlySpendLimitNanos !== undefined ||
155
221
  e.lifetimeSpendLimitNanos !== undefined ||
156
222
  e.dailyTokenLimit !== undefined ||
223
+ e.monthlyTokenLimit !== undefined ||
157
224
  e.lifetimeTokenLimit !== undefined;
225
+ // A bucket needs a reservation row-write if it has any spend/token cap OR a
226
+ // concurrency cap (which reads pendingCount, incremented at reserve time).
227
+ const needsReserve = (e) => hasAnyCap(e) || e.maxConcurrent !== undefined;
158
228
  async function getBucketDoc(ctx, dimension, value) {
159
229
  return await ctx.db
160
230
  .query("buckets")
@@ -182,10 +252,21 @@ async function getSettings(ctx) {
182
252
  .withIndex("key", (q) => q.eq("key", "singleton"))
183
253
  .unique();
184
254
  }
255
+ // Delete the reverse-index rows for a request (called when the request row is
256
+ // deleted, so the tag index never outlives the request it points at).
257
+ async function deleteRequestTags(ctx, requestId) {
258
+ const tags = await ctx.db
259
+ .query("requestTags")
260
+ .withIndex("requestId", (q) => q.eq("requestId", requestId))
261
+ .collect();
262
+ for (const t of tags)
263
+ await ctx.db.delete(t._id);
264
+ }
185
265
  const vStartResult = v.union(v.object({
186
266
  allowed: v.literal(true),
187
267
  requestId: v.id("requests"),
188
- warnings: v.array(v.string()),
268
+ warnings: v.array(v.string()), // soft caps exceeded (allowed with a warning)
269
+ notices: v.array(v.string()), // approaching a cap (warnAtPct threshold)
189
270
  }), v.object({
190
271
  allowed: v.literal(false),
191
272
  code: v.string(),
@@ -226,11 +307,14 @@ export const startRequest = mutation({
226
307
  return { allowed: false, code, reason };
227
308
  };
228
309
  const today = dayStamp();
310
+ const month = monthStamp();
229
311
  const priceInfo = await getPrice(ctx, args.model);
230
312
  const est = estimateUsage(args.messages, priceInfo);
231
313
  const warnings = [];
314
+ const notices = [];
232
315
  // Model allow/deny policy (component-wide).
233
316
  const settings = await getSettings(ctx);
317
+ const defaultWarnAtPct = settings?.defaultWarnAtPct;
234
318
  if (settings) {
235
319
  const mode = settings.modelMode ?? "open";
236
320
  const list = settings.models ?? [];
@@ -257,6 +341,14 @@ export const startRequest = mutation({
257
341
  return reject(`${b.dimension}_blocked`, `${label} "${b.value}" is blocked`, b.dimension !== USER_DIM);
258
342
  }
259
343
  }
344
+ // Concurrency cap: reject when a bucket already has maxConcurrent requests
345
+ // in flight (pendingCount). A transient limit like rate-limiting, so it
346
+ // isn't persisted (the caller retries once something settles).
347
+ for (const b of buckets) {
348
+ if (b.maxConcurrent !== undefined && (b.pendingCount ?? 0) >= b.maxConcurrent) {
349
+ return reject(`${b.dimension}_max_concurrent`, `Too many concurrent requests for ${b.dimension} "${b.value}" (max ${b.maxConcurrent})`, false);
350
+ }
351
+ }
260
352
  // Rate limit is enforced on the `user` dimension (the requests table is
261
353
  // indexed by userId, so the 60s window is a bounded index read).
262
354
  const userBucket = buckets.find((b) => b.dimension === USER_DIM);
@@ -282,28 +374,37 @@ export const startRequest = mutation({
282
374
  // warning instead of a block.
283
375
  for (const b of buckets) {
284
376
  const sameDay = b.dayStamp === today;
377
+ const sameMonth = b.monthStamp === month;
285
378
  const ev = evaluateCaps({
286
379
  label: b.dimension,
287
380
  name: b.value,
288
381
  enforcement: b.enforcement ?? "hard",
382
+ warnAtPct: b.warnAtPct ?? defaultWarnAtPct,
289
383
  estCost: est.cost,
290
384
  estTokens: est.tokens,
291
385
  spendToday: sameDay ? b.spendTodayNanos : 0,
292
386
  reservedSpendToday: sameDay ? b.reservedTodayNanos ?? 0 : 0,
387
+ spendThisMonth: sameMonth ? b.spendThisMonthNanos ?? 0 : 0,
388
+ reservedSpendMonth: sameMonth ? b.reservedMonthNanos ?? 0 : 0,
293
389
  totalSpend: b.totalSpendNanos,
294
390
  reservedSpendTotal: b.reservedTotalNanos ?? 0,
295
391
  tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
296
392
  reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
393
+ tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
394
+ reservedTokensMonth: sameMonth ? b.reservedMonthTokens ?? 0 : 0,
297
395
  totalTokens: b.totalTokens,
298
396
  reservedTokensTotal: b.reservedTotalTokens ?? 0,
299
397
  dailySpendLimitNanos: withBump(b.dailySpendLimitNanos, b.bumpDayStamp === today ? b.dailyBumpNanos : 0),
398
+ monthlySpendLimitNanos: withBump(b.monthlySpendLimitNanos, b.bumpMonthStamp === month ? b.monthlyBumpNanos : 0),
300
399
  lifetimeSpendLimitNanos: withBump(b.lifetimeSpendLimitNanos, b.lifetimeBumpNanos),
301
400
  dailyTokenLimit: b.dailyTokenLimit,
401
+ monthlyTokenLimit: b.monthlyTokenLimit,
302
402
  lifetimeTokenLimit: b.lifetimeTokenLimit,
303
403
  });
304
404
  if (ev.hard)
305
405
  return reject(ev.hard.code, ev.hard.reason);
306
406
  warnings.push(...ev.warnings);
407
+ notices.push(...ev.notices);
307
408
  }
308
409
  // Deployment-wide ("global") spend cap — same reserve-then-settle model and
309
410
  // the SAME evaluateCaps logic as the per-bucket caps above, so the guarantee
@@ -322,14 +423,19 @@ export const startRequest = mutation({
322
423
  label: "global",
323
424
  name: "deployment",
324
425
  enforcement: settings.globalEnforcement ?? "hard",
426
+ warnAtPct: defaultWarnAtPct,
325
427
  estCost: est.cost,
326
428
  estTokens: est.tokens,
327
429
  spendToday: await globalSpend.count(ctx, globalDayKey(today)),
328
430
  reservedSpendToday: 0, // sharded holder: no cross-request reservation
431
+ spendThisMonth: 0, // global tracks daily + lifetime only
432
+ reservedSpendMonth: 0,
329
433
  totalSpend: await globalSpend.count(ctx, GLOBAL_TOTAL),
330
434
  reservedSpendTotal: 0,
331
435
  tokensToday: 0,
332
436
  reservedTokensToday: 0,
437
+ tokensThisMonth: 0,
438
+ reservedTokensMonth: 0,
333
439
  totalTokens: 0,
334
440
  reservedTokensTotal: 0,
335
441
  dailySpendLimitNanos: withBump(settings.globalDailySpendLimitNanos, settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0),
@@ -338,6 +444,7 @@ export const startRequest = mutation({
338
444
  if (globalEval.hard)
339
445
  return reject(globalEval.hard.code, globalEval.hard.reason);
340
446
  warnings.push(...globalEval.warnings);
447
+ notices.push(...globalEval.notices);
341
448
  }
342
449
  // Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
343
450
  // uncapped bucket's row here would serialize every request that shares it
@@ -345,16 +452,22 @@ export const startRequest = mutation({
345
452
  // there's no reserved amount to consult. Totals are still accrued later,
346
453
  // asynchronously, in foldOne — for every bucket, capped or not.
347
454
  for (const b of buckets) {
348
- if (!hasAnyCap(b))
455
+ if (!needsReserve(b))
349
456
  continue;
350
457
  const sameDay = b.dayStamp === today;
458
+ const sameMonth = b.monthStamp === month;
351
459
  await ctx.db.patch(b._id, {
352
460
  dayStamp: today,
461
+ monthStamp: month,
353
462
  spendTodayNanos: sameDay ? b.spendTodayNanos : 0,
354
463
  tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
464
+ spendThisMonthNanos: sameMonth ? b.spendThisMonthNanos ?? 0 : 0,
465
+ tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
355
466
  reservedTodayNanos: (sameDay ? b.reservedTodayNanos ?? 0 : 0) + est.cost,
467
+ reservedMonthNanos: (sameMonth ? b.reservedMonthNanos ?? 0 : 0) + est.cost,
356
468
  reservedTotalNanos: (b.reservedTotalNanos ?? 0) + est.cost,
357
469
  reservedTodayTokens: (sameDay ? b.reservedTodayTokens ?? 0 : 0) + est.tokens,
470
+ reservedMonthTokens: (sameMonth ? b.reservedMonthTokens ?? 0 : 0) + est.tokens,
358
471
  reservedTotalTokens: (b.reservedTotalTokens ?? 0) + est.tokens,
359
472
  pendingCount: (b.pendingCount ?? 0) + 1,
360
473
  });
@@ -372,7 +485,16 @@ export const startRequest = mutation({
372
485
  ...(priceInfo.known ? {} : { unpricedModel: true }),
373
486
  ...(warnings.length > 0 ? { overBudget: true } : {}),
374
487
  });
375
- return { allowed: true, requestId, warnings };
488
+ // Reverse index so the request log can be filtered by any extra tag
489
+ // dimension (user/action are already indexed on the requests table).
490
+ for (const t of extraTags) {
491
+ await ctx.db.insert("requestTags", {
492
+ dimension: t.dimension,
493
+ value: t.value,
494
+ requestId,
495
+ });
496
+ }
497
+ return { allowed: true, requestId, warnings, notices };
376
498
  },
377
499
  });
378
500
  export const finishRequest = mutation({
@@ -383,6 +505,10 @@ export const finishRequest = mutation({
383
505
  promptTokens: v.optional(v.number()),
384
506
  completionTokens: v.optional(v.number()),
385
507
  cachedTokens: v.optional(v.number()),
508
+ // Authoritative cost from the gateway, if it ever reports one. When present
509
+ // it's recorded verbatim (no token-based estimate); when absent we price
510
+ // from tokens (cache-aware). Wired now so adopting a real cost is one line.
511
+ costNanos: v.optional(v.number()),
386
512
  latencyMs: v.optional(v.number()),
387
513
  },
388
514
  returns: v.object({ costNanos: v.number() }),
@@ -405,7 +531,11 @@ export const finishRequest = mutation({
405
531
  const promptTokens = Math.max(0, args.promptTokens ?? 0);
406
532
  const completionTokens = Math.max(0, args.completionTokens ?? 0);
407
533
  const cachedTokens = Math.min(promptTokens, Math.max(0, args.cachedTokens ?? 0));
408
- const costNanos = Math.max(0, costOf(promptTokens, completionTokens, await getPrice(ctx, request.model)));
534
+ // Prefer an authoritative gateway cost when supplied; otherwise price from
535
+ // tokens, discounting the cached (prompt-cache-read) slice of the prompt.
536
+ const costNanos = args.costNanos !== undefined && args.costNanos >= 0
537
+ ? Math.round(args.costNanos)
538
+ : settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model));
409
539
  // Durable write to the request's OWN row only — uncontended, so it always
410
540
  // lands. `settled: false` hands it to the fold step; the row is never left
411
541
  // orphaned in "pending" even if the totals update below fails and retries.
@@ -439,25 +569,36 @@ async function foldOne(ctx, req) {
439
569
  const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
440
570
  const estTokens = req.estimatedTokens ?? 0;
441
571
  const today = dayStamp();
572
+ const month = monthStamp();
442
573
  // Accrue into every attributed bucket (user, action, and each tag) — capped
443
574
  // or not. Buckets that never held a reservation have their reserved fields
444
575
  // clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
576
+ // Also write the durable per-(bucket, day/month) usage rows that survive
577
+ // request retention, so spend history outlives the raw request log.
445
578
  for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
446
579
  const b = await getOrCreateBucket(ctx, t.dimension, t.value);
447
580
  const sameDay = b.dayStamp === today;
581
+ const sameMonth = b.monthStamp === month;
448
582
  await ctx.db.patch(b._id, {
449
583
  totalSpendNanos: b.totalSpendNanos + actual,
450
584
  totalRequests: b.totalRequests + 1,
451
585
  totalTokens: b.totalTokens + tokens,
452
586
  dayStamp: today,
587
+ monthStamp: month,
453
588
  spendTodayNanos: (sameDay ? b.spendTodayNanos : 0) + actual,
454
589
  tokensToday: (sameDay ? b.tokensToday ?? 0 : 0) + tokens,
590
+ spendThisMonthNanos: (sameMonth ? b.spendThisMonthNanos ?? 0 : 0) + actual,
591
+ tokensThisMonth: (sameMonth ? b.tokensThisMonth ?? 0 : 0) + tokens,
455
592
  reservedTodayNanos: Math.max(0, (sameDay ? b.reservedTodayNanos ?? 0 : 0) - estCost),
593
+ reservedMonthNanos: Math.max(0, (sameMonth ? b.reservedMonthNanos ?? 0 : 0) - estCost),
456
594
  reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
457
595
  reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
596
+ reservedMonthTokens: Math.max(0, (sameMonth ? b.reservedMonthTokens ?? 0 : 0) - estTokens),
458
597
  reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
459
598
  pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
460
599
  });
600
+ await addUsage(ctx, t.dimension, t.value, "day", today, actual, tokens, 1);
601
+ await addUsage(ctx, t.dimension, t.value, "month", month, actual, tokens, 1);
461
602
  }
462
603
  // Deployment-wide totals via the sharded counter (only when a global cap is
463
604
  // configured — otherwise skip the writes entirely). Distributed across shards,
@@ -526,6 +667,7 @@ export const reconcile = internalMutation({
526
667
  // Only rows that are done and accounted: folded (settled === true) or a
527
668
  // blocked attempt (never needs folding). Never a pending/unfolded row.
528
669
  if (req.settled === true || req.status === "blocked") {
670
+ await deleteRequestTags(ctx, req._id);
529
671
  await ctx.db.delete(req._id);
530
672
  purged++;
531
673
  }
@@ -571,10 +713,19 @@ export const getRequest = query({
571
713
  handler: async (ctx, args) => ctx.db.get(args.requestId),
572
714
  });
573
715
  export const listRequests = query({
574
- args: { userId: v.optional(v.string()), limit: v.optional(v.number()) },
716
+ args: {
717
+ userId: v.optional(v.string()),
718
+ // Filter by any attribution dimension (user/action indexed on the request;
719
+ // custom tag dimensions resolved via the requestTags reverse index).
720
+ dimension: v.optional(v.string()),
721
+ value: v.optional(v.string()),
722
+ limit: v.optional(v.number()),
723
+ },
575
724
  handler: async (ctx, args) => {
576
725
  const limit = args.limit ?? 50;
577
- if (args.userId !== undefined) {
726
+ const dim = args.dimension;
727
+ const val = args.value ?? args.userId;
728
+ if (dim === undefined && args.userId !== undefined) {
578
729
  const userId = args.userId;
579
730
  return await ctx.db
580
731
  .query("requests")
@@ -582,6 +733,30 @@ export const listRequests = query({
582
733
  .order("desc")
583
734
  .take(limit);
584
735
  }
736
+ if (dim !== undefined && val !== undefined) {
737
+ if (dim === USER_DIM) {
738
+ return await ctx.db
739
+ .query("requests")
740
+ .withIndex("userId", (q) => q.eq("userId", val))
741
+ .order("desc")
742
+ .take(limit);
743
+ }
744
+ if (dim === ACTION_DIM) {
745
+ return await ctx.db
746
+ .query("requests")
747
+ .withIndex("actionName", (q) => q.eq("actionName", val))
748
+ .order("desc")
749
+ .take(limit);
750
+ }
751
+ // Custom tag dimension: walk the reverse index, then fetch each request.
752
+ const tagRows = await ctx.db
753
+ .query("requestTags")
754
+ .withIndex("dim_value", (q) => q.eq("dimension", dim).eq("value", val))
755
+ .order("desc")
756
+ .take(limit);
757
+ const rows = await Promise.all(tagRows.map((t) => ctx.db.get(t.requestId)));
758
+ return rows.filter((r) => r !== null);
759
+ }
585
760
  return await ctx.db.query("requests").order("desc").take(limit);
586
761
  },
587
762
  });
@@ -600,9 +775,11 @@ export const listBuckets = query({
600
775
  .take(ADMIN_LIST_CAP)
601
776
  : await ctx.db.query("buckets").take(ADMIN_LIST_CAP);
602
777
  const today = dayStamp();
778
+ const month = monthStamp();
603
779
  return rows.map((b) => ({
604
780
  ...b,
605
781
  spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
782
+ spendThisMonthNanos: b.monthStamp === month ? b.spendThisMonthNanos ?? 0 : 0,
606
783
  }));
607
784
  },
608
785
  });
@@ -613,7 +790,12 @@ export const getBucket = query({
613
790
  if (!b)
614
791
  return null;
615
792
  const today = dayStamp();
616
- return { ...b, spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0 };
793
+ const month = monthStamp();
794
+ return {
795
+ ...b,
796
+ spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
797
+ spendThisMonthNanos: b.monthStamp === month ? b.spendThisMonthNanos ?? 0 : 0,
798
+ };
617
799
  },
618
800
  });
619
801
  // Set a bucket's limits/controls. `user` and `action` are just dimensions here;
@@ -623,10 +805,14 @@ export const setBucketLimits = mutation({
623
805
  dimension: v.string(),
624
806
  value: v.string(),
625
807
  requestsPerMinute: v.optional(v.number()),
808
+ maxConcurrent: v.optional(v.number()),
626
809
  dailySpendLimitNanos: v.optional(v.number()),
810
+ monthlySpendLimitNanos: v.optional(v.number()),
627
811
  lifetimeSpendLimitNanos: v.optional(v.number()),
628
812
  dailyTokenLimit: v.optional(v.number()),
813
+ monthlyTokenLimit: v.optional(v.number()),
629
814
  lifetimeTokenLimit: v.optional(v.number()),
815
+ warnAtPct: v.optional(v.number()),
630
816
  enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
631
817
  blocked: v.optional(v.boolean()),
632
818
  },
@@ -640,25 +826,104 @@ export const setBucketLimits = mutation({
640
826
  });
641
827
  const vBumpArgs = {
642
828
  dailyNanos: v.optional(v.number()),
829
+ monthlyNanos: v.optional(v.number()),
643
830
  lifetimeNanos: v.optional(v.number()),
644
831
  };
645
832
  // 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.
833
+ // without changing it. Daily/monthly bumps apply to the current window only;
834
+ // lifetime bumps persist.
647
835
  export const bumpBucket = mutation({
648
836
  args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
649
837
  returns: v.null(),
650
- handler: async (ctx, { dimension, value, dailyNanos, lifetimeNanos }) => {
838
+ handler: async (ctx, { dimension, value, dailyNanos, monthlyNanos, lifetimeNanos }) => {
651
839
  const bucket = await getOrCreateBucket(ctx, dimension, value);
652
840
  const today = dayStamp();
841
+ const month = monthStamp();
653
842
  const curDaily = bucket.bumpDayStamp === today ? bucket.dailyBumpNanos ?? 0 : 0;
843
+ const curMonthly = bucket.bumpMonthStamp === month ? bucket.monthlyBumpNanos ?? 0 : 0;
654
844
  await ctx.db.patch(bucket._id, {
655
845
  bumpDayStamp: today,
656
846
  dailyBumpNanos: curDaily + (dailyNanos ?? 0),
847
+ bumpMonthStamp: month,
848
+ monthlyBumpNanos: curMonthly + (monthlyNanos ?? 0),
657
849
  lifetimeBumpNanos: (bucket.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
658
850
  });
659
851
  return null;
660
852
  },
661
853
  });
854
+ // Manually credit or debit a bucket (comp a user, correct an overcharge).
855
+ // Negative deltaNanos = credit/refund, positive = extra charge. Adjusts the
856
+ // live day/month/lifetime windows AND the durable usage history, and records an
857
+ // audit row. Does not touch the global sharded total or reservations.
858
+ export const adjustBucket = mutation({
859
+ args: {
860
+ dimension: v.string(),
861
+ value: v.string(),
862
+ deltaNanos: v.number(),
863
+ tokens: v.optional(v.number()),
864
+ reason: v.optional(v.string()),
865
+ },
866
+ returns: v.null(),
867
+ handler: async (ctx, { dimension, value, deltaNanos, tokens, reason }) => {
868
+ const b = await getOrCreateBucket(ctx, dimension, value);
869
+ const today = dayStamp();
870
+ const month = monthStamp();
871
+ const dSame = b.dayStamp === today;
872
+ const mSame = b.monthStamp === month;
873
+ const dt = tokens ?? 0;
874
+ await ctx.db.patch(b._id, {
875
+ totalSpendNanos: Math.max(0, b.totalSpendNanos + deltaNanos),
876
+ totalTokens: Math.max(0, b.totalTokens + dt),
877
+ dayStamp: today,
878
+ monthStamp: month,
879
+ spendTodayNanos: Math.max(0, (dSame ? b.spendTodayNanos : 0) + deltaNanos),
880
+ tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
881
+ spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
882
+ tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
883
+ });
884
+ await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
885
+ await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
886
+ await addUsage(ctx, dimension, value, "month", month, deltaNanos, dt, 0);
887
+ return null;
888
+ },
889
+ });
890
+ export const listAdjustments = query({
891
+ args: { dimension: v.string(), value: v.string(), limit: v.optional(v.number()) },
892
+ handler: async (ctx, { dimension, value, limit }) => ctx.db
893
+ .query("adjustments")
894
+ .withIndex("dim_value", (q) => q.eq("dimension", dimension).eq("value", value))
895
+ .order("desc")
896
+ .take(limit ?? 50),
897
+ });
898
+ // Durable spend history for a bucket: per-day or per-month rows, newest first.
899
+ // Survives request retention.
900
+ export const usageHistory = query({
901
+ args: {
902
+ dimension: v.string(),
903
+ value: v.string(),
904
+ period: v.union(v.literal("day"), v.literal("month")),
905
+ limit: v.optional(v.number()),
906
+ },
907
+ handler: async (ctx, { dimension, value, period, limit }) => ctx.db
908
+ .query("usage")
909
+ .withIndex("bucket_period_stamp", (q) => q.eq("dimension", dimension).eq("value", value).eq("period", period))
910
+ .order("desc")
911
+ .take(limit ?? 90),
912
+ });
913
+ // Deployment-wide default threshold for approaching-limit alerts (fraction of a
914
+ // cap, e.g. 0.8). Buckets can override with their own warnAtPct.
915
+ export const setAlertDefaults = mutation({
916
+ args: { warnAtPct: v.optional(v.number()) },
917
+ returns: v.null(),
918
+ handler: async (ctx, { warnAtPct }) => {
919
+ const existing = await getSettings(ctx);
920
+ if (existing)
921
+ await ctx.db.patch(existing._id, { defaultWarnAtPct: warnAtPct });
922
+ else
923
+ await ctx.db.insert("settings", { key: "singleton", defaultWarnAtPct: warnAtPct });
924
+ return null;
925
+ },
926
+ });
662
927
  // Delete a bucket and (for the `user` dimension) all of that user's request
663
928
  // rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
664
929
  // self-reschedules so it never exceeds the per-transaction document limit.
@@ -674,8 +939,10 @@ export const deleteBucket = mutation({
674
939
  .query("requests")
675
940
  .withIndex("userId", (q) => q.eq("userId", value))
676
941
  .take(DELETE_BATCH);
677
- for (const r of rows)
942
+ for (const r of rows) {
943
+ await deleteRequestTags(ctx, r._id);
678
944
  await ctx.db.delete(r._id);
945
+ }
679
946
  if (rows.length === DELETE_BATCH) {
680
947
  await ctx.scheduler.runAfter(0, api.lib.deleteBucket, {
681
948
  dimension,
@@ -716,6 +983,9 @@ export const getGlobalStatus = query({
716
983
  enforcement: v.union(v.literal("hard"), v.literal("soft")),
717
984
  spentTodayNanos: v.number(),
718
985
  spentTotalNanos: v.number(),
986
+ // deployment-wide config (surfaced for the admin dashboard)
987
+ retentionMs: v.union(v.number(), v.null()),
988
+ defaultWarnAtPct: v.union(v.number(), v.null()),
719
989
  }),
720
990
  handler: async (ctx) => {
721
991
  const s = await ctx.db
@@ -728,6 +998,8 @@ export const getGlobalStatus = query({
728
998
  enforcement: s?.globalEnforcement ?? "hard",
729
999
  spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
730
1000
  spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
1001
+ retentionMs: s?.retentionMs ?? null,
1002
+ defaultWarnAtPct: s?.defaultWarnAtPct ?? null,
731
1003
  };
732
1004
  },
733
1005
  });
@@ -803,12 +1075,16 @@ export const setPrice = mutation({
803
1075
  model: v.string(),
804
1076
  inputNanosPerMTok: v.number(),
805
1077
  outputNanosPerMTok: v.number(),
1078
+ // optional cache-read rate; if omitted, a default discount off input applies
1079
+ cachedNanosPerMTok: v.optional(v.number()),
806
1080
  },
807
1081
  returns: v.null(),
808
1082
  handler: async (ctx, args) => {
809
1083
  // Negative prices would make costOf return a negative cost, which folds
810
1084
  // into totals as a spend *refund* — pushing a user back under their cap.
811
- if (args.inputNanosPerMTok < 0 || args.outputNanosPerMTok < 0) {
1085
+ if (args.inputNanosPerMTok < 0 ||
1086
+ args.outputNanosPerMTok < 0 ||
1087
+ (args.cachedNanosPerMTok ?? 0) < 0) {
812
1088
  throw new Error("Prices must be non-negative");
813
1089
  }
814
1090
  const existing = await ctx.db
@@ -836,6 +1112,7 @@ export const listPrices = query({
836
1112
  merged[o.model] = {
837
1113
  input: o.inputNanosPerMTok,
838
1114
  output: o.outputNanosPerMTok,
1115
+ cached: o.cachedNanosPerMTok,
839
1116
  overridden: true,
840
1117
  };
841
1118
  }