@convex-dev/ai-budget 0.0.2-alpha.14 → 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.
@@ -8,6 +8,7 @@ import {
8
8
  import { api, internal, components } from "./_generated/api";
9
9
  import { vMessage, vTag } from "./schema";
10
10
  import type { Doc } from "./_generated/dataModel";
11
+ import { RateLimiter, MINUTE } from "@convex-dev/rate-limiter";
11
12
  import { ShardedCounter } from "@convex-dev/sharded-counter";
12
13
 
13
14
  // All money is integer **nanodollars** (1 USD = 1e9 nano). Integers avoid the
@@ -26,6 +27,17 @@ const fmtUsd = (nanos: number) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
26
27
  const USER_DIM = "user";
27
28
  const ACTION_DIM = "action";
28
29
 
30
+ const requestRateLimiter = new RateLimiter(components.rateLimiter);
31
+ const requestRateOptions = (bucket: Doc<"buckets">) => ({
32
+ key: bucket._id,
33
+ config: {
34
+ kind: "token bucket" as const,
35
+ rate: bucket.requestsPerMinute!,
36
+ capacity: bucket.requestsPerMinute!,
37
+ period: MINUTE,
38
+ },
39
+ });
40
+
29
41
  // Deployment-wide spend totals (nanodollars), sharded for high write throughput.
30
42
  // Keyed "total" (lifetime) and "day:<UTC date>" (natural daily reset).
31
43
  const globalSpend = new ShardedCounter(components.shardedCounter);
@@ -56,12 +68,8 @@ const DEFAULT_SERVER_TOOL_PRICES: Record<string, number> = {
56
68
  // concurrent admission atomic against the estimate; a response that exceeds the
57
69
  // estimate can still settle above the cap by the estimation delta.
58
70
  const ESTIMATED_OUTPUT_TOKENS = 800;
59
- // A request still "pending" after this long is presumed dead (its action
60
- // crashed before settling); the reconciler releases its reservation. Set well
61
- // above any real call duration — a long reasoning/agent generation that is
62
- // still billing must not be swept and mis-recorded as free. A late
63
- // finishRequest is a no-op once swept (see finishRequest's terminal guard), so
64
- // the only cost of a generous timeout is a briefly-held reservation.
71
+ // Expiry releases a hold without declaring final usage. A late provider
72
+ // completion can still record its authoritative charge exactly once.
65
73
  const STALE_PENDING_MS = 30 * 60 * 1000;
66
74
  // Default retention for request rows (full prompts + responses). Terminal,
67
75
  // fully-accounted rows older than this are swept by the reconciler. Keeps the
@@ -379,6 +387,36 @@ async function getOrCreateBucket(
379
387
  return (await ctx.db.get(id))!;
380
388
  }
381
389
 
390
+ // Policy is read on every admission; reporting writes never touch it.
391
+ async function syncPolicy(ctx: MutationCtx, b: Doc<"buckets">) {
392
+ const existing = await ctx.db.query("bucketPolicies").withIndex("dim_value", q =>
393
+ q.eq("dimension", b.dimension).eq("value", b.value)).unique();
394
+ const policy = {
395
+ bucketId: b._id, dimension: b.dimension, value: b.value,
396
+ requestsPerMinute: b.requestsPerMinute, maxConcurrent: b.maxConcurrent,
397
+ dailySpendLimitNanos: b.dailySpendLimitNanos, monthlySpendLimitNanos: b.monthlySpendLimitNanos,
398
+ lifetimeSpendLimitNanos: b.lifetimeSpendLimitNanos, dailyTokenLimit: b.dailyTokenLimit,
399
+ monthlyTokenLimit: b.monthlyTokenLimit, lifetimeTokenLimit: b.lifetimeTokenLimit,
400
+ blocked: b.blocked, warnAtPct: b.warnAtPct, enforcement: b.enforcement,
401
+ };
402
+ if (existing) await ctx.db.replace(existing._id, policy);
403
+ else await ctx.db.insert("bucketPolicies", policy);
404
+ }
405
+
406
+ async function admissionBucket(ctx: MutationCtx, dimension: string, value: string): Promise<Doc<"buckets">> {
407
+ const policy = await ctx.db.query("bucketPolicies").withIndex("dim_value", q =>
408
+ q.eq("dimension", dimension).eq("value", value)).unique();
409
+ if (!policy) {
410
+ const bucket = await getOrCreateBucket(ctx, dimension, value);
411
+ await syncPolicy(ctx, bucket);
412
+ return bucket;
413
+ }
414
+ // Only capped buckets need an atomic read of their accounting state.
415
+ if (needsReserve(policy)) return (await ctx.db.get(policy.bucketId))!;
416
+ return { ...policy, _id: policy.bucketId, totalSpendNanos: 0, totalRequests: 0,
417
+ totalTokens: 0, dayStamp: "", spendTodayNanos: 0 };
418
+ }
419
+
382
420
  async function getSettings(ctx: MutationCtx) {
383
421
  return await ctx.db
384
422
  .query("settings")
@@ -432,12 +470,15 @@ export const startRequest = mutation({
432
470
  },
433
471
  returns: vStartResult,
434
472
  handler: async (ctx, args) => {
473
+ if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
474
+ throw new Error("reserveTtlMs must be finite and nonnegative");
475
+ }
435
476
  const extraTags = sanitizeExtraTags(args.tags);
436
477
  // Record the blocked attempt and return a rejection (throwing would roll
437
478
  // back the record). `persist` is false for the high-frequency-by-design
438
479
  // rejections (rate limit, blocked user) that a client retries in a tight
439
480
  // loop — persisting those would grow the requests table without bound and
440
- // bloat the 60s rate-limit window read below.
481
+ // add audit-log writes to every transient rejection.
441
482
  const reject = async (code: string, reason: string, persist = true) => {
442
483
  if (persist) {
443
484
  const requestId = await ctx.db.insert("requests", {
@@ -497,7 +538,7 @@ export const startRequest = mutation({
497
538
  const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
498
539
  const buckets: Doc<"buckets">[] = [];
499
540
  for (const t of bucketTags) {
500
- buckets.push(await getOrCreateBucket(ctx, t.dimension, t.value));
541
+ buckets.push(await admissionBucket(ctx, t.dimension, t.value));
501
542
  }
502
543
 
503
544
  // A hard block on ANY bucket rejects the request. The user dimension's block
@@ -527,49 +568,16 @@ export const startRequest = mutation({
527
568
  }
528
569
  }
529
570
 
530
- // Enforce the rolling 60-second rate limit on every configured dimension.
531
- // User/action requests use their first-class indexes; custom dimensions use
532
- // the requestTags reverse index. All reads are bounded by the configured
533
- // limit (plus a small allowance for persisted blocked attempts).
534
- const rateCutoff = Date.now() - 60_000;
571
+ // Check all rates before consuming any. These transactional reads remain
572
+ // in admission's read set, so concurrent requests cannot spend the same
573
+ // capacity. Budget rejections below leave every rate balance untouched.
535
574
  for (const b of buckets) {
536
575
  const limit = b.requestsPerMinute;
537
576
  if (limit === undefined) continue;
538
-
539
- let recentCount: number;
540
- if (b.dimension === USER_DIM) {
541
- const recent = await ctx.db
542
- .query("requests")
543
- .withIndex("userId", (q) =>
544
- q.eq("userId", b.value).gt("_creationTime", rateCutoff)
545
- )
546
- .take(limit + 50);
547
- recentCount = recent.filter((r) => r.status !== "blocked").length;
548
- } else if (b.dimension === ACTION_DIM) {
549
- const recent = await ctx.db
550
- .query("requests")
551
- .withIndex("actionName", (q) =>
552
- q.eq("actionName", b.value).gt("_creationTime", rateCutoff)
553
- )
554
- .take(limit + 50);
555
- recentCount = recent.filter((r) => r.status !== "blocked").length;
556
- } else {
557
- // Tag rows also cover persisted blocked attempts; fetch each request
558
- // to exclude them, matching the user/action paths above.
559
- const tagRows = await ctx.db
560
- .query("requestTags")
561
- .withIndex("dim_value", (q) =>
562
- q
563
- .eq("dimension", b.dimension)
564
- .eq("value", b.value)
565
- .gt("_creationTime", rateCutoff)
566
- )
567
- .take(limit + 50);
568
- const recent = await Promise.all(tagRows.map((t) => ctx.db.get(t.requestId)));
569
- recentCount = recent.filter((r) => r !== null && r.status !== "blocked").length;
570
- }
571
-
572
- if (recentCount >= limit) {
577
+ const ok = limit > 0 && (await requestRateLimiter.check(
578
+ ctx, "requests", requestRateOptions(b)
579
+ )).ok;
580
+ if (!ok) {
573
581
  const code = b.dimension === USER_DIM ? "rate_limit" : `${b.dimension}_rate_limit`;
574
582
  return reject(
575
583
  code,
@@ -633,9 +641,8 @@ export const startRequest = mutation({
633
641
  // committed + reserved + estimate <= cap. The ONE difference is the holder:
634
642
  // per-bucket caps reserve on a single row (an atomic check-and-reserve),
635
643
  // while the global holder is a sharded counter for
636
- // throughput its committed total is read as an eventually-consistent sum
637
- // with no cross-request reservation, so a hard global cap can overshoot by a
638
- // bounded amount under burst. That's the deliberate consistency/throughput
644
+ // throughput. The sum is transactional, but excludes unsettled usage and
645
+ // has no cross-request reservation, so a hard global cap can overshoot. That's the deliberate consistency/throughput
639
646
  // trade for a deployment-wide killswitch; it's the only approximate scope.
640
647
  if (
641
648
  settings &&
@@ -675,6 +682,18 @@ export const startRequest = mutation({
675
682
  notices.push(...globalEval.notices);
676
683
  }
677
684
 
685
+ // Consume only after every admission check succeeds, in the same
686
+ // transaction as the reservations and request insert. An unexpected
687
+ // rejection throws to roll back all previously consumed dimensions.
688
+ for (const b of buckets) {
689
+ if (b.requestsPerMinute !== undefined) {
690
+ await requestRateLimiter.limit(ctx, "requests", {
691
+ ...requestRateOptions(b),
692
+ throws: true,
693
+ });
694
+ }
695
+ }
696
+
678
697
  // Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
679
698
  // uncapped bucket's row here would serialize every request that shares it
680
699
  // (e.g. all callers of one action, or every request in one env); with no cap
@@ -709,6 +728,10 @@ export const startRequest = mutation({
709
728
  rerunOf: args.rerunOf,
710
729
  ...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
711
730
  status: "pending",
731
+ expiresAt: Date.now() + Math.max(STALE_PENDING_MS, args.reserveTtlMs ?? 0),
732
+ heldBucketIds: buckets.filter(needsReserve).map(b => b._id),
733
+ reservationDay: today,
734
+ reservationMonth: month,
712
735
  estimatedNanos: est.cost,
713
736
  estimatedTokens: est.tokens,
714
737
  ...(priceInfo.known ? {} : { unpricedModel: true }),
@@ -750,14 +773,9 @@ export const finishRequest = mutation({
750
773
  const request = await ctx.db.get(args.requestId);
751
774
  if (!request) throw new Error("Unknown request");
752
775
 
753
- // Exactly-once settlement. A request that already reached a terminal state
754
- // finished normally, or expired by the reconciler's stale sweep — must
755
- // not be settled again. Without this, a merely-slow request that the sweep
756
- // already folded would be re-opened and folded a SECOND time when it
757
- // finally completes: totals double-count and the reservation is released
758
- // twice, dropping the reserved pool below reality and letting the atomic
759
- // check-and-reserve admit requests it should block.
760
- if (request.status !== "pending") {
776
+ // Expiry releases capacity, but is not evidence that the provider charged
777
+ // nothing. Accept one final result even after expiry; duplicates stay no-ops.
778
+ if (request.status !== "pending" && !request.reservationExpired) {
761
779
  return { costNanos: request.costNanos ?? 0 };
762
780
  }
763
781
 
@@ -788,6 +806,9 @@ export const finishRequest = mutation({
788
806
  // orphaned in "pending" even if the totals update below fails and retries.
789
807
  await ctx.db.patch(args.requestId, {
790
808
  status: args.error ? "error" : "success",
809
+ reservationExpired: false,
810
+ expiresAt: undefined,
811
+ finishedAt: Date.now(),
791
812
  responseText: args.responseText,
792
813
  error: args.error,
793
814
  promptTokens,
@@ -808,62 +829,66 @@ export const finishRequest = mutation({
808
829
  },
809
830
  });
810
831
 
811
- // Fold one finished request into every attributed bucket's running totals,
812
- // releasing its reservation. Idempotent: guarded by `settled` so the scheduler
813
- // and the cron reconciler can never double-count.
832
+ // Release only holds owned by this request, and only from their original
833
+ // calendar windows. A previous day's completion must not debit today's holds.
834
+ async function releaseReservation(ctx: MutationCtx, req: Doc<"requests">) {
835
+ if (req.reservationReleased) return;
836
+ const day = req.reservationDay ?? new Date(req._creationTime).toISOString().slice(0, 10);
837
+ const month = req.reservationMonth ?? day.slice(0, 7);
838
+ const cost = req.estimatedNanos ?? 0;
839
+ const tokens = req.estimatedTokens ?? 0;
840
+ for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
841
+ const b = await getBucketDoc(ctx, t.dimension, t.value);
842
+ if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b))) continue;
843
+ await ctx.db.patch(b._id, {
844
+ reservedTodayNanos: Math.max(0, (b.reservedTodayNanos ?? 0) - (b.dayStamp === day ? cost : 0)),
845
+ reservedMonthNanos: Math.max(0, (b.reservedMonthNanos ?? 0) - (b.monthStamp === month ? cost : 0)),
846
+ reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - cost),
847
+ reservedTodayTokens: Math.max(0, (b.reservedTodayTokens ?? 0) - (b.dayStamp === day ? tokens : 0)),
848
+ reservedMonthTokens: Math.max(0, (b.reservedMonthTokens ?? 0) - (b.monthStamp === month ? tokens : 0)),
849
+ reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - tokens),
850
+ pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
851
+ });
852
+ }
853
+ await ctx.db.patch(req._id, { reservationReleased: true });
854
+ }
855
+
856
+ // Final billing is folded once. Reservation release has its own guard because
857
+ // expiry can precede the final charge by hours or days.
814
858
  async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
815
859
  if (!req || req.settled !== false) return;
860
+ await releaseReservation(ctx, req);
816
861
  const actual = req.costNanos ?? 0;
817
- const estCost = req.estimatedNanos ?? 0;
818
862
  const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
819
- const estTokens = req.estimatedTokens ?? 0;
820
- const today = dayStamp();
821
- const month = monthStamp();
822
-
823
- // Accrue into every attributed bucket (user, action, and each tag) — capped
824
- // or not. Buckets that never held a reservation have their reserved fields
825
- // clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
826
- // Also write the durable per-(bucket, day/month) usage rows that survive
827
- // request retention, so spend history outlives the raw request log.
863
+ const timestamp = new Date(req.finishedAt ?? Date.now()).toISOString();
864
+ const day = timestamp.slice(0, 10);
865
+ const month = timestamp.slice(0, 7);
828
866
  for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
829
867
  const b = await getOrCreateBucket(ctx, t.dimension, t.value);
830
- const sameDay = b.dayStamp === today;
831
- const sameMonth = b.monthStamp === month;
868
+ // Keep the newest reporting window and clear obsolete window holds when
869
+ // advancing it. Lifetime holds persist until their owner releases them.
870
+ const targetDay = b.dayStamp > day ? b.dayStamp : day;
871
+ const targetMonth = (b.monthStamp ?? "") > month ? b.monthStamp! : month;
832
872
  await ctx.db.patch(b._id, {
833
873
  totalSpendNanos: b.totalSpendNanos + actual,
834
874
  totalRequests: b.totalRequests + 1,
835
875
  totalTokens: b.totalTokens + tokens,
836
- dayStamp: today,
837
- monthStamp: month,
838
- spendTodayNanos: (sameDay ? b.spendTodayNanos : 0) + actual,
839
- tokensToday: (sameDay ? b.tokensToday ?? 0 : 0) + tokens,
840
- spendThisMonthNanos: (sameMonth ? b.spendThisMonthNanos ?? 0 : 0) + actual,
841
- tokensThisMonth: (sameMonth ? b.tokensThisMonth ?? 0 : 0) + tokens,
842
- reservedTodayNanos: Math.max(0, (sameDay ? b.reservedTodayNanos ?? 0 : 0) - estCost),
843
- reservedMonthNanos: Math.max(0, (sameMonth ? b.reservedMonthNanos ?? 0 : 0) - estCost),
844
- reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
845
- reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
846
- reservedMonthTokens: Math.max(0, (sameMonth ? b.reservedMonthTokens ?? 0 : 0) - estTokens),
847
- reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
848
- pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
876
+ dayStamp: targetDay,
877
+ monthStamp: targetMonth,
878
+ spendTodayNanos: (b.dayStamp === targetDay ? b.spendTodayNanos : 0) + (day === targetDay ? actual : 0),
879
+ tokensToday: (b.dayStamp === targetDay ? b.tokensToday ?? 0 : 0) + (day === targetDay ? tokens : 0),
880
+ spendThisMonthNanos: (b.monthStamp === targetMonth ? b.spendThisMonthNanos ?? 0 : 0) + (month === targetMonth ? actual : 0),
881
+ tokensThisMonth: (b.monthStamp === targetMonth ? b.tokensThisMonth ?? 0 : 0) + (month === targetMonth ? tokens : 0),
882
+ ...(b.dayStamp !== targetDay ? { reservedTodayNanos: 0, reservedTodayTokens: 0 } : {}),
883
+ ...(b.monthStamp !== targetMonth ? { reservedMonthNanos: 0, reservedMonthTokens: 0 } : {}),
849
884
  });
850
- await addUsage(ctx, t.dimension, t.value, "day", today, actual, tokens, 1);
885
+ await addUsage(ctx, t.dimension, t.value, "day", day, actual, tokens, 1);
851
886
  await addUsage(ctx, t.dimension, t.value, "month", month, actual, tokens, 1);
852
887
  }
853
-
854
- // Deployment-wide totals via the sharded counter (only when a global cap is
855
- // configured — otherwise skip the writes entirely). Distributed across shards,
856
- // so this does not serialize on a single row.
888
+ // Reporting is independent of whether enforcement is configured.
857
889
  if (actual > 0) {
858
- const settings = await getSettings(ctx);
859
- if (
860
- settings &&
861
- (settings.globalDailySpendLimitNanos !== undefined ||
862
- settings.globalLifetimeSpendLimitNanos !== undefined)
863
- ) {
864
- await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
865
- await globalSpend.add(ctx, globalDayKey(today), actual);
866
- }
890
+ await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
891
+ await globalSpend.add(ctx, globalDayKey(day), actual);
867
892
  }
868
893
  await ctx.db.patch(req._id, { settled: true });
869
894
  }
@@ -894,29 +919,23 @@ export const reconcile = internalMutation({
894
919
  .take(200);
895
920
  for (const req of toFold) await foldOne(ctx, req);
896
921
 
897
- // Reap dead reservations: pending rows older than the default floor, but a
898
- // per-request reserveTtlMs (set for long async jobs like video) holds the
899
- // reservation until *its* deadline so a still-running job isn't reaped.
900
- const cutoff = Date.now() - STALE_PENDING_MS;
901
- const candidates = await ctx.db
902
- .query("requests")
903
- .withIndex("status", (q) =>
904
- q.eq("status", "pending").lt("_creationTime", cutoff)
905
- )
906
- .take(200);
907
- let expired = 0;
922
+ // Lazily migrate old pending rows in bounded batches. Once indexed, long
923
+ // TTL jobs cannot hide expired jobs behind them in creation-time order.
924
+ const legacy = await ctx.db.query("requests").withIndex("status_expires", q =>
925
+ q.eq("status", "pending").eq("expiresAt", undefined)).take(200);
926
+ for (const req of legacy) {
927
+ await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
928
+ }
929
+ const candidates = await ctx.db.query("requests").withIndex("status_expires", q =>
930
+ q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(200);
908
931
  for (const req of candidates) {
909
- const ttl = req.reserveTtlMs ?? STALE_PENDING_MS;
910
- if (Date.now() - req._creationTime <= ttl) continue; // still within its window
932
+ await releaseReservation(ctx, req);
911
933
  await ctx.db.patch(req._id, {
912
- status: "error",
913
- error: "Timed out before settling; reservation released",
914
- costNanos: 0,
915
- settled: false,
934
+ status: "error", error: "Reservation expired; awaiting final usage",
935
+ reservationExpired: true, expiresAt: undefined, settled: true,
916
936
  });
917
- await foldOne(ctx, await ctx.db.get(req._id));
918
- expired++;
919
937
  }
938
+ const expired = candidates.length;
920
939
 
921
940
  // Retention: delete terminal, fully-accounted request rows past the window.
922
941
  const settings = await getSettings(ctx);
@@ -924,20 +943,26 @@ export const reconcile = internalMutation({
924
943
  let purged = 0;
925
944
  if (retentionMs > 0) {
926
945
  const retentionCutoff = Date.now() - retentionMs;
927
- const old = await ctx.db
928
- .query("requests")
929
- .withIndex("by_creation_time", (q) =>
930
- q.lt("_creationTime", retentionCutoff)
931
- )
932
- .take(500);
946
+ // Query eligible states directly. Long-lived pending jobs and expired
947
+ // billing tombstones must not repeatedly occupy the front of a scan.
948
+ const old = [];
949
+ for (const expiredFlag of [undefined, false]) {
950
+ old.push(...await ctx.db.query("requests").withIndex("retention", q =>
951
+ q.eq("reservationExpired", expiredFlag).eq("settled", true)
952
+ .lt("_creationTime", retentionCutoff)).take(200));
953
+ }
954
+ old.push(...await ctx.db.query("requests").withIndex("status", q =>
955
+ q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
933
956
  for (const req of old) {
934
- // Only rows that are done and accounted: folded (settled === true) or a
935
- // blocked attempt (never needs folding). Never a pending/unfolded row.
936
- if (req.settled === true || req.status === "blocked") {
937
- await deleteRequestTags(ctx, req._id);
938
- await ctx.db.delete(req._id);
939
- purged++;
940
- }
957
+ await deleteRequestTags(ctx, req._id);
958
+ await ctx.db.delete(req._id);
959
+ purged++;
960
+ }
961
+ const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q =>
962
+ q.eq("reservationExpired", true).eq("contentPurged", undefined)
963
+ .lt("_creationTime", retentionCutoff)).take(200);
964
+ for (const req of expiredContent) {
965
+ await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
941
966
  }
942
967
  }
943
968
  return { folded: toFold.length, expired, purged };
@@ -1090,9 +1115,14 @@ export const setBucketLimits = mutation({
1090
1115
  },
1091
1116
  returns: v.null(),
1092
1117
  handler: async (ctx, args) => {
1118
+ if (args.requestsPerMinute !== undefined &&
1119
+ (!Number.isSafeInteger(args.requestsPerMinute) || args.requestsPerMinute < 0)) {
1120
+ throw new Error("requestsPerMinute must be a nonnegative safe integer");
1121
+ }
1093
1122
  const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
1094
1123
  const { dimension: _d, value: _v, ...limits } = args;
1095
1124
  await ctx.db.patch(bucket._id, limits);
1125
+ await syncPolicy(ctx, (await ctx.db.get(bucket._id))!);
1096
1126
  return null;
1097
1127
  },
1098
1128
  });
@@ -1234,11 +1264,23 @@ export const deleteBucket = mutation({
1234
1264
  return { deletedThisBatch: rows.length, done: false };
1235
1265
  }
1236
1266
  const bucket = await getBucketDoc(ctx, dimension, value);
1237
- if (bucket) await ctx.db.delete(bucket._id);
1267
+ if (bucket) {
1268
+ const policy = await ctx.db.query("bucketPolicies").withIndex("dim_value", q =>
1269
+ q.eq("dimension", dimension).eq("value", value)).unique();
1270
+ if (policy) await ctx.db.delete(policy._id);
1271
+ await requestRateLimiter.reset(ctx, "requests", { key: bucket._id });
1272
+ await ctx.db.delete(bucket._id);
1273
+ }
1238
1274
  return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
1239
1275
  }
1240
1276
  const bucket = await getBucketDoc(ctx, dimension, value);
1241
- if (bucket) await ctx.db.delete(bucket._id);
1277
+ if (bucket) {
1278
+ const policy = await ctx.db.query("bucketPolicies").withIndex("dim_value", q =>
1279
+ q.eq("dimension", dimension).eq("value", value)).unique();
1280
+ if (policy) await ctx.db.delete(policy._id);
1281
+ await requestRateLimiter.reset(ctx, "requests", { key: bucket._id });
1282
+ await ctx.db.delete(bucket._id);
1283
+ }
1242
1284
  return { deletedThisBatch: bucket ? 1 : 0, done: true };
1243
1285
  },
1244
1286
  });
@@ -12,6 +12,25 @@ export const vMessage = v.object({
12
12
  export const vTag = v.object({ dimension: v.string(), value: v.string() });
13
13
 
14
14
  export default defineSchema({
15
+ bucketPolicies: defineTable({
16
+ bucketId: v.id("buckets"),
17
+ dimension: v.string(),
18
+ value: v.string(),
19
+ requestsPerMinute: v.optional(v.number()), // token-bucket refill per minute and burst capacity
20
+ maxConcurrent: v.optional(v.number()), // max in-flight (pending) requests
21
+ dailySpendLimitNanos: v.optional(v.number()),
22
+ monthlySpendLimitNanos: v.optional(v.number()),
23
+ lifetimeSpendLimitNanos: v.optional(v.number()),
24
+ dailyTokenLimit: v.optional(v.number()),
25
+ monthlyTokenLimit: v.optional(v.number()),
26
+ lifetimeTokenLimit: v.optional(v.number()),
27
+ blocked: v.optional(v.boolean()), // hard block (was `blocked`/`disabled`)
28
+ // Fire an approaching-limit alert once usage crosses this fraction of a cap
29
+ // (e.g. 0.8 = warn at 80%). Falls back to the deployment default.
30
+ warnAtPct: v.optional(v.number()),
31
+ // "hard" (default): exceeding a budget blocks. "soft": warn but allow.
32
+ enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
33
+ }).index("dim_value", ["dimension", "value"]),
15
34
  // A budget holder, keyed by (dimension, value). Unifies what used to be the
16
35
  // `users` and `actions` tables — those are just the "user" and "action"
17
36
  // dimensions now. Any tag a request carries can have its own budget here.
@@ -19,7 +38,7 @@ export default defineSchema({
19
38
  dimension: v.string(),
20
39
  value: v.string(),
21
40
  // limits (all optional — unlimited by default)
22
- requestsPerMinute: v.optional(v.number()), // rolling limit for this bucket
41
+ requestsPerMinute: v.optional(v.number()), // token-bucket refill per minute and burst capacity
23
42
  maxConcurrent: v.optional(v.number()), // max in-flight (pending) requests
24
43
  dailySpendLimitNanos: v.optional(v.number()),
25
44
  monthlySpendLimitNanos: v.optional(v.number()),
@@ -109,6 +128,14 @@ export default defineSchema({
109
128
  tags: v.optional(v.array(vTag)),
110
129
  model: v.string(),
111
130
  // pessimistic holds placed at start; reconciled to actual on settle
131
+ heldBucketIds: v.optional(v.array(v.id("buckets"))),
132
+ reservationDay: v.optional(v.string()),
133
+ reservationMonth: v.optional(v.string()),
134
+ reservationReleased: v.optional(v.boolean()),
135
+ reservationExpired: v.optional(v.boolean()),
136
+ contentPurged: v.optional(v.boolean()),
137
+ expiresAt: v.optional(v.number()),
138
+ finishedAt: v.optional(v.number()),
112
139
  estimatedNanos: v.optional(v.number()),
113
140
  estimatedTokens: v.optional(v.number()),
114
141
  // true when the model had no known/override price and was charged the
@@ -146,6 +173,9 @@ export default defineSchema({
146
173
  })
147
174
  .index("userId", ["userId"])
148
175
  .index("status", ["status"])
176
+ .index("status_expires", ["status", "expiresAt"])
177
+ .index("retention", ["reservationExpired", "settled"])
178
+ .index("expired_content", ["reservationExpired", "contentPurged"])
149
179
  .index("rerunOf", ["rerunOf"])
150
180
  .index("actionName", ["actionName"])
151
181
  .index("settled", ["settled"]),