@convex-dev/ai-budget 0.0.2-alpha.13 → 0.0.2-alpha.15

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.
@@ -2,6 +2,7 @@ import { v } from "convex/values";
2
2
  import { mutation, internalMutation, query, } from "./_generated/server";
3
3
  import { api, internal, components } from "./_generated/api";
4
4
  import { vMessage, vTag } from "./schema";
5
+ import { RateLimiter, MINUTE } from "@convex-dev/rate-limiter";
5
6
  import { ShardedCounter } from "@convex-dev/sharded-counter";
6
7
  // All money is integer **nanodollars** (1 USD = 1e9 nano). Integers avoid the
7
8
  // rounding drift that floating-point cents accumulate over millions of
@@ -17,6 +18,16 @@ const fmtUsd = (nanos) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
17
18
  // tags carrying them are ignored in favor of the first-class fields.
18
19
  const USER_DIM = "user";
19
20
  const ACTION_DIM = "action";
21
+ const requestRateLimiter = new RateLimiter(components.rateLimiter);
22
+ const requestRateOptions = (bucket) => ({
23
+ key: bucket._id,
24
+ config: {
25
+ kind: "token bucket",
26
+ rate: bucket.requestsPerMinute,
27
+ capacity: bucket.requestsPerMinute,
28
+ period: MINUTE,
29
+ },
30
+ });
20
31
  // Deployment-wide spend totals (nanodollars), sharded for high write throughput.
21
32
  // Keyed "total" (lifetime) and "day:<UTC date>" (natural daily reset).
22
33
  const globalSpend = new ShardedCounter(components.shardedCounter);
@@ -44,12 +55,8 @@ const DEFAULT_SERVER_TOOL_PRICES = {
44
55
  // concurrent admission atomic against the estimate; a response that exceeds the
45
56
  // estimate can still settle above the cap by the estimation delta.
46
57
  const ESTIMATED_OUTPUT_TOKENS = 800;
47
- // A request still "pending" after this long is presumed dead (its action
48
- // crashed before settling); the reconciler releases its reservation. Set well
49
- // above any real call duration — a long reasoning/agent generation that is
50
- // still billing must not be swept and mis-recorded as free. A late
51
- // finishRequest is a no-op once swept (see finishRequest's terminal guard), so
52
- // the only cost of a generous timeout is a briefly-held reservation.
58
+ // Expiry releases a hold without declaring final usage. A late provider
59
+ // completion can still record its authoritative charge exactly once.
53
60
  const STALE_PENDING_MS = 30 * 60 * 1000;
54
61
  // Default retention for request rows (full prompts + responses). Terminal,
55
62
  // fully-accounted rows older than this are swept by the reconciler. Keeps the
@@ -268,6 +275,35 @@ async function getOrCreateBucket(ctx, dimension, value) {
268
275
  });
269
276
  return (await ctx.db.get(id));
270
277
  }
