@convex-dev/ai-budget 0.0.2-alpha.17 → 0.0.2-alpha.19

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.
@@ -46,6 +46,10 @@ function assertFraction(n: number | undefined, name: string) {
46
46
  // mapping NaN/Infinity/garbage to 0 rather than poisoning downstream totals.
47
47
  const safeCount = (n: number | undefined) =>
48
48
  Number.isFinite(n) ? Math.max(0, Math.floor(n as number)) : 0;
49
+ // Treat a non-finite stored accounting field as 0, so a bucket that was poisoned
50
+ // before validation existed self-heals on its next reserve/release instead of
51
+ // staying NaN forever.
52
+ const fin = (n: number | undefined) => (Number.isFinite(n) ? (n as number) : 0);
49
53
 
50
54
  // Built-in attribution dimensions. `user` and `action` are always populated
51
55
  // from a request's userId/actionName; apps can add any other dimensions
@@ -103,6 +107,16 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
103
107
  // audit table — and the sensitive content in it — from growing without bound.
104
108
  // Override per-deployment via setRetention.
105
109
  const DEFAULT_RETENTION_MS = 60 * 60 * 1000; // 1 hour
110
+ // Reconciliation runs as small, independently-rescheduling phases. Keeping the
111
+ // batch small bounds the bytes read per transaction (each request row can carry
112
+ // prompts/responses) so a burst can't push one phase past Convex's 8 MiB / 16k-
113
+ // doc read limit and stall the whole reconciler. A phase that fills its batch
114
+ // reschedules itself immediately, so throughput still scales with backlog.
115
+ const RECONCILE_BATCH = 50;
116
+ // Keep an expired billing tombstone (content already purged) this long so a very
117
+ // late provider charge can still land against it, then delete it. A finish after
118
+ // deletion is a graceful no-op.
119
+ const LATE_SETTLE_HORIZON_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
106
120
 
107
121
  const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
108
122
  const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
@@ -118,12 +132,17 @@ const CACHE_DISCOUNT = 0.1;
118
132
  // bills real money. Charging the conservative max instead keeps the caps honest
119
133
  // (over-counting is the safe direction); admins can pin an exact price via
120
134
  // setPrice, which also clears the `unpricedModel` flag on future requests.
135
+ // Seed with a true frontier ceiling ($20/$100 per Mtok), not just the max of the
136
+ // small built-in table — otherwise premium models (Opus-class $15/$75, etc.) not
137
+ // in the table would be under-counted several-fold whenever the gateway's
138
+ // authoritative cost isn't available. Over-counting an unpriced model is the safe
139
+ // direction; admins pin the exact rate with setPrice.
121
140
  const CONSERVATIVE_PRICE = Object.values(DEFAULT_PRICES).reduce(
122
141
  (m, p) => ({
123
142
  input: Math.max(m.input, p.input),
124
143
  output: Math.max(m.output, p.output),
125
144
  }),
126
- { input: 0, output: 0 }
145
+ { input: 20_000_000_000, output: 100_000_000_000 }
127
146
  );
128
147
 
129
148
  async function getPrice(ctx: MutationCtx, model: string) {
@@ -297,7 +316,8 @@ function sanitizeExtraTags(
297
316
  function evaluateCaps(o: {
298
317
  label: string;
299
318
  name: string;
300
- enforcement: "hard" | "soft";
319
+ // "approximate" is the global killswitch mode; it blocks like "hard" here.
320
+ enforcement: "hard" | "soft" | "approximate";
301
321
  warnAtPct?: number;
302
322
  estCost: number;
303
323
  estTokens: number;
@@ -307,6 +327,10 @@ function evaluateCaps(o: {
307
327
  reservedSpendMonth: number;
308
328
  totalSpend: number;
309
329
  reservedSpendTotal: number;
330
+ // Manual credits (subtracted from spend so a credit grants headroom).
331
+ creditsToday?: number;
332
+ creditsThisMonth?: number;
333
+ creditsTotal?: number;
310
334
  tokensToday: number;
311
335
  reservedTokensToday: number;
312
336
  tokensThisMonth: number;
@@ -327,9 +351,9 @@ function evaluateCaps(o: {
327
351
  // { code, projected usage (incl. this estimate), cap, human window label,
328
352
  // whether it's a money cap (formatted as $), spend? for notices }
329
353
  const checks = [
330
- { w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost, cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
331
- { w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost, cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
332
- { w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost, cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
354
+ { w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost - (o.creditsToday ?? 0), cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
355
+ { w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost - (o.creditsThisMonth ?? 0), cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
356
+ { w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost - (o.creditsTotal ?? 0), cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
333
357
  { w: "daily_token_limit", used: o.tokensToday + o.reservedTokensToday + o.estTokens, cap: o.dailyTokenLimit, label: "daily token limit", money: false },
334
358
  { w: "monthly_token_limit", used: o.tokensThisMonth + o.reservedTokensMonth + o.estTokens, cap: o.monthlyTokenLimit, label: "monthly token limit", money: false },
335
359
  { w: "lifetime_token_limit", used: o.totalTokens + o.reservedTokensTotal + o.estTokens, cap: o.lifetimeTokenLimit, label: "lifetime token limit", money: false },
@@ -500,7 +524,15 @@ export const startRequest = mutation({
500
524
  if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
501
525
  throw new Error("reserveTtlMs must be finite and nonnegative");
502
526
  }
527
+ // Infinity/NaN here is catastrophic: it's reserved onto the bucket, and a
528
+ // later release computes `Infinity - Infinity = NaN`, leaving the reserved
529
+ // fields NaN forever — after which every `used > cap` check is `NaN > cap`
530
+ // (false) and the bucket admits unlimited spend. Reject it up front.
531
+ assertAmount(args.estimatedCostNanos, "estimatedCostNanos");
503
532
  const extraTags = sanitizeExtraTags(args.tags);
533
+ // Set from settings below; when false we persist metadata but no prompt
534
+ // content (a deployment that opts out of storing prompts entirely).
535
+ let storeContent = true;
504
536
  // Record the blocked attempt and return a rejection (throwing would roll
505
537
  // back the record). `persist` is false for the high-frequency-by-design
506
538
  // rejections (rate limit, blocked user) that a client retries in a tight
@@ -513,7 +545,7 @@ export const startRequest = mutation({
513
545
  actionName: args.actionName,
514
546
  ...(extraTags.length ? { tags: extraTags } : {}),
515
547
  model: args.model,
516
- messages: args.messages,
548
+ messages: storeContent ? args.messages : [],
517
549
  rerunOf: args.rerunOf,
518
550
  status: "blocked" as const,
519
551
  error: reason,
@@ -545,6 +577,7 @@ export const startRequest = mutation({
545
577
 
546
578
  // Model allow/deny policy (component-wide).
547
579
  const settings = await getSettings(ctx);
580
+ storeContent = settings?.storeContent !== false;
548
581
  const defaultWarnAtPct = settings?.defaultWarnAtPct;
549
582
  if (settings) {
550
583
  const mode = settings.modelMode ?? "open";
@@ -558,6 +591,14 @@ export const startRequest = mutation({
558
591
  if (mode === "denylist" && list.includes(args.model)) {
559
592
  return reject("model_denied", `Model "${args.model}" is denied`);
560
593
  }
594
+ // Optionally refuse models with no known/override price rather than
595
+ // charging the conservative fallback (which under-counts premium models).
596
+ if (settings.allowUnpricedModels === false && !priceInfo.known) {
597
+ return reject(
598
+ "model_unpriced",
599
+ `Model "${args.model}" has no configured price; set one with setPrice or allow unpriced models`
600
+ );
601
+ }
561
602
  }
562
603
 
563
604
  // Fetch/create every bucket this request is attributed to (user, action,
@@ -636,6 +677,9 @@ export const startRequest = mutation({
636
677
  reservedSpendMonth: sameMonth ? b.reservedMonthNanos ?? 0 : 0,
637
678
  totalSpend: b.totalSpendNanos,
638
679
  reservedSpendTotal: b.reservedTotalNanos ?? 0,
680
+ creditsToday: sameDay ? b.creditsTodayNanos ?? 0 : 0,
681
+ creditsThisMonth: sameMonth ? b.creditsThisMonthNanos ?? 0 : 0,
682
+ creditsTotal: b.creditsNanos ?? 0,
639
683
  tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
640
684
  reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
641
685
  tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
@@ -671,42 +715,26 @@ export const startRequest = mutation({
671
715
  // throughput. The sum is transactional, but excludes unsettled usage and
672
716
  // has no cross-request reservation, so a hard global cap can overshoot. That's the deliberate consistency/throughput
673
717
  // trade for a deployment-wide killswitch; it's the only approximate scope.
718
+ // H4: don't read the sharded counter here — that per-admission read
719
+ // contended with every fold that writes it. The reconciler compares the
720
+ // total to the cap out-of-band and records trip flags on `settings`;
721
+ // admission just reads those (already-loaded) flags. The killswitch lag is
722
+ // bounded by the reconcile interval, which is the point of "approximate".
674
723
  if (
675
724
  settings &&
676
- (settings.globalDailySpendLimitNanos !== undefined ||
677
- settings.globalLifetimeSpendLimitNanos !== undefined)
725
+ (settings.globalTrippedDaily || settings.globalTrippedLifetime)
678
726
  ) {
679
- const globalEval = evaluateCaps({
680
- label: "global",
681
- name: "deployment",
682
- enforcement: settings.globalEnforcement ?? "hard",
683
- warnAtPct: defaultWarnAtPct,
684
- estCost: est.cost,
685
- estTokens: est.tokens,
686
- spendToday: await globalSpend.count(ctx, globalDayKey(today)),
687
- reservedSpendToday: 0, // sharded holder: no cross-request reservation
688
- spendThisMonth: 0, // global tracks daily + lifetime only
689
- reservedSpendMonth: 0,
690
- totalSpend: await globalSpend.count(ctx, GLOBAL_TOTAL),
691
- reservedSpendTotal: 0,
692
- tokensToday: 0,
693
- reservedTokensToday: 0,
694
- tokensThisMonth: 0,
695
- reservedTokensMonth: 0,
696
- totalTokens: 0,
697
- reservedTokensTotal: 0,
698
- dailySpendLimitNanos: withBump(
699
- settings.globalDailySpendLimitNanos,
700
- settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0
701
- ),
702
- lifetimeSpendLimitNanos: withBump(
703
- settings.globalLifetimeSpendLimitNanos,
704
- settings.globalLifetimeBumpNanos
705
- ),
706
- });
707
- if (globalEval.hard) return reject(globalEval.hard.code, globalEval.hard.reason);
708
- warnings.push(...globalEval.warnings);
709
- notices.push(...globalEval.notices);
727
+ const enforcement = settings.globalEnforcement ?? "approximate";
728
+ if (enforcement === "soft") {
729
+ warnings.push(`Global spend limit reached for the deployment (allowed — soft)`);
730
+ } else {
731
+ return reject(
732
+ "global_spend_limit",
733
+ "Global spend limit reached for the deployment"
734
+ );
735
+ }
736
+ } else if (settings?.globalNearLimit) {
737
+ notices.push("Deployment approaching its global spend limit");
710
738
  }
711
739
 
712
740
  // Consume only after every admission check succeeds, in the same
@@ -757,7 +785,7 @@ export const startRequest = mutation({
757
785
  actionName: args.actionName,
758
786
  ...(extraTags.length ? { tags: extraTags } : {}),
759
787
  model: args.model,
760
- messages: args.messages,
788
+ messages: storeContent ? args.messages : [],
761
789
  rerunOf: args.rerunOf,
762
790
  ...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
763
791
  status: "pending",
@@ -825,18 +853,27 @@ export const finishRequest = mutation({
825
853
  // tool fees); otherwise price from tokens — discounting the cached
826
854
  // (prompt-cache-read) slice — plus any server-tool per-call fees. Require it
827
855
  // finite: +Infinity passes a bare `>= 0` and would corrupt the totals.
856
+ const settings = await getSettings(ctx);
857
+ const storeContent = settings?.storeContent !== false;
828
858
  let costNanos: number;
829
859
  if (args.costNanos !== undefined && Number.isFinite(args.costNanos) && args.costNanos >= 0) {
830
860
  costNanos = Math.round(args.costNanos);
831
861
  } else {
832
- const settings = await getSettings(ctx);
833
- costNanos =
862
+ const priced =
834
863
  settleCost(
835
864
  promptTokens,
836
865
  cachedTokens,
837
866
  completionTokens,
838
867
  await getPrice(ctx, request.model)
839
868
  ) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
869
+ // Fail closed: if the settle carried NO usable cost signal — no tokens and
870
+ // no priced server-tool fee (an unpriced tool like `video_seconds`, or a
871
+ // bare settle()) — fall back to the reserved estimate rather than recording
872
+ // $0. Known-cost calls (image/video/audio) set estimatedCostNanos at
873
+ // reserve time precisely so this floor is their real cost. A caller that
874
+ // truly wants $0 passes an explicit authoritative costNanos: 0 above.
875
+ const noSignal = promptTokens === 0 && completionTokens === 0 && priced === 0;
876
+ costNanos = noSignal ? (request.estimatedNanos ?? 0) : priced;
840
877
  }
841
878
 
842
879
  // Durable write to the request's OWN row only — uncontended, so it always
@@ -847,7 +884,7 @@ export const finishRequest = mutation({
847
884
  reservationExpired: false,
848
885
  expiresAt: undefined,
849
886
  finishedAt: Date.now(),
850
- responseText: args.responseText,
887
+ responseText: storeContent ? args.responseText : undefined,
851
888
  error: args.error,
852
889
  promptTokens,
853
890
  completionTokens,
@@ -879,13 +916,13 @@ async function releaseReservation(ctx: MutationCtx, req: Doc<"requests">) {
879
916
  const b = await getBucketDoc(ctx, t.dimension, t.value);
880
917
  if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b))) continue;
881
918
  await ctx.db.patch(b._id, {
882
- reservedTodayNanos: Math.max(0, (b.reservedTodayNanos ?? 0) - (b.dayStamp === day ? cost : 0)),
883
- reservedMonthNanos: Math.max(0, (b.reservedMonthNanos ?? 0) - (b.monthStamp === month ? cost : 0)),
884
- reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - cost),
885
- reservedTodayTokens: Math.max(0, (b.reservedTodayTokens ?? 0) - (b.dayStamp === day ? tokens : 0)),
886
- reservedMonthTokens: Math.max(0, (b.reservedMonthTokens ?? 0) - (b.monthStamp === month ? tokens : 0)),
887
- reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - tokens),
888
- pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
919
+ reservedTodayNanos: Math.max(0, fin(b.reservedTodayNanos) - (b.dayStamp === day ? cost : 0)),
920
+ reservedMonthNanos: Math.max(0, fin(b.reservedMonthNanos) - (b.monthStamp === month ? cost : 0)),
921
+ reservedTotalNanos: Math.max(0, fin(b.reservedTotalNanos) - cost),
922
+ reservedTodayTokens: Math.max(0, fin(b.reservedTodayTokens) - (b.dayStamp === day ? tokens : 0)),
923
+ reservedMonthTokens: Math.max(0, fin(b.reservedMonthTokens) - (b.monthStamp === month ? tokens : 0)),
924
+ reservedTotalTokens: Math.max(0, fin(b.reservedTotalTokens) - tokens),
925
+ pendingCount: Math.max(0, fin(b.pendingCount) - 1),
889
926
  });
890
927
  }
891
928
  await ctx.db.patch(req._id, { reservationReleased: true });
@@ -943,29 +980,98 @@ export const foldTotals = internalMutation({
943
980
  // Backstop for both failure modes: folds finished requests whose scheduled fold
944
981
  // lost the retry race, and releases reservations for requests that never
945
982
  // settled (their action crashed). Runs on a cron.
983
+ // Cron entry: kick off each reconciliation phase as its OWN transaction so a
984
+ // failure in one (e.g. an oversized retention scan) can't stall the others, and
985
+ // so the hot fold path doesn't share a transaction with retention. Each phase
986
+ // self-reschedules while it has a full batch of backlog.
946
987
  export const reconcile = internalMutation({
947
988
  args: {},
948
- returns: v.object({
949
- folded: v.number(),
950
- expired: v.number(),
951
- purged: v.number(),
952
- }),
989
+ returns: v.null(),
990
+ handler: async (ctx) => {
991
+ await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
992
+ await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
993
+ await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
994
+ await ctx.scheduler.runAfter(0, internal.lib.globalPhase, {});
995
+ return null;
996
+ },
997
+ });
998
+
999
+ // H4: compute the deployment-wide global-cap trip flags out-of-band so admission
1000
+ // never reads the sharded counter on its hot path. One cheap counter read here
1001
+ // per interval; admission then consults the flags on `settings`.
1002
+ export const globalPhase = internalMutation({
1003
+ args: {},
1004
+ returns: v.null(),
1005
+ handler: async (ctx) => {
1006
+ const s = await getSettings(ctx);
1007
+ if (!s) return null;
1008
+ const hasCap =
1009
+ s.globalDailySpendLimitNanos !== undefined ||
1010
+ s.globalLifetimeSpendLimitNanos !== undefined;
1011
+ if (!hasCap) {
1012
+ // Clear stale flags when no global cap is configured.
1013
+ if (s.globalTrippedDaily || s.globalTrippedLifetime || s.globalNearLimit)
1014
+ await ctx.db.patch(s._id, {
1015
+ globalTrippedDaily: false,
1016
+ globalTrippedLifetime: false,
1017
+ globalNearLimit: false,
1018
+ });
1019
+ return null;
1020
+ }
1021
+ const today = dayStamp();
1022
+ const dailyCap = withBump(
1023
+ s.globalDailySpendLimitNanos,
1024
+ s.globalBumpDayStamp === today ? s.globalDailyBumpNanos : 0
1025
+ );
1026
+ const lifetimeCap = withBump(
1027
+ s.globalLifetimeSpendLimitNanos,
1028
+ s.globalLifetimeBumpNanos
1029
+ );
1030
+ const spentToday = await globalSpend.count(ctx, globalDayKey(today));
1031
+ const spentTotal = await globalSpend.count(ctx, GLOBAL_TOTAL);
1032
+ const pct = s.defaultWarnAtPct;
1033
+ const near = (spent: number, cap?: number) =>
1034
+ cap !== undefined && pct !== undefined && pct > 0 && pct < 1 && spent >= pct * cap;
1035
+ await ctx.db.patch(s._id, {
1036
+ globalTrippedDaily: dailyCap !== undefined && spentToday >= dailyCap,
1037
+ globalTrippedLifetime: lifetimeCap !== undefined && spentTotal >= lifetimeCap,
1038
+ globalNearLimit: near(spentToday, dailyCap) || near(spentTotal, lifetimeCap),
1039
+ });
1040
+ return null;
1041
+ },
1042
+ });
1043
+
1044
+ // Fold finished-but-unfolded requests whose scheduled fold lost the OCC race.
1045
+ export const foldPhase = internalMutation({
1046
+ args: {},
1047
+ returns: v.object({ folded: v.number() }),
953
1048
  handler: async (ctx) => {
954
1049
  const toFold = await ctx.db
955
1050
  .query("requests")
956
1051
  .withIndex("settled", (q) => q.eq("settled", false))
957
- .take(200);
1052
+ .take(RECONCILE_BATCH);
958
1053
  for (const req of toFold) await foldOne(ctx, req);
1054
+ if (toFold.length === RECONCILE_BATCH)
1055
+ await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
1056
+ return { folded: toFold.length };
1057
+ },
1058
+ });
959
1059
 
1060
+ // Release reservations for requests that never settled (their action crashed),
1061
+ // and lazily backfill deadlines on legacy pending rows.
1062
+ export const expirePhase = internalMutation({
1063
+ args: {},
1064
+ returns: v.object({ expired: v.number() }),
1065
+ handler: async (ctx) => {
960
1066
  // Lazily migrate old pending rows in bounded batches. Once indexed, long
961
1067
  // TTL jobs cannot hide expired jobs behind them in creation-time order.
962
1068
  const legacy = await ctx.db.query("requests").withIndex("status_expires", q =>
963
- q.eq("status", "pending").eq("expiresAt", undefined)).take(200);
1069
+ q.eq("status", "pending").eq("expiresAt", undefined)).take(RECONCILE_BATCH);
964
1070
  for (const req of legacy) {
965
1071
  await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
966
1072
  }
967
1073
  const candidates = await ctx.db.query("requests").withIndex("status_expires", q =>
968
- q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(200);
1074
+ q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(RECONCILE_BATCH);
969
1075
  for (const req of candidates) {
970
1076
  await releaseReservation(ctx, req);
971
1077
  await ctx.db.patch(req._id, {
@@ -973,37 +1079,58 @@ export const reconcile = internalMutation({
973
1079
  reservationExpired: true, expiresAt: undefined, settled: true,
974
1080
  });
975
1081
  }
976
- const expired = candidates.length;
1082
+ if (legacy.length === RECONCILE_BATCH || candidates.length === RECONCILE_BATCH)
1083
+ await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
1084
+ return { expired: candidates.length };
1085
+ },
1086
+ });
977
1087
 
978
- // Retention: delete terminal, fully-accounted request rows past the window.
1088
+ // Delete terminal, fully-accounted request rows past the retention window; purge
1089
+ // content from expired tombstones; and finally delete tombstones past the
1090
+ // late-settle horizon so they can't accumulate forever.
1091
+ export const retentionPhase = internalMutation({
1092
+ args: {},
1093
+ returns: v.object({ purged: v.number() }),
1094
+ handler: async (ctx) => {
979
1095
  const settings = await getSettings(ctx);
980
1096
  const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
1097
+ if (retentionMs <= 0) return { purged: 0 };
1098
+ const retentionCutoff = Date.now() - retentionMs;
981
1099
  let purged = 0;
982
- if (retentionMs > 0) {
983
- const retentionCutoff = Date.now() - retentionMs;
984
- // Query eligible states directly. Long-lived pending jobs and expired
985
- // billing tombstones must not repeatedly occupy the front of a scan.
986
- const old = [];
987
- for (const expiredFlag of [undefined, false]) {
988
- old.push(...await ctx.db.query("requests").withIndex("retention", q =>
989
- q.eq("reservationExpired", expiredFlag).eq("settled", true)
990
- .lt("_creationTime", retentionCutoff)).take(200));
991
- }
992
- old.push(...await ctx.db.query("requests").withIndex("status", q =>
993
- q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
994
- for (const req of old) {
1100
+ let more = false;
1101
+ const sweep = async (rows: Doc<"requests">[]) => {
1102
+ for (const req of rows) {
995
1103
  await deleteRequestTags(ctx, req._id);
996
1104
  await ctx.db.delete(req._id);
997
1105
  purged++;
998
1106
  }
999
- const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q =>
1000
- q.eq("reservationExpired", true).eq("contentPurged", undefined)
1001
- .lt("_creationTime", retentionCutoff)).take(200);
1002
- for (const req of expiredContent) {
1003
- await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
1004
- }
1107
+ if (rows.length === RECONCILE_BATCH) more = true;
1108
+ };
1109
+ // Settled, non-expired terminal rows past the window.
1110
+ for (const expiredFlag of [undefined, false] as const) {
1111
+ await sweep(await ctx.db.query("requests").withIndex("retention", q =>
1112
+ q.eq("reservationExpired", expiredFlag).eq("settled", true)
1113
+ .lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
1005
1114
  }
1006
- return { folded: toFold.length, expired, purged };
1115
+ // Blocked attempts past the window.
1116
+ await sweep(await ctx.db.query("requests").withIndex("status", q =>
1117
+ q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
1118
+ // Expired billing tombstones past the late-settle horizon (content already
1119
+ // gone). Without this they'd live forever (one per crashed request).
1120
+ const tombstoneCutoff = Date.now() - Math.max(retentionMs, LATE_SETTLE_HORIZON_MS);
1121
+ await sweep(await ctx.db.query("requests").withIndex("retention", q =>
1122
+ q.eq("reservationExpired", true).eq("settled", true)
1123
+ .lt("_creationTime", tombstoneCutoff)).take(RECONCILE_BATCH));
1124
+ // Strip PII from expired tombstones still inside the horizon.
1125
+ const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q =>
1126
+ q.eq("reservationExpired", true).eq("contentPurged", undefined)
1127
+ .lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH);
1128
+ for (const req of expiredContent) {
1129
+ await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
1130
+ }
1131
+ if (expiredContent.length === RECONCILE_BATCH) more = true;
1132
+ if (more) await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
1133
+ return { purged };
1007
1134
  },
1008
1135
  });
1009
1136
 
@@ -1226,15 +1353,25 @@ export const adjustBucket = mutation({
1226
1353
  const dSame = b.dayStamp === today;
1227
1354
  const mSame = b.monthStamp === month;
1228
1355
  const dt = tokens ?? 0;
1356
+ // Gross-plus-credits ledger: a positive delta is a real extra charge (adds
1357
+ // to GROSS spend); a negative delta is a credit/refund that accrues to a
1358
+ // separate credits balance and NEVER reduces gross spend — so gross spend
1359
+ // and durable history stay consistent, and net = gross - credits. Credits
1360
+ // grant headroom because admission subtracts them from the cap check.
1361
+ const debit = deltaNanos > 0 ? deltaNanos : 0;
1362
+ const credit = deltaNanos < 0 ? -deltaNanos : 0;
1229
1363
  await ctx.db.patch(b._id, {
1230
- totalSpendNanos: Math.max(0, b.totalSpendNanos + deltaNanos),
1364
+ totalSpendNanos: b.totalSpendNanos + debit,
1231
1365
  totalTokens: Math.max(0, b.totalTokens + dt),
1232
1366
  dayStamp: today,
1233
1367
  monthStamp: month,
1234
- spendTodayNanos: Math.max(0, (dSame ? b.spendTodayNanos : 0) + deltaNanos),
1368
+ spendTodayNanos: (dSame ? b.spendTodayNanos : 0) + debit,
1235
1369
  tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
1236
- spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
1370
+ spendThisMonthNanos: (mSame ? b.spendThisMonthNanos ?? 0 : 0) + debit,
1237
1371
  tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
1372
+ creditsNanos: (b.creditsNanos ?? 0) + credit,
1373
+ creditsTodayNanos: (dSame ? b.creditsTodayNanos ?? 0 : 0) + credit,
1374
+ creditsThisMonthNanos: (mSame ? b.creditsThisMonthNanos ?? 0 : 0) + credit,
1238
1375
  // Advancing the window here must also clear the OLD window's reserved
1239
1376
  // holds, or an in-flight request from the previous day/month would be
1240
1377
  // treated as reserving against the new window and its later release would
@@ -1243,8 +1380,10 @@ export const adjustBucket = mutation({
1243
1380
  ...(mSame ? {} : { reservedMonthNanos: 0, reservedMonthTokens: 0 }),
1244
1381
  });
1245
1382
  await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
1246
- await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
1247
- await addUsage(ctx, dimension, value, "month", month, deltaNanos, dt, 0);
1383
+ // Durable history tracks GROSS spend only (debits); credits live in the
1384
+ // adjustments log + bucket balance, so usage rollups never go negative.
1385
+ await addUsage(ctx, dimension, value, "day", today, debit, dt, 0);
1386
+ await addUsage(ctx, dimension, value, "month", month, debit, dt, 0);
1248
1387
  return null;
1249
1388
  },
1250
1389
  });
@@ -1291,6 +1430,28 @@ export const setAlertDefaults = mutation({
1291
1430
  },
1292
1431
  });
1293
1432
 
1433
+ // Deployment-wide data/pricing policy. Only the fields you pass change.
1434
+ // - allowUnpricedModels: false rejects models with no configured price under
1435
+ // hard enforcement (default true — charge the conservative fallback).
1436
+ // - storeContent: false stops persisting prompt/response content on request
1437
+ // rows (default true).
1438
+ export const setDeploymentPolicy = mutation({
1439
+ args: {
1440
+ allowUnpricedModels: v.optional(v.boolean()),
1441
+ storeContent: v.optional(v.boolean()),
1442
+ },
1443
+ returns: v.null(),
1444
+ handler: async (ctx, args) => {
1445
+ const patch: Record<string, unknown> = {};
1446
+ if ("allowUnpricedModels" in args) patch.allowUnpricedModels = args.allowUnpricedModels;
1447
+ if ("storeContent" in args) patch.storeContent = args.storeContent;
1448
+ const existing = await getSettings(ctx);
1449
+ if (existing) await ctx.db.patch(existing._id, patch);
1450
+ else await ctx.db.insert("settings", { key: "singleton", ...patch });
1451
+ return null;
1452
+ },
1453
+ });
1454
+
1294
1455
  // Delete a bucket and (for the `user` dimension) all of that user's request
1295
1456
  // rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
1296
1457
  // self-reschedules so it never exceeds the per-transaction document limit.
@@ -1372,7 +1533,7 @@ export const getGlobalStatus = query({
1372
1533
  returns: v.object({
1373
1534
  dailySpendLimitNanos: v.union(v.number(), v.null()),
1374
1535
  lifetimeSpendLimitNanos: v.union(v.number(), v.null()),
1375
- enforcement: v.union(v.literal("hard"), v.literal("soft")),
1536
+ enforcement: v.union(v.literal("approximate"), v.literal("soft")),
1376
1537
  spentTodayNanos: v.number(),
1377
1538
  spentTotalNanos: v.number(),
1378
1539
  // deployment-wide config (surfaced for the admin dashboard)
@@ -1387,7 +1548,7 @@ export const getGlobalStatus = query({
1387
1548
  return {
1388
1549
  dailySpendLimitNanos: s?.globalDailySpendLimitNanos ?? null,
1389
1550
  lifetimeSpendLimitNanos: s?.globalLifetimeSpendLimitNanos ?? null,
1390
- enforcement: s?.globalEnforcement ?? "hard",
1551
+ enforcement: s?.globalEnforcement ?? "approximate",
1391
1552
  spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
1392
1553
  spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
1393
1554
  retentionMs: s?.retentionMs ?? null,
@@ -1405,7 +1566,7 @@ export const setGlobalLimits = mutation({
1405
1566
  dailySpendLimitNanos: v.optional(v.union(v.number(), v.null())),
1406
1567
  lifetimeSpendLimitNanos: v.optional(v.union(v.number(), v.null())),
1407
1568
  enforcement: v.optional(
1408
- v.union(v.literal("hard"), v.literal("soft"), v.null())
1569
+ v.union(v.literal("approximate"), v.literal("soft"), v.null())
1409
1570
  ),
1410
1571
  },
1411
1572
  returns: v.null(),
@@ -59,10 +59,17 @@ export default defineSchema({
59
59
  lifetimeBumpNanos: v.optional(v.number()),
60
60
  bumpDayStamp: v.optional(v.string()),
61
61
  bumpMonthStamp: v.optional(v.string()),
62
- // settled totals (from finished requests)
62
+ // settled GROSS spend (real charges + positive adjustments). Credits never
63
+ // reduce these — they accrue separately below — so gross spend and durable
64
+ // history always agree. Cap checks and "net" use (gross - credits).
63
65
  totalSpendNanos: v.number(),
64
66
  totalRequests: v.number(),
65
67
  totalTokens: v.number(),
68
+ // manual credits (comps/refunds), tracked separately from gross spend and
69
+ // subtracted at admission so a credit grants real headroom under a cap.
70
+ creditsNanos: v.optional(v.number()),
71
+ creditsTodayNanos: v.optional(v.number()),
72
+ creditsThisMonthNanos: v.optional(v.number()),
66
73
  // daily window
67
74
  dayStamp: v.string(),
68
75
  spendTodayNanos: v.number(),
@@ -211,12 +218,23 @@ export default defineSchema({
211
218
  // killswitch; per-bucket concurrent admission is atomic via reserve/settle.
212
219
  globalDailySpendLimitNanos: v.optional(v.number()),
213
220
  globalLifetimeSpendLimitNanos: v.optional(v.number()),
221
+ // "approximate" (default): a best-effort killswitch — it blocks once the
222
+ // sharded total crosses the cap, but with bounded overshoot (no per-request
223
+ // reservation). "soft": warn only. There is deliberately no "hard": a true
224
+ // to-the-dollar ceiling is a per-bucket cap.
214
225
  globalEnforcement: v.optional(
215
- v.union(v.literal("hard"), v.literal("soft"))
226
+ v.union(v.literal("approximate"), v.literal("soft"))
216
227
  ),
217
228
  globalDailyBumpNanos: v.optional(v.number()),
218
229
  globalLifetimeBumpNanos: v.optional(v.number()),
219
230
  globalBumpDayStamp: v.optional(v.string()),
231
+ // H4: the reconciler compares the sharded global total to the cap and sets
232
+ // these, so admission reads ONE settings doc instead of the sharded counter
233
+ // (whose per-admission read contended with every fold). Killswitch lag is
234
+ // bounded by the reconcile interval — fine for a deployment-wide stop.
235
+ globalTrippedDaily: v.optional(v.boolean()),
236
+ globalTrippedLifetime: v.optional(v.boolean()),
237
+ globalNearLimit: v.optional(v.boolean()),
220
238
  // request-row retention window in ms (default 1h); 0 disables sweeping.
221
239
  retentionMs: v.optional(v.number()),
222
240
  // default approaching-limit alert threshold (fraction of a cap) for buckets
@@ -225,5 +243,13 @@ export default defineSchema({
225
243
  // per-call price (nanodollars) overrides for provider server tools, keyed by
226
244
  // tool name (e.g. { web_search: 12_000_000 }). Merged over the defaults.
227
245
  serverToolPrices: v.optional(v.record(v.string(), v.number())),
246
+ // When false, reject requests for a model with no known/override price under
247
+ // hard enforcement (instead of charging the conservative fallback). Default
248
+ // true (charge the fallback, keep the call working).
249
+ allowUnpricedModels: v.optional(v.boolean()),
250
+ // When false, don't persist prompt/response content on request rows (only
251
+ // metadata + cost). Default true. For teams that want zero prompt retention
252
+ // rather than short retention.
253
+ storeContent: v.optional(v.boolean()),
228
254
  }).index("key", ["key"]),
229
255
  });