@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.
@@ -36,6 +36,10 @@ function assertFraction(n, name) {
36
36
  // Coerce a caller/provider-supplied count to a finite nonnegative integer,
37
37
  // mapping NaN/Infinity/garbage to 0 rather than poisoning downstream totals.
38
38
  const safeCount = (n) => Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
39
+ // Treat a non-finite stored accounting field as 0, so a bucket that was poisoned
40
+ // before validation existed self-heals on its next reserve/release instead of
41
+ // staying NaN forever.
42
+ const fin = (n) => (Number.isFinite(n) ? n : 0);
39
43
  // Built-in attribution dimensions. `user` and `action` are always populated
40
44
  // from a request's userId/actionName; apps can add any other dimensions
41
45
  // (team, project, customer, env, …) as tags. These two names are reserved —
@@ -87,6 +91,16 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
87
91
  // audit table — and the sensitive content in it — from growing without bound.
88
92
  // Override per-deployment via setRetention.
89
93
  const DEFAULT_RETENTION_MS = 60 * 60 * 1000; // 1 hour
94
+ // Reconciliation runs as small, independently-rescheduling phases. Keeping the
95
+ // batch small bounds the bytes read per transaction (each request row can carry
96
+ // prompts/responses) so a burst can't push one phase past Convex's 8 MiB / 16k-
97
+ // doc read limit and stall the whole reconciler. A phase that fills its batch
98
+ // reschedules itself immediately, so throughput still scales with backlog.
99
+ const RECONCILE_BATCH = 50;
100
+ // Keep an expired billing tombstone (content already purged) this long so a very
101
+ // late provider charge can still land against it, then delete it. A finish after
102
+ // deletion is a graceful no-op.
103
+ const LATE_SETTLE_HORIZON_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
90
104
  const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
91
105
  const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
92
106
  // Cached (prompt-cache-read) input tokens are billed far below the normal input
@@ -99,10 +113,15 @@ const CACHE_DISCOUNT = 0.1;
99
113
  // bills real money. Charging the conservative max instead keeps the caps honest
100
114
  // (over-counting is the safe direction); admins can pin an exact price via
101
115
  // setPrice, which also clears the `unpricedModel` flag on future requests.
116
+ // Seed with a true frontier ceiling ($20/$100 per Mtok), not just the max of the
117
+ // small built-in table — otherwise premium models (Opus-class $15/$75, etc.) not
118
+ // in the table would be under-counted several-fold whenever the gateway's
119
+ // authoritative cost isn't available. Over-counting an unpriced model is the safe
120
+ // direction; admins pin the exact rate with setPrice.
102
121
  const CONSERVATIVE_PRICE = Object.values(DEFAULT_PRICES).reduce((m, p) => ({
103
122
  input: Math.max(m.input, p.input),
104
123
  output: Math.max(m.output, p.output),
105
- }), { input: 0, output: 0 });
124
+ }), { input: 20_000_000_000, output: 100_000_000_000 });
106
125
  async function getPrice(ctx, model) {
107
126
  const override = await ctx.db
108
127
  .query("prices")
@@ -235,9 +254,9 @@ function evaluateCaps(o) {
235
254
  // { code, projected usage (incl. this estimate), cap, human window label,
236
255
  // whether it's a money cap (formatted as $), spend? for notices }
237
256
  const checks = [
238
- { w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost, cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
239
- { w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost, cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
240
- { w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost, cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
257
+ { w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost - (o.creditsToday ?? 0), cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
258
+ { w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost - (o.creditsThisMonth ?? 0), cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
259
+ { w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost - (o.creditsTotal ?? 0), cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
241
260
  { w: "daily_token_limit", used: o.tokensToday + o.reservedTokensToday + o.estTokens, cap: o.dailyTokenLimit, label: "daily token limit", money: false },
242
261
  { w: "monthly_token_limit", used: o.tokensThisMonth + o.reservedTokensMonth + o.estTokens, cap: o.monthlyTokenLimit, label: "monthly token limit", money: false },
243
262
  { w: "lifetime_token_limit", used: o.totalTokens + o.reservedTokensTotal + o.estTokens, cap: o.lifetimeTokenLimit, label: "lifetime token limit", money: false },
@@ -379,7 +398,15 @@ export const startRequest = mutation({
379
398
  if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
380
399
  throw new Error("reserveTtlMs must be finite and nonnegative");
381
400
  }
401
+ // Infinity/NaN here is catastrophic: it's reserved onto the bucket, and a
402
+ // later release computes `Infinity - Infinity = NaN`, leaving the reserved
403
+ // fields NaN forever — after which every `used > cap` check is `NaN > cap`
404
+ // (false) and the bucket admits unlimited spend. Reject it up front.
405
+ assertAmount(args.estimatedCostNanos, "estimatedCostNanos");
382
406
  const extraTags = sanitizeExtraTags(args.tags);
407
+ // Set from settings below; when false we persist metadata but no prompt
408
+ // content (a deployment that opts out of storing prompts entirely).
409
+ let storeContent = true;
383
410
  // Record the blocked attempt and return a rejection (throwing would roll
384
411
  // back the record). `persist` is false for the high-frequency-by-design
385
412
  // rejections (rate limit, blocked user) that a client retries in a tight
@@ -392,7 +419,7 @@ export const startRequest = mutation({
392
419
  actionName: args.actionName,
393
420
  ...(extraTags.length ? { tags: extraTags } : {}),
394
421
  model: args.model,
395
- messages: args.messages,
422
+ messages: storeContent ? args.messages : [],
396
423
  rerunOf: args.rerunOf,
397
424
  status: "blocked",
398
425
  error: reason,
@@ -422,6 +449,7 @@ export const startRequest = mutation({
422
449
  const notices = [];
423
450
  // Model allow/deny policy (component-wide).
424
451
  const settings = await getSettings(ctx);
452
+ storeContent = settings?.storeContent !== false;
425
453
  const defaultWarnAtPct = settings?.defaultWarnAtPct;
426
454
  if (settings) {
427
455
  const mode = settings.modelMode ?? "open";
@@ -432,6 +460,11 @@ export const startRequest = mutation({
432
460
  if (mode === "denylist" && list.includes(args.model)) {
433
461
  return reject("model_denied", `Model "${args.model}" is denied`);
434
462
  }
463
+ // Optionally refuse models with no known/override price rather than
464
+ // charging the conservative fallback (which under-counts premium models).
465
+ if (settings.allowUnpricedModels === false && !priceInfo.known) {
466
+ return reject("model_unpriced", `Model "${args.model}" has no configured price; set one with setPrice or allow unpriced models`);
467
+ }
435
468
  }
436
469
  // Fetch/create every bucket this request is attributed to (user, action,
437
470
  // and any extra tags). Each may carry its own budget.
@@ -492,6 +525,9 @@ export const startRequest = mutation({
492
525
  reservedSpendMonth: sameMonth ? b.reservedMonthNanos ?? 0 : 0,
493
526
  totalSpend: b.totalSpendNanos,
494
527
  reservedSpendTotal: b.reservedTotalNanos ?? 0,
528
+ creditsToday: sameDay ? b.creditsTodayNanos ?? 0 : 0,
529
+ creditsThisMonth: sameMonth ? b.creditsThisMonthNanos ?? 0 : 0,
530
+ creditsTotal: b.creditsNanos ?? 0,
495
531
  tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
496
532
  reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
497
533
  tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
@@ -518,35 +554,23 @@ export const startRequest = mutation({
518
554
  // throughput. The sum is transactional, but excludes unsettled usage and
519
555
  // has no cross-request reservation, so a hard global cap can overshoot. That's the deliberate consistency/throughput
520
556
  // trade for a deployment-wide killswitch; it's the only approximate scope.
557
+ // H4: don't read the sharded counter here — that per-admission read
558
+ // contended with every fold that writes it. The reconciler compares the
559
+ // total to the cap out-of-band and records trip flags on `settings`;
560
+ // admission just reads those (already-loaded) flags. The killswitch lag is
561
+ // bounded by the reconcile interval, which is the point of "approximate".
521
562
  if (settings &&
522
- (settings.globalDailySpendLimitNanos !== undefined ||
523
- settings.globalLifetimeSpendLimitNanos !== undefined)) {
524
- const globalEval = evaluateCaps({
525
- label: "global",
526
- name: "deployment",
527
- enforcement: settings.globalEnforcement ?? "hard",
528
- warnAtPct: defaultWarnAtPct,
529
- estCost: est.cost,
530
- estTokens: est.tokens,
531
- spendToday: await globalSpend.count(ctx, globalDayKey(today)),
532
- reservedSpendToday: 0, // sharded holder: no cross-request reservation
533
- spendThisMonth: 0, // global tracks daily + lifetime only
534
- reservedSpendMonth: 0,
535
- totalSpend: await globalSpend.count(ctx, GLOBAL_TOTAL),
536
- reservedSpendTotal: 0,
537
- tokensToday: 0,
538
- reservedTokensToday: 0,
539
- tokensThisMonth: 0,
540
- reservedTokensMonth: 0,
541
- totalTokens: 0,
542
- reservedTokensTotal: 0,
543
- dailySpendLimitNanos: withBump(settings.globalDailySpendLimitNanos, settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0),
544
- lifetimeSpendLimitNanos: withBump(settings.globalLifetimeSpendLimitNanos, settings.globalLifetimeBumpNanos),
545
- });
546
- if (globalEval.hard)
547
- return reject(globalEval.hard.code, globalEval.hard.reason);
548
- warnings.push(...globalEval.warnings);
549
- notices.push(...globalEval.notices);
563
+ (settings.globalTrippedDaily || settings.globalTrippedLifetime)) {
564
+ const enforcement = settings.globalEnforcement ?? "approximate";
565
+ if (enforcement === "soft") {
566
+ warnings.push(`Global spend limit reached for the deployment (allowed — soft)`);
567
+ }
568
+ else {
569
+ return reject("global_spend_limit", "Global spend limit reached for the deployment");
570
+ }
571
+ }
572
+ else if (settings?.globalNearLimit) {
573
+ notices.push("Deployment approaching its global spend limit");
550
574
  }
551
575
  // Consume only after every admission check succeeds, in the same
552
576
  // transaction as the reservations and request insert. `throws: true` is
@@ -596,7 +620,7 @@ export const startRequest = mutation({
596
620
  actionName: args.actionName,
597
621
  ...(extraTags.length ? { tags: extraTags } : {}),
598
622
  model: args.model,
599
- messages: args.messages,
623
+ messages: storeContent ? args.messages : [],
600
624
  rerunOf: args.rerunOf,
601
625
  ...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
602
626
  status: "pending",
@@ -662,14 +686,22 @@ export const finishRequest = mutation({
662
686
  // tool fees); otherwise price from tokens — discounting the cached
663
687
  // (prompt-cache-read) slice — plus any server-tool per-call fees. Require it
664
688
  // finite: +Infinity passes a bare `>= 0` and would corrupt the totals.
689
+ const settings = await getSettings(ctx);
690
+ const storeContent = settings?.storeContent !== false;
665
691
  let costNanos;
666
692
  if (args.costNanos !== undefined && Number.isFinite(args.costNanos) && args.costNanos >= 0) {
667
693
  costNanos = Math.round(args.costNanos);
668
694
  }
669
695
  else {
670
- const settings = await getSettings(ctx);
671
- costNanos =
672
- settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model)) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
696
+ const priced = settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model)) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
697
+ // Fail closed: if the settle carried NO usable cost signal — no tokens and
698
+ // no priced server-tool fee (an unpriced tool like `video_seconds`, or a
699
+ // bare settle()) — fall back to the reserved estimate rather than recording
700
+ // $0. Known-cost calls (image/video/audio) set estimatedCostNanos at
701
+ // reserve time precisely so this floor is their real cost. A caller that
702
+ // truly wants $0 passes an explicit authoritative costNanos: 0 above.
703
+ const noSignal = promptTokens === 0 && completionTokens === 0 && priced === 0;
704
+ costNanos = noSignal ? (request.estimatedNanos ?? 0) : priced;
673
705
  }
674
706
  // Durable write to the request's OWN row only — uncontended, so it always
675
707
  // lands. `settled: false` hands it to the fold step; the row is never left
@@ -679,7 +711,7 @@ export const finishRequest = mutation({
679
711
  reservationExpired: false,
680
712
  expiresAt: undefined,
681
713
  finishedAt: Date.now(),
682
- responseText: args.responseText,
714
+ responseText: storeContent ? args.responseText : undefined,
683
715
  error: args.error,
684
716
  promptTokens,
685
717
  completionTokens,
@@ -711,13 +743,13 @@ async function releaseReservation(ctx, req) {
711
743
  if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b)))
712
744
  continue;
713
745
  await ctx.db.patch(b._id, {
714
- reservedTodayNanos: Math.max(0, (b.reservedTodayNanos ?? 0) - (b.dayStamp === day ? cost : 0)),
715
- reservedMonthNanos: Math.max(0, (b.reservedMonthNanos ?? 0) - (b.monthStamp === month ? cost : 0)),
716
- reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - cost),
717
- reservedTodayTokens: Math.max(0, (b.reservedTodayTokens ?? 0) - (b.dayStamp === day ? tokens : 0)),
718
- reservedMonthTokens: Math.max(0, (b.reservedMonthTokens ?? 0) - (b.monthStamp === month ? tokens : 0)),
719
- reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - tokens),
720
- pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
746
+ reservedTodayNanos: Math.max(0, fin(b.reservedTodayNanos) - (b.dayStamp === day ? cost : 0)),
747
+ reservedMonthNanos: Math.max(0, fin(b.reservedMonthNanos) - (b.monthStamp === month ? cost : 0)),
748
+ reservedTotalNanos: Math.max(0, fin(b.reservedTotalNanos) - cost),
749
+ reservedTodayTokens: Math.max(0, fin(b.reservedTodayTokens) - (b.dayStamp === day ? tokens : 0)),
750
+ reservedMonthTokens: Math.max(0, fin(b.reservedMonthTokens) - (b.monthStamp === month ? tokens : 0)),
751
+ reservedTotalTokens: Math.max(0, fin(b.reservedTotalTokens) - tokens),
752
+ pendingCount: Math.max(0, fin(b.pendingCount) - 1),
721
753
  });
722
754
  }
723
755
  await ctx.db.patch(req._id, { reservationReleased: true });
@@ -773,27 +805,87 @@ export const foldTotals = internalMutation({
773
805
  // Backstop for both failure modes: folds finished requests whose scheduled fold
774
806
  // lost the retry race, and releases reservations for requests that never
775
807
  // settled (their action crashed). Runs on a cron.
808
+ // Cron entry: kick off each reconciliation phase as its OWN transaction so a
809
+ // failure in one (e.g. an oversized retention scan) can't stall the others, and
810
+ // so the hot fold path doesn't share a transaction with retention. Each phase
811
+ // self-reschedules while it has a full batch of backlog.
776
812
  export const reconcile = internalMutation({
777
813
  args: {},
778
- returns: v.object({
779
- folded: v.number(),
780
- expired: v.number(),
781
- purged: v.number(),
782
- }),
814
+ returns: v.null(),
815
+ handler: async (ctx) => {
816
+ await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
817
+ await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
818
+ await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
819
+ await ctx.scheduler.runAfter(0, internal.lib.globalPhase, {});
820
+ return null;
821
+ },
822
+ });
823
+ // H4: compute the deployment-wide global-cap trip flags out-of-band so admission
824
+ // never reads the sharded counter on its hot path. One cheap counter read here
825
+ // per interval; admission then consults the flags on `settings`.
826
+ export const globalPhase = internalMutation({
827
+ args: {},
828
+ returns: v.null(),
829
+ handler: async (ctx) => {
830
+ const s = await getSettings(ctx);
831
+ if (!s)
832
+ return null;
833
+ const hasCap = s.globalDailySpendLimitNanos !== undefined ||
834
+ s.globalLifetimeSpendLimitNanos !== undefined;
835
+ if (!hasCap) {
836
+ // Clear stale flags when no global cap is configured.
837
+ if (s.globalTrippedDaily || s.globalTrippedLifetime || s.globalNearLimit)
838
+ await ctx.db.patch(s._id, {
839
+ globalTrippedDaily: false,
840
+ globalTrippedLifetime: false,
841
+ globalNearLimit: false,
842
+ });
843
+ return null;
844
+ }
845
+ const today = dayStamp();
846
+ const dailyCap = withBump(s.globalDailySpendLimitNanos, s.globalBumpDayStamp === today ? s.globalDailyBumpNanos : 0);
847
+ const lifetimeCap = withBump(s.globalLifetimeSpendLimitNanos, s.globalLifetimeBumpNanos);
848
+ const spentToday = await globalSpend.count(ctx, globalDayKey(today));
849
+ const spentTotal = await globalSpend.count(ctx, GLOBAL_TOTAL);
850
+ const pct = s.defaultWarnAtPct;
851
+ const near = (spent, cap) => cap !== undefined && pct !== undefined && pct > 0 && pct < 1 && spent >= pct * cap;
852
+ await ctx.db.patch(s._id, {
853
+ globalTrippedDaily: dailyCap !== undefined && spentToday >= dailyCap,
854
+ globalTrippedLifetime: lifetimeCap !== undefined && spentTotal >= lifetimeCap,
855
+ globalNearLimit: near(spentToday, dailyCap) || near(spentTotal, lifetimeCap),
856
+ });
857
+ return null;
858
+ },
859
+ });
860
+ // Fold finished-but-unfolded requests whose scheduled fold lost the OCC race.
861
+ export const foldPhase = internalMutation({
862
+ args: {},
863
+ returns: v.object({ folded: v.number() }),
783
864
  handler: async (ctx) => {
784
865
  const toFold = await ctx.db
785
866
  .query("requests")
786
867
  .withIndex("settled", (q) => q.eq("settled", false))
787
- .take(200);
868
+ .take(RECONCILE_BATCH);
788
869
  for (const req of toFold)
789
870
  await foldOne(ctx, req);
871
+ if (toFold.length === RECONCILE_BATCH)
872
+ await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
873
+ return { folded: toFold.length };
874
+ },
875
+ });
876
+ // Release reservations for requests that never settled (their action crashed),
877
+ // and lazily backfill deadlines on legacy pending rows.
878
+ export const expirePhase = internalMutation({
879
+ args: {},
880
+ returns: v.object({ expired: v.number() }),
881
+ handler: async (ctx) => {
790
882
  // Lazily migrate old pending rows in bounded batches. Once indexed, long
791
883
  // TTL jobs cannot hide expired jobs behind them in creation-time order.
792
- const legacy = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").eq("expiresAt", undefined)).take(200);
884
+ const legacy = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").eq("expiresAt", undefined)).take(RECONCILE_BATCH);
793
885
  for (const req of legacy) {
794
886
  await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
795
887
  }
796
- const candidates = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(200);
888
+ const candidates = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(RECONCILE_BATCH);
797
889
  for (const req of candidates) {
798
890
  await releaseReservation(ctx, req);
799
891
  await ctx.db.patch(req._id, {
@@ -801,33 +893,57 @@ export const reconcile = internalMutation({
801
893
  reservationExpired: true, expiresAt: undefined, settled: true,
802
894
  });
803
895
  }
804
- const expired = candidates.length;
805
- // Retention: delete terminal, fully-accounted request rows past the window.
896
+ if (legacy.length === RECONCILE_BATCH || candidates.length === RECONCILE_BATCH)
897
+ await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
898
+ return { expired: candidates.length };
899
+ },
900
+ });
901
+ // Delete terminal, fully-accounted request rows past the retention window; purge
902
+ // content from expired tombstones; and finally delete tombstones past the
903
+ // late-settle horizon so they can't accumulate forever.
904
+ export const retentionPhase = internalMutation({
905
+ args: {},
906
+ returns: v.object({ purged: v.number() }),
907
+ handler: async (ctx) => {
806
908
  const settings = await getSettings(ctx);
807
909
  const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
910
+ if (retentionMs <= 0)
911
+ return { purged: 0 };
912
+ const retentionCutoff = Date.now() - retentionMs;
808
913
  let purged = 0;
809
- if (retentionMs > 0) {
810
- const retentionCutoff = Date.now() - retentionMs;
811
- // Query eligible states directly. Long-lived pending jobs and expired
812
- // billing tombstones must not repeatedly occupy the front of a scan.
813
- const old = [];
814
- for (const expiredFlag of [undefined, false]) {
815
- old.push(...await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", expiredFlag).eq("settled", true)
816
- .lt("_creationTime", retentionCutoff)).take(200));
817
- }
818
- old.push(...await ctx.db.query("requests").withIndex("status", q => q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
819
- for (const req of old) {
914
+ let more = false;
915
+ const sweep = async (rows) => {
916
+ for (const req of rows) {
820
917
  await deleteRequestTags(ctx, req._id);
821
918
  await ctx.db.delete(req._id);
822
919
  purged++;
823
920
  }
824
- const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q => q.eq("reservationExpired", true).eq("contentPurged", undefined)
825
- .lt("_creationTime", retentionCutoff)).take(200);
826
- for (const req of expiredContent) {
827
- await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
828
- }
921
+ if (rows.length === RECONCILE_BATCH)
922
+ more = true;
923
+ };
924
+ // Settled, non-expired terminal rows past the window.
925
+ for (const expiredFlag of [undefined, false]) {
926
+ await sweep(await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", expiredFlag).eq("settled", true)
927
+ .lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
928
+ }
929
+ // Blocked attempts past the window.
930
+ await sweep(await ctx.db.query("requests").withIndex("status", q => q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
931
+ // Expired billing tombstones past the late-settle horizon (content already
932
+ // gone). Without this they'd live forever (one per crashed request).
933
+ const tombstoneCutoff = Date.now() - Math.max(retentionMs, LATE_SETTLE_HORIZON_MS);
934
+ await sweep(await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", true).eq("settled", true)
935
+ .lt("_creationTime", tombstoneCutoff)).take(RECONCILE_BATCH));
936
+ // Strip PII from expired tombstones still inside the horizon.
937
+ const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q => q.eq("reservationExpired", true).eq("contentPurged", undefined)
938
+ .lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH);
939
+ for (const req of expiredContent) {
940
+ await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
829
941
  }
830
- return { folded: toFold.length, expired, purged };
942
+ if (expiredContent.length === RECONCILE_BATCH)
943
+ more = true;
944
+ if (more)
945
+ await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
946
+ return { purged };
831
947
  },
832
948
  });
833
949
  export const setRetention = mutation({
@@ -1040,15 +1156,25 @@ export const adjustBucket = mutation({
1040
1156
  const dSame = b.dayStamp === today;
1041
1157
  const mSame = b.monthStamp === month;
1042
1158
  const dt = tokens ?? 0;
1159
+ // Gross-plus-credits ledger: a positive delta is a real extra charge (adds
1160
+ // to GROSS spend); a negative delta is a credit/refund that accrues to a
1161
+ // separate credits balance and NEVER reduces gross spend — so gross spend
1162
+ // and durable history stay consistent, and net = gross - credits. Credits
1163
+ // grant headroom because admission subtracts them from the cap check.
1164
+ const debit = deltaNanos > 0 ? deltaNanos : 0;
1165
+ const credit = deltaNanos < 0 ? -deltaNanos : 0;
1043
1166
  await ctx.db.patch(b._id, {
1044
- totalSpendNanos: Math.max(0, b.totalSpendNanos + deltaNanos),
1167
+ totalSpendNanos: b.totalSpendNanos + debit,
1045
1168
  totalTokens: Math.max(0, b.totalTokens + dt),
1046
1169
  dayStamp: today,
1047
1170
  monthStamp: month,
1048
- spendTodayNanos: Math.max(0, (dSame ? b.spendTodayNanos : 0) + deltaNanos),
1171
+ spendTodayNanos: (dSame ? b.spendTodayNanos : 0) + debit,
1049
1172
  tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
1050
- spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
1173
+ spendThisMonthNanos: (mSame ? b.spendThisMonthNanos ?? 0 : 0) + debit,
1051
1174
  tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
1175
+ creditsNanos: (b.creditsNanos ?? 0) + credit,
1176
+ creditsTodayNanos: (dSame ? b.creditsTodayNanos ?? 0 : 0) + credit,
1177
+ creditsThisMonthNanos: (mSame ? b.creditsThisMonthNanos ?? 0 : 0) + credit,
1052
1178
  // Advancing the window here must also clear the OLD window's reserved
1053
1179
  // holds, or an in-flight request from the previous day/month would be
1054
1180
  // treated as reserving against the new window and its later release would
@@ -1057,8 +1183,10 @@ export const adjustBucket = mutation({
1057
1183
  ...(mSame ? {} : { reservedMonthNanos: 0, reservedMonthTokens: 0 }),
1058
1184
  });
1059
1185
  await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
1060
- await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
1061
- await addUsage(ctx, dimension, value, "month", month, deltaNanos, dt, 0);
1186
+ // Durable history tracks GROSS spend only (debits); credits live in the
1187
+ // adjustments log + bucket balance, so usage rollups never go negative.
1188
+ await addUsage(ctx, dimension, value, "day", today, debit, dt, 0);
1189
+ await addUsage(ctx, dimension, value, "month", month, debit, dt, 0);
1062
1190
  return null;
1063
1191
  },
1064
1192
  });
@@ -1099,6 +1227,31 @@ export const setAlertDefaults = mutation({
1099
1227
  return null;
1100
1228
  },
1101
1229
  });
1230
+ // Deployment-wide data/pricing policy. Only the fields you pass change.
1231
+ // - allowUnpricedModels: false rejects models with no configured price under
1232
+ // hard enforcement (default true — charge the conservative fallback).
1233
+ // - storeContent: false stops persisting prompt/response content on request
1234
+ // rows (default true).
1235
+ export const setDeploymentPolicy = mutation({
1236
+ args: {
1237
+ allowUnpricedModels: v.optional(v.boolean()),
1238
+ storeContent: v.optional(v.boolean()),
1239
+ },
1240
+ returns: v.null(),
1241
+ handler: async (ctx, args) => {
1242
+ const patch = {};
1243
+ if ("allowUnpricedModels" in args)
1244
+ patch.allowUnpricedModels = args.allowUnpricedModels;
1245
+ if ("storeContent" in args)
1246
+ patch.storeContent = args.storeContent;
1247
+ const existing = await getSettings(ctx);
1248
+ if (existing)
1249
+ await ctx.db.patch(existing._id, patch);
1250
+ else
1251
+ await ctx.db.insert("settings", { key: "singleton", ...patch });
1252
+ return null;
1253
+ },
1254
+ });
1102
1255
  // Delete a bucket and (for the `user` dimension) all of that user's request
1103
1256
  // rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
1104
1257
  // self-reschedules so it never exceeds the per-transaction document limit.
@@ -1176,7 +1329,7 @@ export const getGlobalStatus = query({
1176
1329
  returns: v.object({
1177
1330
  dailySpendLimitNanos: v.union(v.number(), v.null()),
1178
1331
  lifetimeSpendLimitNanos: v.union(v.number(), v.null()),
1179
- enforcement: v.union(v.literal("hard"), v.literal("soft")),
1332
+ enforcement: v.union(v.literal("approximate"), v.literal("soft")),
1180
1333
  spentTodayNanos: v.number(),
1181
1334
  spentTotalNanos: v.number(),
1182
1335
  // deployment-wide config (surfaced for the admin dashboard)
@@ -1191,7 +1344,7 @@ export const getGlobalStatus = query({
1191
1344
  return {
1192
1345
  dailySpendLimitNanos: s?.globalDailySpendLimitNanos ?? null,
1193
1346
  lifetimeSpendLimitNanos: s?.globalLifetimeSpendLimitNanos ?? null,
1194
- enforcement: s?.globalEnforcement ?? "hard",
1347
+ enforcement: s?.globalEnforcement ?? "approximate",
1195
1348
  spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
1196
1349
  spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
1197
1350
  retentionMs: s?.retentionMs ?? null,
@@ -1207,7 +1360,7 @@ export const setGlobalLimits = mutation({
1207
1360
  // other global controls by patching them to undefined.)
1208
1361
  dailySpendLimitNanos: v.optional(v.union(v.number(), v.null())),
1209
1362
  lifetimeSpendLimitNanos: v.optional(v.union(v.number(), v.null())),
1210
- enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"), v.null())),
1363
+ enforcement: v.optional(v.union(v.literal("approximate"), v.literal("soft"), v.null())),
1211
1364
  },
1212
1365
  returns: v.null(),
1213
1366
  handler: async (ctx, args) => {
@@ -63,6 +63,9 @@ declare const _default: import("convex/server").SchemaDefinition<{
63
63
  lifetimeBumpNanos?: number | undefined;
64
64
  bumpDayStamp?: string | undefined;
65
65
  bumpMonthStamp?: string | undefined;
66
+ creditsNanos?: number | undefined;
67
+ creditsTodayNanos?: number | undefined;
68
+ creditsThisMonthNanos?: number | undefined;
66
69
  tokensToday?: number | undefined;
67
70
  monthStamp?: string | undefined;
68
71
  spendThisMonthNanos?: number | undefined;
@@ -103,6 +106,9 @@ declare const _default: import("convex/server").SchemaDefinition<{
103
106
  totalSpendNanos: import("convex/values").VFloat64<number, "required">;
104
107
  totalRequests: import("convex/values").VFloat64<number, "required">;
105
108
  totalTokens: import("convex/values").VFloat64<number, "required">;
109
+ creditsNanos: import("convex/values").VFloat64<number | undefined, "optional">;
110
+ creditsTodayNanos: import("convex/values").VFloat64<number | undefined, "optional">;
111
+ creditsThisMonthNanos: import("convex/values").VFloat64<number | undefined, "optional">;
106
112
  dayStamp: import("convex/values").VString<string, "required">;
107
113
  spendTodayNanos: import("convex/values").VFloat64<number, "required">;
108
114
  tokensToday: import("convex/values").VFloat64<number | undefined, "optional">;
@@ -116,7 +122,7 @@ declare const _default: import("convex/server").SchemaDefinition<{
116
122
  reservedMonthTokens: import("convex/values").VFloat64<number | undefined, "optional">;
117
123
  reservedTotalTokens: import("convex/values").VFloat64<number | undefined, "optional">;
118
124
  pendingCount: import("convex/values").VFloat64<number | undefined, "optional">;
119
- }, "required", "dimension" | "value" | "requestsPerMinute" | "maxConcurrent" | "dailySpendLimitNanos" | "monthlySpendLimitNanos" | "lifetimeSpendLimitNanos" | "dailyTokenLimit" | "monthlyTokenLimit" | "lifetimeTokenLimit" | "blocked" | "warnAtPct" | "enforcement" | "dailyBumpNanos" | "monthlyBumpNanos" | "lifetimeBumpNanos" | "bumpDayStamp" | "bumpMonthStamp" | "totalSpendNanos" | "totalRequests" | "totalTokens" | "dayStamp" | "spendTodayNanos" | "tokensToday" | "monthStamp" | "spendThisMonthNanos" | "tokensThisMonth" | "reservedTodayNanos" | "reservedMonthNanos" | "reservedTotalNanos" | "reservedTodayTokens" | "reservedMonthTokens" | "reservedTotalTokens" | "pendingCount">, {
125
+ }, "required", "dimension" | "value" | "requestsPerMinute" | "maxConcurrent" | "dailySpendLimitNanos" | "monthlySpendLimitNanos" | "lifetimeSpendLimitNanos" | "dailyTokenLimit" | "monthlyTokenLimit" | "lifetimeTokenLimit" | "blocked" | "warnAtPct" | "enforcement" | "dailyBumpNanos" | "monthlyBumpNanos" | "lifetimeBumpNanos" | "bumpDayStamp" | "bumpMonthStamp" | "totalSpendNanos" | "totalRequests" | "totalTokens" | "creditsNanos" | "creditsTodayNanos" | "creditsThisMonthNanos" | "dayStamp" | "spendTodayNanos" | "tokensToday" | "monthStamp" | "spendThisMonthNanos" | "tokensThisMonth" | "reservedTodayNanos" | "reservedMonthNanos" | "reservedTotalNanos" | "reservedTodayTokens" | "reservedMonthTokens" | "reservedTotalTokens" | "pendingCount">, {
120
126
  dim_value: ["dimension", "value", "_creationTime"];
121
127
  dimension: ["dimension", "_creationTime"];
122
128
  }, {}, {}>;
@@ -279,13 +285,18 @@ declare const _default: import("convex/server").SchemaDefinition<{
279
285
  models?: string[] | undefined;
280
286
  globalDailySpendLimitNanos?: number | undefined;
281
287
  globalLifetimeSpendLimitNanos?: number | undefined;
282
- globalEnforcement?: "hard" | "soft" | undefined;
288
+ globalEnforcement?: "soft" | "approximate" | undefined;
283
289
  globalDailyBumpNanos?: number | undefined;
284
290
  globalLifetimeBumpNanos?: number | undefined;
285
291
  globalBumpDayStamp?: string | undefined;
292
+ globalTrippedDaily?: boolean | undefined;
293
+ globalTrippedLifetime?: boolean | undefined;
294
+ globalNearLimit?: boolean | undefined;
286
295
  retentionMs?: number | undefined;
287
296
  defaultWarnAtPct?: number | undefined;
288
297
  serverToolPrices?: Record<string, number> | undefined;
298
+ allowUnpricedModels?: boolean | undefined;
299
+ storeContent?: boolean | undefined;
289
300
  key: string;
290
301
  }, {
291
302
  key: import("convex/values").VString<string, "required">;
@@ -293,14 +304,19 @@ declare const _default: import("convex/server").SchemaDefinition<{
293
304
  models: import("convex/values").VArray<string[] | undefined, import("convex/values").VString<string, "required">, "optional">;
294
305
  globalDailySpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
295
306
  globalLifetimeSpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
296
- globalEnforcement: import("convex/values").VUnion<"hard" | "soft" | undefined, [import("convex/values").VLiteral<"hard", "required">, import("convex/values").VLiteral<"soft", "required">], "optional", never>;
307
+ globalEnforcement: import("convex/values").VUnion<"soft" | "approximate" | undefined, [import("convex/values").VLiteral<"approximate", "required">, import("convex/values").VLiteral<"soft", "required">], "optional", never>;
297
308
  globalDailyBumpNanos: import("convex/values").VFloat64<number | undefined, "optional">;
298
309
  globalLifetimeBumpNanos: import("convex/values").VFloat64<number | undefined, "optional">;
299
310
  globalBumpDayStamp: import("convex/values").VString<string | undefined, "optional">;
311
+ globalTrippedDaily: import("convex/values").VBoolean<boolean | undefined, "optional">;
312
+ globalTrippedLifetime: import("convex/values").VBoolean<boolean | undefined, "optional">;
313
+ globalNearLimit: import("convex/values").VBoolean<boolean | undefined, "optional">;
300
314
  retentionMs: import("convex/values").VFloat64<number | undefined, "optional">;
301
315
  defaultWarnAtPct: import("convex/values").VFloat64<number | undefined, "optional">;
302
316
  serverToolPrices: import("convex/values").VRecord<Record<string, number> | undefined, import("convex/values").VString<string, "required">, import("convex/values").VFloat64<number, "required">, "optional", string>;
303
- }, "required", "key" | "modelMode" | "models" | "globalDailySpendLimitNanos" | "globalLifetimeSpendLimitNanos" | "globalEnforcement" | "globalDailyBumpNanos" | "globalLifetimeBumpNanos" | "globalBumpDayStamp" | "retentionMs" | "defaultWarnAtPct" | "serverToolPrices" | `serverToolPrices.${string}`>, {
317
+ allowUnpricedModels: import("convex/values").VBoolean<boolean | undefined, "optional">;
318
+ storeContent: import("convex/values").VBoolean<boolean | undefined, "optional">;
319
+ }, "required", "key" | "modelMode" | "models" | "globalDailySpendLimitNanos" | "globalLifetimeSpendLimitNanos" | "globalEnforcement" | "globalDailyBumpNanos" | "globalLifetimeBumpNanos" | "globalBumpDayStamp" | "globalTrippedDaily" | "globalTrippedLifetime" | "globalNearLimit" | "retentionMs" | "defaultWarnAtPct" | "serverToolPrices" | "allowUnpricedModels" | "storeContent" | `serverToolPrices.${string}`>, {
304
320
  key: ["key", "_creationTime"];
305
321
  }, {}, {}>;
306
322
  }, true>;
@@ -56,10 +56,17 @@ export default defineSchema({
56
56
  lifetimeBumpNanos: v.optional(v.number()),
57
57
  bumpDayStamp: v.optional(v.string()),
58
58
  bumpMonthStamp: v.optional(v.string()),
59
- // settled totals (from finished requests)
59
+ // settled GROSS spend (real charges + positive adjustments). Credits never
60
+ // reduce these — they accrue separately below — so gross spend and durable
61
+ // history always agree. Cap checks and "net" use (gross - credits).
60
62
  totalSpendNanos: v.number(),
61
63
  totalRequests: v.number(),
62
64
  totalTokens: v.number(),
65
+ // manual credits (comps/refunds), tracked separately from gross spend and
66
+ // subtracted at admission so a credit grants real headroom under a cap.
67
+ creditsNanos: v.optional(v.number()),
68
+ creditsTodayNanos: v.optional(v.number()),
69
+ creditsThisMonthNanos: v.optional(v.number()),
63
70
  // daily window
64
71
  dayStamp: v.string(),
65
72
  spendTodayNanos: v.number(),
@@ -191,10 +198,21 @@ export default defineSchema({
191
198
  // killswitch; per-bucket concurrent admission is atomic via reserve/settle.
192
199
  globalDailySpendLimitNanos: v.optional(v.number()),
193
200
  globalLifetimeSpendLimitNanos: v.optional(v.number()),
194
- globalEnforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
201
+ // "approximate" (default): a best-effort killswitch — it blocks once the
202
+ // sharded total crosses the cap, but with bounded overshoot (no per-request
203
+ // reservation). "soft": warn only. There is deliberately no "hard": a true
204
+ // to-the-dollar ceiling is a per-bucket cap.
205
+ globalEnforcement: v.optional(v.union(v.literal("approximate"), v.literal("soft"))),
195
206
  globalDailyBumpNanos: v.optional(v.number()),
196
207
  globalLifetimeBumpNanos: v.optional(v.number()),
197
208
  globalBumpDayStamp: v.optional(v.string()),
209
+ // H4: the reconciler compares the sharded global total to the cap and sets
210
+ // these, so admission reads ONE settings doc instead of the sharded counter
211
+ // (whose per-admission read contended with every fold). Killswitch lag is
212
+ // bounded by the reconcile interval — fine for a deployment-wide stop.
213
+ globalTrippedDaily: v.optional(v.boolean()),
214
+ globalTrippedLifetime: v.optional(v.boolean()),
215
+ globalNearLimit: v.optional(v.boolean()),
198
216
  // request-row retention window in ms (default 1h); 0 disables sweeping.
199
217
  retentionMs: v.optional(v.number()),
200
218
  // default approaching-limit alert threshold (fraction of a cap) for buckets
@@ -203,5 +221,13 @@ export default defineSchema({
203
221
  // per-call price (nanodollars) overrides for provider server tools, keyed by
204
222
  // tool name (e.g. { web_search: 12_000_000 }). Merged over the defaults.
205
223
  serverToolPrices: v.optional(v.record(v.string(), v.number())),
224
+ // When false, reject requests for a model with no known/override price under
225
+ // hard enforcement (instead of charging the conservative fallback). Default
226
+ // true (charge the fallback, keep the call working).
227
+ allowUnpricedModels: v.optional(v.boolean()),
228
+ // When false, don't persist prompt/response content on request rows (only
229
+ // metadata + cost). Default true. For teams that want zero prompt retention
230
+ // rather than short retention.
231
+ storeContent: v.optional(v.boolean()),
206
232
  }).index("key", ["key"]),
207
233
  });