278
+ // Policy is read on every admission; reporting writes never touch it.
279
+ async function syncPolicy(ctx, b) {
280
+ const existing = await ctx.db.query("bucketPolicies").withIndex("dim_value", q => q.eq("dimension", b.dimension).eq("value", b.value)).unique();
281
+ const policy = {
282
+ bucketId: b._id, dimension: b.dimension, value: b.value,
283
+ requestsPerMinute: b.requestsPerMinute, maxConcurrent: b.maxConcurrent,
284
+ dailySpendLimitNanos: b.dailySpendLimitNanos, monthlySpendLimitNanos: b.monthlySpendLimitNanos,
285
+ lifetimeSpendLimitNanos: b.lifetimeSpendLimitNanos, dailyTokenLimit: b.dailyTokenLimit,
286
+ monthlyTokenLimit: b.monthlyTokenLimit, lifetimeTokenLimit: b.lifetimeTokenLimit,
287
+ blocked: b.blocked, warnAtPct: b.warnAtPct, enforcement: b.enforcement,
288
+ };
289
+ if (existing)
290
+ await ctx.db.replace(existing._id, policy);
291
+ else
292
+ await ctx.db.insert("bucketPolicies", policy);
293
+ }
294
+ async function admissionBucket(ctx, dimension, value) {
295
+ const policy = await ctx.db.query("bucketPolicies").withIndex("dim_value", q => q.eq("dimension", dimension).eq("value", value)).unique();
296
+ if (!policy) {
297
+ const bucket = await getOrCreateBucket(ctx, dimension, value);
298
+ await syncPolicy(ctx, bucket);
299
+ return bucket;
300
+ }
301
+ // Only capped buckets need an atomic read of their accounting state.
302
+ if (needsReserve(policy))
303
+ return (await ctx.db.get(policy.bucketId));
304
+ return { ...policy, _id: policy.bucketId, totalSpendNanos: 0, totalRequests: 0,
305
+ totalTokens: 0, dayStamp: "", spendTodayNanos: 0 };
306
+ }
271
307
  async function getSettings(ctx) {
272
308
  return await ctx.db
273
309
  .query("settings")
@@ -316,15 +352,18 @@ export const startRequest = mutation({
316
352
  },
317
353
  returns: vStartResult,
318
354
  handler: async (ctx, args) => {
355
+ if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
356
+ throw new Error("reserveTtlMs must be finite and nonnegative");
357
+ }
319
358
  const extraTags = sanitizeExtraTags(args.tags);
320
359
  // Record the blocked attempt and return a rejection (throwing would roll
321
360
  // back the record). `persist` is false for the high-frequency-by-design
322
361
  // rejections (rate limit, blocked user) that a client retries in a tight
323
362
  // loop — persisting those would grow the requests table without bound and
324
- // bloat the 60s rate-limit window read below.
363
+ // add audit-log writes to every transient rejection.
325
364
  const reject = async (code, reason, persist = true) => {
326
365
  if (persist) {
327
- await ctx.db.insert("requests", {
366
+ const requestId = await ctx.db.insert("requests", {
328
367
  userId: args.userId,
329
368
  actionName: args.actionName,
330
369
  ...(extraTags.length ? { tags: extraTags } : {}),
@@ -334,6 +373,15 @@ export const startRequest = mutation({
334
373
  status: "blocked",
335
374
  error: reason,
336
375
  });
376
+ // Reverse-index the blocked attempt too, so tag-filtered request logs
377
+ // show rejections alongside admitted traffic.
378
+ for (const t of extraTags) {
379
+ await ctx.db.insert("requestTags", {
380
+ dimension: t.dimension,
381
+ value: t.value,
382
+ requestId,
383
+ });
384
+ }
337
385
  }
338
386
  return { allowed: false, code, reason };
339
387
  };
@@ -366,7 +414,7 @@ export const startRequest = mutation({
366
414
  const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
367
415
  const buckets = [];
368
416
  for (const t of bucketTags) {
369
- buckets.push(await getOrCreateBucket(ctx, t.dimension, t.value));
417
+ buckets.push(await admissionBucket(ctx, t.dimension, t.value));
370
418
  }
371
419
  // A hard block on ANY bucket rejects the request. The user dimension's block
372
420
  // isn't persisted (retried in a loop); config-level blocks on other
@@ -385,40 +433,15 @@ export const startRequest = mutation({
385
433
  return reject(`${b.dimension}_max_concurrent`, `Too many concurrent requests for ${b.dimension} "${b.value}" (max ${b.maxConcurrent})`, false);
386
434
  }
387
435
  }
388
- // Enforce the rolling 60-second rate limit on every configured dimension.
389
- // User/action requests use their first-class indexes; custom dimensions use
390
- // the requestTags reverse index. All reads are bounded by the configured
391
- // limit (plus a small allowance for persisted blocked attempts).
392
- const rateCutoff = Date.now() - 60_000;
436
+ // Check all rates before consuming any. These transactional reads remain
437
+ // in admission's read set, so concurrent requests cannot spend the same
438
+ // capacity. Budget rejections below leave every rate balance untouched.
393
439
  for (const b of buckets) {
394
440
  const limit = b.requestsPerMinute;
395
441
  if (limit === undefined)
396
442
  continue;
397
- let recentCount;
398
- if (b.dimension === USER_DIM) {
399
- const recent = await ctx.db
400
- .query("requests")
401
- .withIndex("userId", (q) => q.eq("userId", b.value).gt("_creationTime", rateCutoff))
402
- .take(limit + 50);
403
- recentCount = recent.filter((r) => r.status !== "blocked").length;
404
- }
405
- else if (b.dimension === ACTION_DIM) {
406
- const recent = await ctx.db
407
- .query("requests")
408
- .withIndex("actionName", (q) => q.eq("actionName", b.value).gt("_creationTime", rateCutoff))
409
- .take(limit + 50);
410
- recentCount = recent.filter((r) => r.status !== "blocked").length;
411
- }
412
- else {
413
- recentCount = (await ctx.db
414
- .query("requestTags")
415
- .withIndex("dim_value", (q) => q
416
- .eq("dimension", b.dimension)
417
- .eq("value", b.value)
418
- .gt("_creationTime", rateCutoff))
419
- .take(limit)).length;
420
- }
421
- if (recentCount >= limit) {
443
+ const ok = limit > 0 && (await requestRateLimiter.check(ctx, "requests", requestRateOptions(b))).ok;
444
+ if (!ok) {
422
445
  const code = b.dimension === USER_DIM ? "rate_limit" : `${b.dimension}_rate_limit`;
423
446
  return reject(code, `Rate limit exceeded for ${b.dimension} "${b.value}" (${limit}/min)`, false);
424
447
  }
@@ -468,9 +491,8 @@ export const startRequest = mutation({
468
491
  // committed + reserved + estimate <= cap. The ONE difference is the holder:
469
492
  // per-bucket caps reserve on a single row (an atomic check-and-reserve),
470
493
  // while the global holder is a sharded counter for
471
- // throughput its committed total is read as an eventually-consistent sum
472
- // with no cross-request reservation, so a hard global cap can overshoot by a
473
- // bounded amount under burst. That's the deliberate consistency/throughput
494
+ // throughput. The sum is transactional, but excludes unsettled usage and
495
+ // has no cross-request reservation, so a hard global cap can overshoot. That's the deliberate consistency/throughput
474
496
  // trade for a deployment-wide killswitch; it's the only approximate scope.
475
497
  if (settings &&
476
498
  (settings.globalDailySpendLimitNanos !== undefined ||
@@ -502,6 +524,17 @@ export const startRequest = mutation({
502
524
  warnings.push(...globalEval.warnings);
503
525
  notices.push(...globalEval.notices);
504
526
  }
527
+ // Consume only after every admission check succeeds, in the same
528
+ // transaction as the reservations and request insert. An unexpected
529
+ // rejection throws to roll back all previously consumed dimensions.
530
+ for (const b of buckets) {
531
+ if (b.requestsPerMinute !== undefined) {
532
+ await requestRateLimiter.limit(ctx, "requests", {
533
+ ...requestRateOptions(b),
534
+ throws: true,
535
+ });
536
+ }
537
+ }
505
538
  // Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
506
539
  // uncapped bucket's row here would serialize every request that shares it
507
540
  // (e.g. all callers of one action, or every request in one env); with no cap
@@ -537,6 +570,10 @@ export const startRequest = mutation({
537
570
  rerunOf: args.rerunOf,
538
571
  ...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
539
572
  status: "pending",
573
+ expiresAt: Date.now() + Math.max(STALE_PENDING_MS, args.reserveTtlMs ?? 0),
574
+ heldBucketIds: buckets.filter(needsReserve).map(b => b._id),
575
+ reservationDay: today,
576
+ reservationMonth: month,
540
577
  estimatedNanos: est.cost,
541
578
  estimatedTokens: est.tokens,
542
579
  ...(priceInfo.known ? {} : { unpricedModel: true }),
@@ -577,14 +614,9 @@ export const finishRequest = mutation({
577
614
  const request = await ctx.db.get(args.requestId);
578
615
  if (!request)
579
616
  throw new Error("Unknown request");
580
- // Exactly-once settlement. A request that already reached a terminal state
581
- // finished normally, or expired by the reconciler's stale sweep — must
582
- // not be settled again. Without this, a merely-slow request that the sweep
583
- // already folded would be re-opened and folded a SECOND time when it
584
- // finally completes: totals double-count and the reservation is released
585
- // twice, dropping the reserved pool below reality and letting the atomic
586
- // check-and-reserve admit requests it should block.
587
- if (request.status !== "pending") {
617
+ // Expiry releases capacity, but is not evidence that the provider charged
618
+ // nothing. Accept one final result even after expiry; duplicates stay no-ops.
619
+ if (request.status !== "pending" && !request.reservationExpired) {
588
620
  return { costNanos: request.costNanos ?? 0 };
589
621
  }
590
622
  // Clamp caller-supplied token counts: negatives would produce negative cost
@@ -609,6 +641,9 @@ export const finishRequest = mutation({
609
641
  // orphaned in "pending" even if the totals update below fails and retries.
610
642
  await ctx.db.patch(args.requestId, {
611
643
  status: args.error ? "error" : "success",
644
+ reservationExpired: false,
645
+ expiresAt: undefined,
646
+ finishedAt: Date.now(),
612
647
  responseText: args.responseText,
613
648
  error: args.error,
614
649
  promptTokens,
@@ -627,59 +662,68 @@ export const finishRequest = mutation({
627
662
  return { costNanos };
628
663
  },
629
664
  });
630
- // Fold one finished request into every attributed bucket's running totals,
631
- // releasing its reservation. Idempotent: guarded by `settled` so the scheduler
632
- // and the cron reconciler can never double-count.
665
+ // Release only holds owned by this request, and only from their original
666
+ // calendar windows. A previous day's completion must not debit today's holds.
667
+ async function releaseReservation(ctx, req) {
668
+ if (req.reservationReleased)
669
+ return;
670
+ const day = req.reservationDay ?? new Date(req._creationTime).toISOString().slice(0, 10);
671
+ const month = req.reservationMonth ?? day.slice(0, 7);
672
+ const cost = req.estimatedNanos ?? 0;
673
+ const tokens = req.estimatedTokens ?? 0;
674
+ for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
675
+ const b = await getBucketDoc(ctx, t.dimension, t.value);
676
+ if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b)))
677
+ continue;
678
+ await ctx.db.patch(b._id, {
679
+ reservedTodayNanos: Math.max(0, (b.reservedTodayNanos ?? 0) - (b.dayStamp === day ? cost : 0)),
680
+ reservedMonthNanos: Math.max(0, (b.reservedMonthNanos ?? 0) - (b.monthStamp === month ? cost : 0)),
681
+ reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - cost),
682
+ reservedTodayTokens: Math.max(0, (b.reservedTodayTokens ?? 0) - (b.dayStamp === day ? tokens : 0)),
683
+ reservedMonthTokens: Math.max(0, (b.reservedMonthTokens ?? 0) - (b.monthStamp === month ? tokens : 0)),
684
+ reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - tokens),
685
+ pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
686
+ });
687
+ }
688
+ await ctx.db.patch(req._id, { reservationReleased: true });
689
+ }
690
+ // Final billing is folded once. Reservation release has its own guard because
691
+ // expiry can precede the final charge by hours or days.
633
692
  async function foldOne(ctx, req) {
634
693
  if (!req || req.settled !== false)
635
694
  return;
695
+ await releaseReservation(ctx, req);
636
696
  const actual = req.costNanos ?? 0;
637
- const estCost = req.estimatedNanos ?? 0;
638
697
  const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
639
- const estTokens = req.estimatedTokens ?? 0;
640
- const today = dayStamp();
641
- const month = monthStamp();
642
- // Accrue into every attributed bucket (user, action, and each tag) — capped
643
- // or not. Buckets that never held a reservation have their reserved fields
644
- // clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
645
- // Also write the durable per-(bucket, day/month) usage rows that survive
646
- // request retention, so spend history outlives the raw request log.
698
+ const timestamp = new Date(req.finishedAt ?? Date.now()).toISOString();
699
+ const day = timestamp.slice(0, 10);
700
+ const month = timestamp.slice(0, 7);
647
701
  for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
648
702
  const b = await getOrCreateBucket(ctx, t.dimension, t.value);
649
- const sameDay = b.dayStamp === today;
650
- const sameMonth = b.monthStamp === month;
703
+ // Keep the newest reporting window and clear obsolete window holds when
704
+ // advancing it. Lifetime holds persist until their owner releases them.
705
+ const targetDay = b.dayStamp > day ? b.dayStamp : day;
706
+ const targetMonth = (b.monthStamp ?? "") > month ? b.monthStamp : month;
651
707
  await ctx.db.patch(b._id, {
652
708
  totalSpendNanos: b.totalSpendNanos + actual,
653
709
  totalRequests: b.totalRequests + 1,
654
710
  totalTokens: b.totalTokens + tokens,
655
- dayStamp: today,
656
- monthStamp: month,
657
- spendTodayNanos: (sameDay ? b.spendTodayNanos : 0) + actual,
658
- tokensToday: (sameDay ? b.tokensToday ?? 0 : 0) + tokens,
659
- spendThisMonthNanos: (sameMonth ? b.spendThisMonthNanos ?? 0 : 0) + actual,
660
- tokensThisMonth: (sameMonth ? b.tokensThisMonth ?? 0 : 0) + tokens,
661
- reservedTodayNanos: Math.max(0, (sameDay ? b.reservedTodayNanos ?? 0 : 0) - estCost),
662
- reservedMonthNanos: Math.max(0, (sameMonth ? b.reservedMonthNanos ?? 0 : 0) - estCost),
663
- reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
664
- reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
665
- reservedMonthTokens: Math.max(0, (sameMonth ? b.reservedMonthTokens ?? 0 : 0) - estTokens),
666
- reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
667
- pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
711
+ dayStamp: targetDay,
712
+ monthStamp: targetMonth,
713
+ spendTodayNanos: (b.dayStamp === targetDay ? b.spendTodayNanos : 0) + (day === targetDay ? actual : 0),
714
+ tokensToday: (b.dayStamp === targetDay ? b.tokensToday ?? 0 : 0) + (day === targetDay ? tokens : 0),
715
+ spendThisMonthNanos: (b.monthStamp === targetMonth ? b.spendThisMonthNanos ?? 0 : 0) + (month === targetMonth ? actual : 0),
716
+ tokensThisMonth: (b.monthStamp === targetMonth ? b.tokensThisMonth ?? 0 : 0) + (month === targetMonth ? tokens : 0),
717
+ ...(b.dayStamp !== targetDay ? { reservedTodayNanos: 0, reservedTodayTokens: 0 } : {}),
718
+ ...(b.monthStamp !== targetMonth ? { reservedMonthNanos: 0, reservedMonthTokens: 0 } : {}),
668
719
  });
669
- await addUsage(ctx, t.dimension, t.value, "day", today, actual, tokens, 1);
720
+ await addUsage(ctx, t.dimension, t.value, "day", day, actual, tokens, 1);
670
721
  await addUsage(ctx, t.dimension, t.value, "month", month, actual, tokens, 1);
671
722
  }
672
- // Deployment-wide totals via the sharded counter (only when a global cap is
673
- // configured — otherwise skip the writes entirely). Distributed across shards,
674
- // so this does not serialize on a single row.
723
+ // Reporting is independent of whether enforcement is configured.
675
724
  if (actual > 0) {
676
- const settings = await getSettings(ctx);
677
- if (settings &&
678
- (settings.globalDailySpendLimitNanos !== undefined ||
679
- settings.globalLifetimeSpendLimitNanos !== undefined)) {
680
- await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
681
- await globalSpend.add(ctx, globalDayKey(today), actual);
682
- }
725
+ await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
726
+ await globalSpend.add(ctx, globalDayKey(day), actual);
683
727
  }
684
728
  await ctx.db.patch(req._id, { settled: true });
685
729
  }
@@ -708,46 +752,44 @@ export const reconcile = internalMutation({
708
752
  .take(200);
709
753
  for (const req of toFold)
710
754
  await foldOne(ctx, req);
711
- // Reap dead reservations: pending rows older than the default floor, but a
712
- // per-request reserveTtlMs (set for long async jobs like video) holds the
713
- // reservation until *its* deadline so a still-running job isn't reaped.
714
- const cutoff = Date.now() - STALE_PENDING_MS;
715
- const candidates = await ctx.db
716
- .query("requests")
717
- .withIndex("status", (q) => q.eq("status", "pending").lt("_creationTime", cutoff))
718
- .take(200);
719
- let expired = 0;
755
+ // Lazily migrate old pending rows in bounded batches. Once indexed, long
756
+ // TTL jobs cannot hide expired jobs behind them in creation-time order.
757
+ const legacy = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").eq("expiresAt", undefined)).take(200);
758
+ for (const req of legacy) {
759
+ await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
760
+ }
761
+ const candidates = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(200);
720
762
  for (const req of candidates) {
721
- const ttl = req.reserveTtlMs ?? STALE_PENDING_MS;
722
- if (Date.now() - req._creationTime <= ttl)
723
- continue; // still within its window
763
+ await releaseReservation(ctx, req);
724
764
  await ctx.db.patch(req._id, {
725
- status: "error",
726
- error: "Timed out before settling; reservation released",
727
- costNanos: 0,
728
- settled: false,
765
+ status: "error", error: "Reservation expired; awaiting final usage",
766
+ reservationExpired: true, expiresAt: undefined, settled: true,
729
767
  });
730
- await foldOne(ctx, await ctx.db.get(req._id));
731
- expired++;
732
768
  }
769
+ const expired = candidates.length;
733
770
  // Retention: delete terminal, fully-accounted request rows past the window.
734
771
  const settings = await getSettings(ctx);
735
772
  const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
736
773
  let purged = 0;
737
774
  if (retentionMs > 0) {
738
775
  const retentionCutoff = Date.now() - retentionMs;
739
- const old = await ctx.db
740
- .query("requests")
741
- .withIndex("by_creation_time", (q) => q.lt("_creationTime", retentionCutoff))
742
- .take(500);
776
+ // Query eligible states directly. Long-lived pending jobs and expired
777
+ // billing tombstones must not repeatedly occupy the front of a scan.
778
+ const old = [];
779
+ for (const expiredFlag of [undefined, false]) {
780
+ old.push(...await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", expiredFlag).eq("settled", true)
781
+ .lt("_creationTime", retentionCutoff)).take(200));
782
+ }
783
+ old.push(...await ctx.db.query("requests").withIndex("status", q => q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
743
784
  for (const req of old) {
744
- // Only rows that are done and accounted: folded (settled === true) or a
745
- // blocked attempt (never needs folding). Never a pending/unfolded row.
746
- if (req.settled === true || req.status === "blocked") {
747
- await deleteRequestTags(ctx, req._id);
748
- await ctx.db.delete(req._id);
749
- purged++;
750
- }
785
+ await deleteRequestTags(ctx, req._id);
786
+ await ctx.db.delete(req._id);
787
+ purged++;
788
+ }
789
+ const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q => q.eq("reservationExpired", true).eq("contentPurged", undefined)
790
+ .lt("_creationTime", retentionCutoff)).take(200);
791
+ for (const req of expiredContent) {
792
+ await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
751
793
  }
752
794
  }
753
795
  return { folded: toFold.length, expired, purged };
@@ -895,9 +937,14 @@ export const setBucketLimits = mutation({
895
937
  },
896
938
  returns: v.null(),
897
939
  handler: async (ctx, args) => {
940
+ if (args.requestsPerMinute !== undefined &&
941
+ (!Number.isSafeInteger(args.requestsPerMinute) || args.requestsPerMinute < 0)) {
942
+ throw new Error("requestsPerMinute must be a nonnegative safe integer");
943
+ }
898
944
  const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
899
945
  const { dimension: _d, value: _v, ...limits } = args;
900
946
  await ctx.db.patch(bucket._id, limits);
947
+ await syncPolicy(ctx, (await ctx.db.get(bucket._id)));
901
948
  return null;
902
949
  },
903
950
  });
@@ -1028,13 +1075,23 @@ export const deleteBucket = mutation({
1028
1075
  return { deletedThisBatch: rows.length, done: false };
1029
1076
  }
1030
1077
  const bucket = await getBucketDoc(ctx, dimension, value);
1031
- if (bucket)
1078
+ if (bucket) {
1079
+ const policy = await ctx.db.query("bucketPolicies").withIndex("dim_value", q => q.eq("dimension", dimension).eq("value", value)).unique();
1080
+ if (policy)
1081
+ await ctx.db.delete(policy._id);
1082
+ await requestRateLimiter.reset(ctx, "requests", { key: bucket._id });
1032
1083
  await ctx.db.delete(bucket._id);
1084
+ }
1033
1085
  return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
1034
1086
  }
1035
1087
  const bucket = await getBucketDoc(ctx, dimension, value);
1036
- if (bucket)
1088
+ if (bucket) {
1089
+ const policy = await ctx.db.query("bucketPolicies").withIndex("dim_value", q => q.eq("dimension", dimension).eq("value", value)).unique();
1090
+ if (policy)
1091
+ await ctx.db.delete(policy._id);
1092
+ await requestRateLimiter.reset(ctx, "requests", { key: bucket._id });
1037
1093
  await ctx.db.delete(bucket._id);
1094
+ }
1038
1095
  return { deletedThisBatch: bucket ? 1 : 0, done: true };
1039
1096
  },
1040
1097
  });
@@ -13,6 +13,39 @@ export declare const vTag: import("convex/values").VObject<{
13
13
  value: import("convex/values").VString<string, "required">;
14
14
  }, "required", "dimension" | "value">;
15
15
  declare const _default: import("convex/server").SchemaDefinition<{
16
+ bucketPolicies: import("convex/server").TableDefinition<import("convex/values").VObject<{
17
+ requestsPerMinute?: number | undefined;
18
+ maxConcurrent?: number | undefined;
19
+ dailySpendLimitNanos?: number | undefined;
20
+ monthlySpendLimitNanos?: number | undefined;
21
+ lifetimeSpendLimitNanos?: number | undefined;
22
+ dailyTokenLimit?: number | undefined;
23
+ monthlyTokenLimit?: number | undefined;
24
+ lifetimeTokenLimit?: number | undefined;
25
+ blocked?: boolean | undefined;
26
+ warnAtPct?: number | undefined;
27
+ enforcement?: "hard" | "soft" | undefined;
28
+ bucketId: import("convex/values").GenericId<"buckets">;
29
+ dimension: string;
30
+ value: string;
31
+ }, {
32
+ bucketId: import("convex/values").VId<import("convex/values").GenericId<"buckets">, "required">;
33
+ dimension: import("convex/values").VString<string, "required">;
34
+ value: import("convex/values").VString<string, "required">;
35
+ requestsPerMinute: import("convex/values").VFloat64<number | undefined, "optional">;
36
+ maxConcurrent: import("convex/values").VFloat64<number | undefined, "optional">;
37
+ dailySpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
38
+ monthlySpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
39
+ lifetimeSpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
40
+ dailyTokenLimit: import("convex/values").VFloat64<number | undefined, "optional">;
41
+ monthlyTokenLimit: import("convex/values").VFloat64<number | undefined, "optional">;
42
+ lifetimeTokenLimit: import("convex/values").VFloat64<number | undefined, "optional">;
43
+ blocked: import("convex/values").VBoolean<boolean | undefined, "optional">;
44
+ warnAtPct: import("convex/values").VFloat64<number | undefined, "optional">;
45
+ enforcement: import("convex/values").VUnion<"hard" | "soft" | undefined, [import("convex/values").VLiteral<"hard", "required">, import("convex/values").VLiteral<"soft", "required">], "optional", never>;
46
+ }, "required", "bucketId" | "dimension" | "value" | "requestsPerMinute" | "maxConcurrent" | "dailySpendLimitNanos" | "monthlySpendLimitNanos" | "lifetimeSpendLimitNanos" | "dailyTokenLimit" | "monthlyTokenLimit" | "lifetimeTokenLimit" | "blocked" | "warnAtPct" | "enforcement">, {
47
+ dim_value: ["dimension", "value", "_creationTime"];
48
+ }, {}, {}>;
16
49
  buckets: import("convex/server").TableDefinition<import("convex/values").VObject<{
17
50
  requestsPerMinute?: number | undefined;
18
51
  maxConcurrent?: number | undefined;
@@ -140,6 +173,14 @@ declare const _default: import("convex/server").SchemaDefinition<{
140
173
  dimension: string;
141
174
  value: string;
142
175
  }[] | undefined;
176
+ heldBucketIds?: import("convex/values").GenericId<"buckets">[] | undefined;
177
+ reservationDay?: string | undefined;
178
+ reservationMonth?: string | undefined;
179
+ reservationReleased?: boolean | undefined;
180
+ reservationExpired?: boolean | undefined;
181
+ contentPurged?: boolean | undefined;
182
+ expiresAt?: number | undefined;
183
+ finishedAt?: number | undefined;
143
184
  estimatedNanos?: number | undefined;
144
185
  estimatedTokens?: number | undefined;
145
186
  unpricedModel?: boolean | undefined;
@@ -176,6 +217,14 @@ declare const _default: import("convex/server").SchemaDefinition<{
176
217
  value: import("convex/values").VString<string, "required">;
177
218
  }, "required", "dimension" | "value">, "optional">;
178
219
  model: import("convex/values").VString<string, "required">;
220
+ heldBucketIds: import("convex/values").VArray<import("convex/values").GenericId<"buckets">[] | undefined, import("convex/values").VId<import("convex/values").GenericId<"buckets">, "required">, "optional">;
221
+ reservationDay: import("convex/values").VString<string | undefined, "optional">;
222
+ reservationMonth: import("convex/values").VString<string | undefined, "optional">;
223
+ reservationReleased: import("convex/values").VBoolean<boolean | undefined, "optional">;
224
+ reservationExpired: import("convex/values").VBoolean<boolean | undefined, "optional">;
225
+ contentPurged: import("convex/values").VBoolean<boolean | undefined, "optional">;
226
+ expiresAt: import("convex/values").VFloat64<number | undefined, "optional">;
227
+ finishedAt: import("convex/values").VFloat64<number | undefined, "optional">;
179
228
  estimatedNanos: import("convex/values").VFloat64<number | undefined, "optional">;
180
229
  estimatedTokens: import("convex/values").VFloat64<number | undefined, "optional">;
181
230
  unpricedModel: import("convex/values").VBoolean<boolean | undefined, "optional">;
@@ -202,9 +251,12 @@ declare const _default: import("convex/server").SchemaDefinition<{
202
251
  costNanos: import("convex/values").VFloat64<number | undefined, "optional">;
203
252
  latencyMs: import("convex/values").VFloat64<number | undefined, "optional">;
204
253
  rerunOf: import("convex/values").VId<import("convex/values").GenericId<"requests"> | undefined, "optional">;
205
- }, "required", "userId" | "actionName" | "tags" | "model" | "estimatedNanos" | "estimatedTokens" | "unpricedModel" | "overBudget" | "settled" | "messages" | "status" | "error" | "responseText" | "promptTokens" | "completionTokens" | "cachedTokens" | "serverToolUses" | "reserveTtlMs" | "costNanos" | "latencyMs" | "rerunOf" | `serverToolUses.${string}`>, {
254
+ }, "required", "userId" | "actionName" | "tags" | "model" | "heldBucketIds" | "reservationDay" | "reservationMonth" | "reservationReleased" | "reservationExpired" | "contentPurged" | "expiresAt" | "finishedAt" | "estimatedNanos" | "estimatedTokens" | "unpricedModel" | "overBudget" | "settled" | "messages" | "status" | "error" | "responseText" | "promptTokens" | "completionTokens" | "cachedTokens" | "serverToolUses" | "reserveTtlMs" | "costNanos" | "latencyMs" | "rerunOf" | `serverToolUses.${string}`>, {
206
255
  userId: ["userId", "_creationTime"];
207
256
  status: ["status", "_creationTime"];
257
+ status_expires: ["status", "expiresAt", "_creationTime"];
258
+ retention: ["reservationExpired", "settled", "_creationTime"];
259
+ expired_content: ["reservationExpired", "contentPurged", "_creationTime"];
208
260
  rerunOf: ["rerunOf", "_creationTime"];
209
261
  actionName: ["actionName", "_creationTime"];
210
262
  settled: ["settled", "_creationTime"];
@@ -9,6 +9,25 @@ export const vMessage = v.object({
9
9
  // are built-in dimensions; apps can add any others (team, project, env, …).
10
10
  export const vTag = v.object({ dimension: v.string(), value: v.string() });
11
11
  export default defineSchema({
12
+ bucketPolicies: defineTable({
13
+ bucketId: v.id("buckets"),
14
+ dimension: v.string(),
15
+ value: v.string(),
16
+ requestsPerMinute: v.optional(v.number()), // token-bucket refill per minute and burst capacity
17
+ maxConcurrent: v.optional(v.number()), // max in-flight (pending) requests
18
+ dailySpendLimitNanos: v.optional(v.number()),
19
+ monthlySpendLimitNanos: v.optional(v.number()),
20
+ lifetimeSpendLimitNanos: v.optional(v.number()),
21
+ dailyTokenLimit: v.optional(v.number()),
22
+ monthlyTokenLimit: v.optional(v.number()),
23
+ lifetimeTokenLimit: v.optional(v.number()),
24
+ blocked: v.optional(v.boolean()), // hard block (was `blocked`/`disabled`)
25
+ // Fire an approaching-limit alert once usage crosses this fraction of a cap
26
+ // (e.g. 0.8 = warn at 80%). Falls back to the deployment default.
27
+ warnAtPct: v.optional(v.number()),
28
+ // "hard" (default): exceeding a budget blocks. "soft": warn but allow.
29
+ enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
30
+ }).index("dim_value", ["dimension", "value"]),
12
31
  // A budget holder, keyed by (dimension, value). Unifies what used to be the
13
32
  // `users` and `actions` tables — those are just the "user" and "action"
14
33
  // dimensions now. Any tag a request carries can have its own budget here.
@@ -16,7 +35,7 @@ export default defineSchema({
16
35
  dimension: v.string(),
17
36
  value: v.string(),
18
37
  // limits (all optional — unlimited by default)
19
- requestsPerMinute: v.optional(v.number()), // rolling limit for this bucket
38
+ requestsPerMinute: v.optional(v.number()), // token-bucket refill per minute and burst capacity
20
39
  maxConcurrent: v.optional(v.number()), // max in-flight (pending) requests
21
40
  dailySpendLimitNanos: v.optional(v.number()),
22
41
  monthlySpendLimitNanos: v.optional(v.number()),
@@ -102,6 +121,14 @@ export default defineSchema({
102
121
  tags: v.optional(v.array(vTag)),
103
122
  model: v.string(),
104
123
  // pessimistic holds placed at start; reconciled to actual on settle
124
+ heldBucketIds: v.optional(v.array(v.id("buckets"))),
125
+ reservationDay: v.optional(v.string()),
126
+ reservationMonth: v.optional(v.string()),
127
+ reservationReleased: v.optional(v.boolean()),
128
+ reservationExpired: v.optional(v.boolean()),
129
+ contentPurged: v.optional(v.boolean()),
130
+ expiresAt: v.optional(v.number()),
131
+ finishedAt: v.optional(v.number()),
105
132
  estimatedNanos: v.optional(v.number()),
106
133
  estimatedTokens: v.optional(v.number()),
107
134
  // true when the model had no known/override price and was charged the
@@ -134,6 +161,9 @@ export default defineSchema({
134
161
  })
135
162
  .index("userId", ["userId"])
136
163
  .index("status", ["status"])
164
+ .index("status_expires", ["status", "expiresAt"])
165
+ .index("retention", ["reservationExpired", "settled"])
166
+ .index("expired_content", ["reservationExpired", "contentPurged"])
137
167
  .index("rerunOf", ["rerunOf"])
138
168
  .index("actionName", ["actionName"])
139
169
  .index("settled", ["settled"]),
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "support@convex.dev",
8
8
  "url": "https://github.com/get-convex/ai-budget/issues"
9
9
  },
10
- "version": "0.0.2-alpha.13",
10
+ "version": "0.0.2-alpha.15",
11
11
  "license": "Apache-2.0",
12
12
  "type": "module",
13
13
  "keywords": [
@@ -56,7 +56,8 @@
56
56
  "types": "./dist/client/index.d.ts",
57
57
  "module": "./dist/client/index.js",
58
58
  "dependencies": {
59
- "@convex-dev/sharded-counter": "^0.2.1"
59
+ "@convex-dev/sharded-counter": "^0.2.1",
60
+ "@convex-dev/rate-limiter": "0.4.0"
60
61
  },
61
62
  "peerDependencies": {
62
63
  "@convex-dev/ai-sdk-provider": "*",