@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.
@@ -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,15 +470,18 @@ 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
- await ctx.db.insert("requests", {
484
+ const requestId = await ctx.db.insert("requests", {
444
485
  userId: args.userId,
445
486
  actionName: args.actionName,
446
487
  ...(extraTags.length ? { tags: extraTags } : {}),
@@ -450,6 +491,15 @@ export const startRequest = mutation({
450
491
  status: "blocked" as const,
451
492
  error: reason,
452
493
  });
494
+ // Reverse-index the blocked attempt too, so tag-filtered request logs
495
+ // show rejections alongside admitted traffic.
496
+ for (const t of extraTags) {
497
+ await ctx.db.insert("requestTags", {
498
+ dimension: t.dimension,
499
+ value: t.value,
500
+ requestId,
501
+ });
502
+ }
453
503
  }
454
504
  return { allowed: false as const, code, reason };
455
505
  };
@@ -488,7 +538,7 @@ export const startRequest = mutation({
488
538
  const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
489
539
  const buckets: Doc<"buckets">[] = [];
490
540
  for (const t of bucketTags) {
491
- buckets.push(await getOrCreateBucket(ctx, t.dimension, t.value));
541
+ buckets.push(await admissionBucket(ctx, t.dimension, t.value));
492
542
  }
493
543
 
494
544
  // A hard block on ANY bucket rejects the request. The user dimension's block
@@ -518,47 +568,16 @@ export const startRequest = mutation({
518
568
  }
519
569
  }
520
570
 
521
- // Enforce the rolling 60-second rate limit on every configured dimension.
522
- // User/action requests use their first-class indexes; custom dimensions use
523
- // the requestTags reverse index. All reads are bounded by the configured
524
- // limit (plus a small allowance for persisted blocked attempts).
525
- 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.
526
574
  for (const b of buckets) {
527
575
  const limit = b.requestsPerMinute;
528
576
  if (limit === undefined) continue;
529
-
530
- let recentCount: number;
531
- if (b.dimension === USER_DIM) {
532
- const recent = await ctx.db
533
- .query("requests")
534
- .withIndex("userId", (q) =>
535
- q.eq("userId", b.value).gt("_creationTime", rateCutoff)
536
- )
537
- .take(limit + 50);
538
- recentCount = recent.filter((r) => r.status !== "blocked").length;
539
- } else if (b.dimension === ACTION_DIM) {
540
- const recent = await ctx.db
541
- .query("requests")
542
- .withIndex("actionName", (q) =>
543
- q.eq("actionName", b.value).gt("_creationTime", rateCutoff)
544
- )
545
- .take(limit + 50);
546
- recentCount = recent.filter((r) => r.status !== "blocked").length;
547
- } else {
548
- recentCount = (
549
- await ctx.db
550
- .query("requestTags")
551
- .withIndex("dim_value", (q) =>
552
- q
553
- .eq("dimension", b.dimension)
554
- .eq("value", b.value)
555
- .gt("_creationTime", rateCutoff)
556
- )
557
- .take(limit)
558
- ).length;
559
- }
560
-
561
- if (recentCount >= limit) {
577
+ const ok = limit > 0 && (await requestRateLimiter.check(
578
+ ctx, "requests", requestRateOptions(b)
579
+ )).ok;
580
+ if (!ok) {
562
581
  const code = b.dimension === USER_DIM ? "rate_limit" : `${b.dimension}_rate_limit`;
563
582
  return reject(
564
583
  code,
@@ -622,9 +641,8 @@ export const startRequest = mutation({
622
641
  // committed + reserved + estimate <= cap. The ONE difference is the holder:
623
642
  // per-bucket caps reserve on a single row (an atomic check-and-reserve),
624
643
  // while the global holder is a sharded counter for
625
- // throughput its committed total is read as an eventually-consistent sum
626
- // with no cross-request reservation, so a hard global cap can overshoot by a
627
- // 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
628
646
  // trade for a deployment-wide killswitch; it's the only approximate scope.
629
647
  if (
630
648
  settings &&
@@ -664,6 +682,18 @@ export const startRequest = mutation({
664
682
  notices.push(...globalEval.notices);
665
683
  }
666
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
+
667
697
  // Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
668
698
  // uncapped bucket's row here would serialize every request that shares it
669
699
  // (e.g. all callers of one action, or every request in one env); with no cap
@@ -698,6 +728,10 @@ export const startRequest = mutation({
698
728
  rerunOf: args.rerunOf,
699
729
  ...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
700
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,
701
735
  estimatedNanos: est.cost,
702
736
  estimatedTokens: est.tokens,
703
737
  ...(priceInfo.known ? {} : { unpricedModel: true }),
@@ -739,14 +773,9 @@ export const finishRequest = mutation({
739
773
  const request = await ctx.db.get(args.requestId);
740
774
  if (!request) throw new Error("Unknown request");
741
775
 
742
- // Exactly-once settlement. A request that already reached a terminal state
743
- // finished normally, or expired by the reconciler's stale sweep — must
744
- // not be settled again. Without this, a merely-slow request that the sweep
745
- // already folded would be re-opened and folded a SECOND time when it
746
- // finally completes: totals double-count and the reservation is released
747
- // twice, dropping the reserved pool below reality and letting the atomic
748
- // check-and-reserve admit requests it should block.
749
- 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) {
750
779
  return { costNanos: request.costNanos ?? 0 };
751
780
  }
752
781
 
@@ -777,6 +806,9 @@ export const finishRequest = mutation({
777
806
  // orphaned in "pending" even if the totals update below fails and retries.
778
807
  await ctx.db.patch(args.requestId, {
779
808
  status: args.error ? "error" : "success",
809
+ reservationExpired: false,
810
+ expiresAt: undefined,
811
+ finishedAt: Date.now(),
780
812
  responseText: args.responseText,
781
813
  error: args.error,
782
814
  promptTokens,
@@ -797,62 +829,66 @@ export const finishRequest = mutation({
797
829
  },
798
830
  });
799
831
 
800
- // Fold one finished request into every attributed bucket's running totals,
801
- // releasing its reservation. Idempotent: guarded by `settled` so the scheduler
802
- // 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.
803
858
  async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
804
859
  if (!req || req.settled !== false) return;
860
+ await releaseReservation(ctx, req);
805
861
  const actual = req.costNanos ?? 0;
806
- const estCost = req.estimatedNanos ?? 0;
807
862
  const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
808
- const estTokens = req.estimatedTokens ?? 0;
809
- const today = dayStamp();
810
- const month = monthStamp();
811
-
812
- // Accrue into every attributed bucket (user, action, and each tag) — capped
813
- // or not. Buckets that never held a reservation have their reserved fields
814
- // clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
815
- // Also write the durable per-(bucket, day/month) usage rows that survive
816
- // 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);
817
866
  for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
818
867
  const b = await getOrCreateBucket(ctx, t.dimension, t.value);
819
- const sameDay = b.dayStamp === today;
820
- 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;
821
872
  await ctx.db.patch(b._id, {
822
873
  totalSpendNanos: b.totalSpendNanos + actual,
823
874
  totalRequests: b.totalRequests + 1,
824
875
  totalTokens: b.totalTokens + tokens,
825
- dayStamp: today,
826
- monthStamp: month,
827
- spendTodayNanos: (sameDay ? b.spendTodayNanos : 0) + actual,
828
- tokensToday: (sameDay ? b.tokensToday ?? 0 : 0) + tokens,
829
- spendThisMonthNanos: (sameMonth ? b.spendThisMonthNanos ?? 0 : 0) + actual,
830
- tokensThisMonth: (sameMonth ? b.tokensThisMonth ?? 0 : 0) + tokens,
831
- reservedTodayNanos: Math.max(0, (sameDay ? b.reservedTodayNanos ?? 0 : 0) - estCost),
832
- reservedMonthNanos: Math.max(0, (sameMonth ? b.reservedMonthNanos ?? 0 : 0) - estCost),
833
- reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
834
- reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
835
- reservedMonthTokens: Math.max(0, (sameMonth ? b.reservedMonthTokens ?? 0 : 0) - estTokens),
836
- reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
837
- 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 } : {}),
838
884
  });
839
- 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);
840
886
  await addUsage(ctx, t.dimension, t.value, "month", month, actual, tokens, 1);
841
887
  }
842
-
843
- // Deployment-wide totals via the sharded counter (only when a global cap is
844
- // configured — otherwise skip the writes entirely). Distributed across shards,
845
- // so this does not serialize on a single row.
888
+ // Reporting is independent of whether enforcement is configured.
846
889
  if (actual > 0) {
847
- const settings = await getSettings(ctx);
848
- if (
849
- settings &&
850
- (settings.globalDailySpendLimitNanos !== undefined ||
851
- settings.globalLifetimeSpendLimitNanos !== undefined)
852
- ) {
853
- await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
854
- await globalSpend.add(ctx, globalDayKey(today), actual);
855
- }
890
+ await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
891
+ await globalSpend.add(ctx, globalDayKey(day), actual);
856
892
  }
857
893
  await ctx.db.patch(req._id, { settled: true });
858
894
  }
@@ -883,29 +919,23 @@ export const reconcile = internalMutation({
883
919
  .take(200);
884
920
  for (const req of toFold) await foldOne(ctx, req);
885
921
 
886
- // Reap dead reservations: pending rows older than the default floor, but a
887
- // per-request reserveTtlMs (set for long async jobs like video) holds the
888
- // reservation until *its* deadline so a still-running job isn't reaped.
889
- const cutoff = Date.now() - STALE_PENDING_MS;
890
- const candidates = await ctx.db
891
- .query("requests")
892
- .withIndex("status", (q) =>
893
- q.eq("status", "pending").lt("_creationTime", cutoff)
894
- )
895
- .take(200);
896
- 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);
897
931
  for (const req of candidates) {
898
- const ttl = req.reserveTtlMs ?? STALE_PENDING_MS;
899
- if (Date.now() - req._creationTime <= ttl) continue; // still within its window
932
+ await releaseReservation(ctx, req);
900
933
  await ctx.db.patch(req._id, {
901
- status: "error",
902
- error: "Timed out before settling; reservation released",
903
- costNanos: 0,
904
- settled: false,
934
+ status: "error", error: "Reservation expired; awaiting final usage",
935
+ reservationExpired: true, expiresAt: undefined, settled: true,
905
936
  });
906
- await foldOne(ctx, await ctx.db.get(req._id));
907
- expired++;
908
937
  }
938
+ const expired = candidates.length;
909
939
 
910
940
  // Retention: delete terminal, fully-accounted request rows past the window.
911
941
  const settings = await getSettings(ctx);
@@ -913,20 +943,26 @@ export const reconcile = internalMutation({
913
943
  let purged = 0;
914
944
  if (retentionMs > 0) {
915
945
  const retentionCutoff = Date.now() - retentionMs;
916
- const old = await ctx.db
917
- .query("requests")
918
- .withIndex("by_creation_time", (q) =>
919
- q.lt("_creationTime", retentionCutoff)
920
- )
921
- .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));
922
956
  for (const req of old) {
923
- // Only rows that are done and accounted: folded (settled === true) or a
924
- // blocked attempt (never needs folding). Never a pending/unfolded row.
925
- if (req.settled === true || req.status === "blocked") {
926
- await deleteRequestTags(ctx, req._id);
927
- await ctx.db.delete(req._id);
928
- purged++;
929
- }
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 });
930
966
  }
931
967
  }
932
968
  return { folded: toFold.length, expired, purged };
@@ -1079,9 +1115,14 @@ export const setBucketLimits = mutation({
1079
1115
  },
1080
1116
  returns: v.null(),
1081
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
+ }
1082
1122
  const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
1083
1123
  const { dimension: _d, value: _v, ...limits } = args;
1084
1124
  await ctx.db.patch(bucket._id, limits);
1125
+ await syncPolicy(ctx, (await ctx.db.get(bucket._id))!);
1085
1126
  return null;
1086
1127
  },
1087
1128
  });
@@ -1223,11 +1264,23 @@ export const deleteBucket = mutation({
1223
1264
  return { deletedThisBatch: rows.length, done: false };
1224
1265
  }
1225
1266
  const bucket = await getBucketDoc(ctx, dimension, value);
1226
- 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
+ }
1227
1274
  return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
1228
1275
  }
1229
1276
  const bucket = await getBucketDoc(ctx, dimension, value);
1230
- 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
+ }
1231
1284
  return { deletedThisBatch: bucket ? 1 : 0, done: true };
1232
1285
  },
1233
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"]),