@convex-dev/ai-budget 0.0.2-alpha.14 → 0.0.2-alpha.16

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,12 +352,15 @@ 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
366
  const requestId = await ctx.db.insert("requests", {
@@ -375,7 +414,7 @@ export const startRequest = mutation({
375
414
  const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
376
415
  const buckets = [];
377
416
  for (const t of bucketTags) {
378
- buckets.push(await getOrCreateBucket(ctx, t.dimension, t.value));
417
+ buckets.push(await admissionBucket(ctx, t.dimension, t.value));
379
418
  }
380
419
  // A hard block on ANY bucket rejects the request. The user dimension's block
381
420
  // isn't persisted (retried in a loop); config-level blocks on other
@@ -394,44 +433,15 @@ export const startRequest = mutation({
394
433
  return reject(`${b.dimension}_max_concurrent`, `Too many concurrent requests for ${b.dimension} "${b.value}" (max ${b.maxConcurrent})`, false);
395
434
  }
396
435
  }
397
- // Enforce the rolling 60-second rate limit on every configured dimension.
398
- // User/action requests use their first-class indexes; custom dimensions use
399
- // the requestTags reverse index. All reads are bounded by the configured
400
- // limit (plus a small allowance for persisted blocked attempts).
401
- 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.
402
439
  for (const b of buckets) {
403
440
  const limit = b.requestsPerMinute;
404
441
  if (limit === undefined)
405
442
  continue;
406
- let recentCount;
407
- if (b.dimension === USER_DIM) {
408
- const recent = await ctx.db
409
- .query("requests")
410
- .withIndex("userId", (q) => q.eq("userId", b.value).gt("_creationTime", rateCutoff))
411
- .take(limit + 50);
412
- recentCount = recent.filter((r) => r.status !== "blocked").length;
413
- }
414
- else if (b.dimension === ACTION_DIM) {
415
- const recent = await ctx.db
416
- .query("requests")
417
- .withIndex("actionName", (q) => q.eq("actionName", b.value).gt("_creationTime", rateCutoff))
418
- .take(limit + 50);
419
- recentCount = recent.filter((r) => r.status !== "blocked").length;
420
- }
421
- else {
422
- // Tag rows also cover persisted blocked attempts; fetch each request
423
- // to exclude them, matching the user/action paths above.
424
- const tagRows = await ctx.db
425
- .query("requestTags")
426
- .withIndex("dim_value", (q) => q
427
- .eq("dimension", b.dimension)
428
- .eq("value", b.value)
429
- .gt("_creationTime", rateCutoff))
430
- .take(limit + 50);
431
- const recent = await Promise.all(tagRows.map((t) => ctx.db.get(t.requestId)));
432
- recentCount = recent.filter((r) => r !== null && r.status !== "blocked").length;
433
- }
434
- if (recentCount >= limit) {
443
+ const ok = limit > 0 && (await requestRateLimiter.check(ctx, "requests", requestRateOptions(b))).ok;
444
+ if (!ok) {
435
445
  const code = b.dimension === USER_DIM ? "rate_limit" : `${b.dimension}_rate_limit`;
436
446
  return reject(code, `Rate limit exceeded for ${b.dimension} "${b.value}" (${limit}/min)`, false);
437
447
  }
@@ -481,9 +491,8 @@ export const startRequest = mutation({
481
491
  // committed + reserved + estimate <= cap. The ONE difference is the holder:
482
492
  // per-bucket caps reserve on a single row (an atomic check-and-reserve),
483
493
  // while the global holder is a sharded counter for
484
- // throughput its committed total is read as an eventually-consistent sum
485
- // with no cross-request reservation, so a hard global cap can overshoot by a
486
- // 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
487
496
  // trade for a deployment-wide killswitch; it's the only approximate scope.
488
497
  if (settings &&
489
498
  (settings.globalDailySpendLimitNanos !== undefined ||
@@ -515,6 +524,17 @@ export const startRequest = mutation({
515
524
  warnings.push(...globalEval.warnings);
516
525
  notices.push(...globalEval.notices);
517
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
+ }
518
538
  // Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
519
539
  // uncapped bucket's row here would serialize every request that shares it
520
540
  // (e.g. all callers of one action, or every request in one env); with no cap
@@ -550,6 +570,10 @@ export const startRequest = mutation({
550
570
  rerunOf: args.rerunOf,
551
571
  ...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
552
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,
553
577
  estimatedNanos: est.cost,
554
578
  estimatedTokens: est.tokens,
555
579
  ...(priceInfo.known ? {} : { unpricedModel: true }),
@@ -590,14 +614,9 @@ export const finishRequest = mutation({
590
614
  const request = await ctx.db.get(args.requestId);
591
615
  if (!request)
592
616
  throw new Error("Unknown request");
593
- // Exactly-once settlement. A request that already reached a terminal state
594
- // finished normally, or expired by the reconciler's stale sweep — must
595
- // not be settled again. Without this, a merely-slow request that the sweep
596
- // already folded would be re-opened and folded a SECOND time when it
597
- // finally completes: totals double-count and the reservation is released
598
- // twice, dropping the reserved pool below reality and letting the atomic
599
- // check-and-reserve admit requests it should block.
600
- 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) {
601
620
  return { costNanos: request.costNanos ?? 0 };
602
621
  }
603
622
  // Clamp caller-supplied token counts: negatives would produce negative cost
@@ -622,6 +641,9 @@ export const finishRequest = mutation({
622
641
  // orphaned in "pending" even if the totals update below fails and retries.
623
642
  await ctx.db.patch(args.requestId, {
624
643
  status: args.error ? "error" : "success",
644
+ reservationExpired: false,
645
+ expiresAt: undefined,
646
+ finishedAt: Date.now(),
625
647
  responseText: args.responseText,
626
648
  error: args.error,
627
649
  promptTokens,
@@ -640,59 +662,68 @@ export const finishRequest = mutation({
640
662
  return { costNanos };
641
663
  },
642
664
  });
643
- // Fold one finished request into every attributed bucket's running totals,
644
- // releasing its reservation. Idempotent: guarded by `settled` so the scheduler
645
- // 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.
646
692
  async function foldOne(ctx, req) {
647
693
  if (!req || req.settled !== false)
648
694
  return;
695
+ await releaseReservation(ctx, req);
649
696
  const actual = req.costNanos ?? 0;
650
- const estCost = req.estimatedNanos ?? 0;
651
697
  const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
652
- const estTokens = req.estimatedTokens ?? 0;
653
- const today = dayStamp();
654
- const month = monthStamp();
655
- // Accrue into every attributed bucket (user, action, and each tag) — capped
656
- // or not. Buckets that never held a reservation have their reserved fields
657
- // clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
658
- // Also write the durable per-(bucket, day/month) usage rows that survive
659
- // 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);
660
701
  for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
661
702
  const b = await getOrCreateBucket(ctx, t.dimension, t.value);
662
- const sameDay = b.dayStamp === today;
663
- 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;
664
707
  await ctx.db.patch(b._id, {
665
708
  totalSpendNanos: b.totalSpendNanos + actual,
666
709
  totalRequests: b.totalRequests + 1,
667
710
  totalTokens: b.totalTokens + tokens,
668
- dayStamp: today,
669
- monthStamp: month,
670
- spendTodayNanos: (sameDay ? b.spendTodayNanos : 0) + actual,
671
- tokensToday: (sameDay ? b.tokensToday ?? 0 : 0) + tokens,
672
- spendThisMonthNanos: (sameMonth ? b.spendThisMonthNanos ?? 0 : 0) + actual,
673
- tokensThisMonth: (sameMonth ? b.tokensThisMonth ?? 0 : 0) + tokens,
674
- reservedTodayNanos: Math.max(0, (sameDay ? b.reservedTodayNanos ?? 0 : 0) - estCost),
675
- reservedMonthNanos: Math.max(0, (sameMonth ? b.reservedMonthNanos ?? 0 : 0) - estCost),
676
- reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
677
- reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
678
- reservedMonthTokens: Math.max(0, (sameMonth ? b.reservedMonthTokens ?? 0 : 0) - estTokens),
679
- reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
680
- 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 } : {}),
681
719
  });
682
- 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);
683
721
  await addUsage(ctx, t.dimension, t.value, "month", month, actual, tokens, 1);
684
722
  }
685
- // Deployment-wide totals via the sharded counter (only when a global cap is
686
- // configured — otherwise skip the writes entirely). Distributed across shards,
687
- // so this does not serialize on a single row.
723
+ // Reporting is independent of whether enforcement is configured.
688
724
  if (actual > 0) {
689
- const settings = await getSettings(ctx);
690
- if (settings &&
691
- (settings.globalDailySpendLimitNanos !== undefined ||
692
- settings.globalLifetimeSpendLimitNanos !== undefined)) {
693
- await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
694
- await globalSpend.add(ctx, globalDayKey(today), actual);
695
- }
725
+ await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
726
+ await globalSpend.add(ctx, globalDayKey(day), actual);
696
727
  }
697
728
  await ctx.db.patch(req._id, { settled: true });
698
729
  }
@@ -721,46 +752,44 @@ export const reconcile = internalMutation({
721
752
  .take(200);
722
753
  for (const req of toFold)
723
754
  await foldOne(ctx, req);
724
- // Reap dead reservations: pending rows older than the default floor, but a
725
- // per-request reserveTtlMs (set for long async jobs like video) holds the
726
- // reservation until *its* deadline so a still-running job isn't reaped.
727
- const cutoff = Date.now() - STALE_PENDING_MS;
728
- const candidates = await ctx.db
729
- .query("requests")
730
- .withIndex("status", (q) => q.eq("status", "pending").lt("_creationTime", cutoff))
731
- .take(200);
732
- 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);
733
762
  for (const req of candidates) {
734
- const ttl = req.reserveTtlMs ?? STALE_PENDING_MS;
735
- if (Date.now() - req._creationTime <= ttl)
736
- continue; // still within its window
763
+ await releaseReservation(ctx, req);
737
764
  await ctx.db.patch(req._id, {
738
- status: "error",
739
- error: "Timed out before settling; reservation released",
740
- costNanos: 0,
741
- settled: false,
765
+ status: "error", error: "Reservation expired; awaiting final usage",
766
+ reservationExpired: true, expiresAt: undefined, settled: true,
742
767
  });
743
- await foldOne(ctx, await ctx.db.get(req._id));
744
- expired++;
745
768
  }
769
+ const expired = candidates.length;
746
770
  // Retention: delete terminal, fully-accounted request rows past the window.
747
771
  const settings = await getSettings(ctx);
748
772
  const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
749
773
  let purged = 0;
750
774
  if (retentionMs > 0) {
751
775
  const retentionCutoff = Date.now() - retentionMs;
752
- const old = await ctx.db
753
- .query("requests")
754
- .withIndex("by_creation_time", (q) => q.lt("_creationTime", retentionCutoff))
755
- .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));
756
784
  for (const req of old) {
757
- // Only rows that are done and accounted: folded (settled === true) or a
758
- // blocked attempt (never needs folding). Never a pending/unfolded row.
759
- if (req.settled === true || req.status === "blocked") {
760
- await deleteRequestTags(ctx, req._id);
761
- await ctx.db.delete(req._id);
762
- purged++;
763
- }
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 });
764
793
  }
765
794
  }
766
795
  return { folded: toFold.length, expired, purged };
@@ -908,9 +937,14 @@ export const setBucketLimits = mutation({
908
937
  },
909
938
  returns: v.null(),
910
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
+ }
911
944
  const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
912
945
  const { dimension: _d, value: _v, ...limits } = args;
913
946
  await ctx.db.patch(bucket._id, limits);
947
+ await syncPolicy(ctx, (await ctx.db.get(bucket._id)));
914
948
  return null;
915
949
  },
916
950
  });
@@ -1041,13 +1075,23 @@ export const deleteBucket = mutation({
1041
1075
  return { deletedThisBatch: rows.length, done: false };
1042
1076
  }
1043
1077
  const bucket = await getBucketDoc(ctx, dimension, value);
1044
- 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 });
1045
1083
  await ctx.db.delete(bucket._id);
1084
+ }
1046
1085
  return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
1047
1086
  }
1048
1087
  const bucket = await getBucketDoc(ctx, dimension, value);
1049
- 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 });
1050
1093
  await ctx.db.delete(bucket._id);
1094
+ }
1051
1095
  return { deletedThisBatch: bucket ? 1 : 0, done: true };
1052
1096
  },
1053
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.14",
10
+ "version": "0.0.2-alpha.16",
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": "*",