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

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.
@@ -12,6 +12,34 @@ import { ShardedCounter } from "@convex-dev/sharded-counter";
12
12
  // currency code alongside these amounts and convert here, at the one boundary.
13
13
  const NANOS_PER_DOLLAR = 1e9;
14
14
  const fmtUsd = (nanos) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
15
+ // Convex's `v.number()` accepts NaN and ±Infinity. Those are poison here: a NaN
16
+ // cap or count silently defeats every `used > cap` comparison (NaN > x is
17
+ // false), so an unvalidated NaN would make admission fail OPEN and admit
18
+ // unlimited spend; +Infinity in totals is just as corrupting. Validate every
19
+ // externally-supplied accounting amount at the mutation boundary.
20
+ function assertAmount(n, name, { signed = false } = {}) {
21
+ if (n === undefined)
22
+ return;
23
+ if (!Number.isFinite(n) || !Number.isSafeInteger(n)) {
24
+ throw new Error(`${name} must be a finite safe integer (got ${n})`);
25
+ }
26
+ if (!signed && n < 0)
27
+ throw new Error(`${name} must be nonnegative (got ${n})`);
28
+ }
29
+ function assertFraction(n, name) {
30
+ if (n === undefined)
31
+ return;
32
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
33
+ throw new Error(`${name} must be a number in [0, 1] (got ${n})`);
34
+ }
35
+ }
36
+ // Coerce a caller/provider-supplied count to a finite nonnegative integer,
37
+ // mapping NaN/Infinity/garbage to 0 rather than poisoning downstream totals.
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);
15
43
  // Built-in attribution dimensions. `user` and `action` are always populated
16
44
  // from a request's userId/actionName; apps can add any other dimensions
17
45
  // (team, project, customer, env, …) as tags. These two names are reserved —
@@ -63,6 +91,16 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
63
91
  // audit table — and the sensitive content in it — from growing without bound.
64
92
  // Override per-deployment via setRetention.
65
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
66
104
  const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
67
105
  const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
68
106
  // Cached (prompt-cache-read) input tokens are billed far below the normal input
@@ -75,10 +113,15 @@ const CACHE_DISCOUNT = 0.1;
75
113
  // bills real money. Charging the conservative max instead keeps the caps honest
76
114
  // (over-counting is the safe direction); admins can pin an exact price via
77
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.
78
121
  const CONSERVATIVE_PRICE = Object.values(DEFAULT_PRICES).reduce((m, p) => ({
79
122
  input: Math.max(m.input, p.input),
80
123
  output: Math.max(m.output, p.output),
81
- }), { input: 0, output: 0 });
124
+ }), { input: 20_000_000_000, output: 100_000_000_000 });
82
125
  async function getPrice(ctx, model) {
83
126
  const override = await ctx.db
84
127
  .query("prices")
@@ -355,6 +398,11 @@ export const startRequest = mutation({
355
398
  if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
356
399
  throw new Error("reserveTtlMs must be finite and nonnegative");
357
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");
358
406
  const extraTags = sanitizeExtraTags(args.tags);
359
407
  // Record the blocked attempt and return a rejection (throwing would roll
360
408
  // back the record). `persist` is false for the high-frequency-by-design
@@ -525,8 +573,14 @@ export const startRequest = mutation({
525
573
  notices.push(...globalEval.notices);
526
574
  }
527
575
  // Consume only after every admission check succeeds, in the same
528
- // transaction as the reservations and request insert. An unexpected
529
- // rejection throws to roll back all previously consumed dimensions.
576
+ // transaction as the reservations and request insert. `throws: true` is
577
+ // deliberate, not a rough edge: we already `.check`ed every bucket above in
578
+ // this same serializable transaction, so a `.limit` here cannot fail on a
579
+ // bucket that passed check — and if it somehow did (or a later bucket did),
580
+ // throwing rolls back the WHOLE transaction, including the rate we already
581
+ // consumed on earlier buckets. Converting this to a graceful `{allowed:false}`
582
+ // return would COMMIT the partial consumption and leak rate capacity, so keep
583
+ // the throw.
530
584
  for (const b of buckets) {
531
585
  if (b.requestsPerMinute !== undefined) {
532
586
  await requestRateLimiter.limit(ctx, "requests", {
@@ -612,29 +666,41 @@ export const finishRequest = mutation({
612
666
  returns: v.object({ costNanos: v.number() }),
613
667
  handler: async (ctx, args) => {
614
668
  const request = await ctx.db.get(args.requestId);
669
+ // The request may be gone — retention purged it, or the owning bucket was
670
+ // deleted. A late/duplicate webhook must be an idempotent no-op, not a 500
671
+ // (the caller can't do anything useful with the error, and it triggers retries).
615
672
  if (!request)
616
- throw new Error("Unknown request");
673
+ return { costNanos: 0 };
617
674
  // Expiry releases capacity, but is not evidence that the provider charged
618
675
  // nothing. Accept one final result even after expiry; duplicates stay no-ops.
619
676
  if (request.status !== "pending" && !request.reservationExpired) {
620
677
  return { costNanos: request.costNanos ?? 0 };
621
678
  }
622
- // Clamp caller-supplied token counts: negatives would produce negative cost
623
- // and could refund a user below their cap.
624
- const promptTokens = Math.max(0, args.promptTokens ?? 0);
625
- const completionTokens = Math.max(0, args.completionTokens ?? 0);
626
- const cachedTokens = Math.min(promptTokens, Math.max(0, args.cachedTokens ?? 0));
679
+ // Coerce caller/provider-supplied token counts to finite nonnegative
680
+ // integers: negatives would refund below a cap, and NaN/Infinity (which
681
+ // v.number() allows) would poison every downstream total and cap check.
682
+ const promptTokens = safeCount(args.promptTokens);
683
+ const completionTokens = safeCount(args.completionTokens);
684
+ const cachedTokens = Math.min(promptTokens, safeCount(args.cachedTokens));
627
685
  // Prefer an authoritative gateway cost when supplied (it already includes
628
686
  // tool fees); otherwise price from tokens — discounting the cached
629
- // (prompt-cache-read) slice — plus any server-tool per-call fees.
687
+ // (prompt-cache-read) slice — plus any server-tool per-call fees. Require it
688
+ // finite: +Infinity passes a bare `>= 0` and would corrupt the totals.
630
689
  let costNanos;
631
- if (args.costNanos !== undefined && args.costNanos >= 0) {
690
+ if (args.costNanos !== undefined && Number.isFinite(args.costNanos) && args.costNanos >= 0) {
632
691
  costNanos = Math.round(args.costNanos);
633
692
  }
634
693
  else {
635
694
  const settings = await getSettings(ctx);
636
- costNanos =
637
- settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model)) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
695
+ const priced = settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model)) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
696
+ // Fail closed: if the settle carried NO usable cost signal — no tokens and
697
+ // no priced server-tool fee (an unpriced tool like `video_seconds`, or a
698
+ // bare settle()) — fall back to the reserved estimate rather than recording
699
+ // $0. Known-cost calls (image/video/audio) set estimatedCostNanos at
700
+ // reserve time precisely so this floor is their real cost. A caller that
701
+ // truly wants $0 passes an explicit authoritative costNanos: 0 above.
702
+ const noSignal = promptTokens === 0 && completionTokens === 0 && priced === 0;
703
+ costNanos = noSignal ? (request.estimatedNanos ?? 0) : priced;
638
704
  }
639
705
  // Durable write to the request's OWN row only — uncontended, so it always
640
706
  // lands. `settled: false` hands it to the fold step; the row is never left
@@ -676,13 +742,13 @@ async function releaseReservation(ctx, req) {
676
742
  if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b)))
677
743
  continue;
678
744
  await ctx.db.patch(b._id, {
679
- reservedTodayNanos: Math.max(0, (b.reservedTodayNanos ?? 0) - (b.dayStamp === day ? cost : 0)),
680
- reservedMonthNanos: Math.max(0, (b.reservedMonthNanos ?? 0) - (b.monthStamp === month ? cost : 0)),
681
- reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - cost),
682
- reservedTodayTokens: Math.max(0, (b.reservedTodayTokens ?? 0) - (b.dayStamp === day ? tokens : 0)),
683
- reservedMonthTokens: Math.max(0, (b.reservedMonthTokens ?? 0) - (b.monthStamp === month ? tokens : 0)),
684
- reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - tokens),
685
- pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
745
+ reservedTodayNanos: Math.max(0, fin(b.reservedTodayNanos) - (b.dayStamp === day ? cost : 0)),
746
+ reservedMonthNanos: Math.max(0, fin(b.reservedMonthNanos) - (b.monthStamp === month ? cost : 0)),
747
+ reservedTotalNanos: Math.max(0, fin(b.reservedTotalNanos) - cost),
748
+ reservedTodayTokens: Math.max(0, fin(b.reservedTodayTokens) - (b.dayStamp === day ? tokens : 0)),
749
+ reservedMonthTokens: Math.max(0, fin(b.reservedMonthTokens) - (b.monthStamp === month ? tokens : 0)),
750
+ reservedTotalTokens: Math.max(0, fin(b.reservedTotalTokens) - tokens),
751
+ pendingCount: Math.max(0, fin(b.pendingCount) - 1),
686
752
  });
687
753
  }
688
754
  await ctx.db.patch(req._id, { reservationReleased: true });
@@ -738,27 +804,49 @@ export const foldTotals = internalMutation({
738
804
  // Backstop for both failure modes: folds finished requests whose scheduled fold
739
805
  // lost the retry race, and releases reservations for requests that never
740
806
  // settled (their action crashed). Runs on a cron.
807
+ // Cron entry: kick off each reconciliation phase as its OWN transaction so a
808
+ // failure in one (e.g. an oversized retention scan) can't stall the others, and
809
+ // so the hot fold path doesn't share a transaction with retention. Each phase
810
+ // self-reschedules while it has a full batch of backlog.
741
811
  export const reconcile = internalMutation({
742
812
  args: {},
743
- returns: v.object({
744
- folded: v.number(),
745
- expired: v.number(),
746
- purged: v.number(),
747
- }),
813
+ returns: v.null(),
814
+ handler: async (ctx) => {
815
+ await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
816
+ await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
817
+ await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
818
+ return null;
819
+ },
820
+ });
821
+ // Fold finished-but-unfolded requests whose scheduled fold lost the OCC race.
822
+ export const foldPhase = internalMutation({
823
+ args: {},
824
+ returns: v.object({ folded: v.number() }),
748
825
  handler: async (ctx) => {
749
826
  const toFold = await ctx.db
750
827
  .query("requests")
751
828
  .withIndex("settled", (q) => q.eq("settled", false))
752
- .take(200);
829
+ .take(RECONCILE_BATCH);
753
830
  for (const req of toFold)
754
831
  await foldOne(ctx, req);
832
+ if (toFold.length === RECONCILE_BATCH)
833
+ await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
834
+ return { folded: toFold.length };
835
+ },
836
+ });
837
+ // Release reservations for requests that never settled (their action crashed),
838
+ // and lazily backfill deadlines on legacy pending rows.
839
+ export const expirePhase = internalMutation({
840
+ args: {},
841
+ returns: v.object({ expired: v.number() }),
842
+ handler: async (ctx) => {
755
843
  // Lazily migrate old pending rows in bounded batches. Once indexed, long
756
844
  // TTL jobs cannot hide expired jobs behind them in creation-time order.
757
- const legacy = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").eq("expiresAt", undefined)).take(200);
845
+ const legacy = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").eq("expiresAt", undefined)).take(RECONCILE_BATCH);
758
846
  for (const req of legacy) {
759
847
  await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
760
848
  }
761
- const candidates = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(200);
849
+ 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);
762
850
  for (const req of candidates) {
763
851
  await releaseReservation(ctx, req);
764
852
  await ctx.db.patch(req._id, {
@@ -766,33 +854,57 @@ export const reconcile = internalMutation({
766
854
  reservationExpired: true, expiresAt: undefined, settled: true,
767
855
  });
768
856
  }
769
- const expired = candidates.length;
770
- // Retention: delete terminal, fully-accounted request rows past the window.
857
+ if (legacy.length === RECONCILE_BATCH || candidates.length === RECONCILE_BATCH)
858
+ await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
859
+ return { expired: candidates.length };
860
+ },
861
+ });
862
+ // Delete terminal, fully-accounted request rows past the retention window; purge
863
+ // content from expired tombstones; and finally delete tombstones past the
864
+ // late-settle horizon so they can't accumulate forever.
865
+ export const retentionPhase = internalMutation({
866
+ args: {},
867
+ returns: v.object({ purged: v.number() }),
868
+ handler: async (ctx) => {
771
869
  const settings = await getSettings(ctx);
772
870
  const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
871
+ if (retentionMs <= 0)
872
+ return { purged: 0 };
873
+ const retentionCutoff = Date.now() - retentionMs;
773
874
  let purged = 0;
774
- if (retentionMs > 0) {
775
- const retentionCutoff = Date.now() - retentionMs;
776
- // Query eligible states directly. Long-lived pending jobs and expired
777
- // billing tombstones must not repeatedly occupy the front of a scan.
778
- const old = [];
779
- for (const expiredFlag of [undefined, false]) {
780
- old.push(...await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", expiredFlag).eq("settled", true)
781
- .lt("_creationTime", retentionCutoff)).take(200));
782
- }
783
- old.push(...await ctx.db.query("requests").withIndex("status", q => q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
784
- for (const req of old) {
875
+ let more = false;
876
+ const sweep = async (rows) => {
877
+ for (const req of rows) {
785
878
  await deleteRequestTags(ctx, req._id);
786
879
  await ctx.db.delete(req._id);
787
880
  purged++;
788
881
  }
789
- const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q => q.eq("reservationExpired", true).eq("contentPurged", undefined)
790
- .lt("_creationTime", retentionCutoff)).take(200);
791
- for (const req of expiredContent) {
792
- await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
793
- }
882
+ if (rows.length === RECONCILE_BATCH)
883
+ more = true;
884
+ };
885
+ // Settled, non-expired terminal rows past the window.
886
+ for (const expiredFlag of [undefined, false]) {
887
+ await sweep(await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", expiredFlag).eq("settled", true)
888
+ .lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
889
+ }
890
+ // Blocked attempts past the window.
891
+ await sweep(await ctx.db.query("requests").withIndex("status", q => q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
892
+ // Expired billing tombstones past the late-settle horizon (content already
893
+ // gone). Without this they'd live forever (one per crashed request).
894
+ const tombstoneCutoff = Date.now() - Math.max(retentionMs, LATE_SETTLE_HORIZON_MS);
895
+ await sweep(await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", true).eq("settled", true)
896
+ .lt("_creationTime", tombstoneCutoff)).take(RECONCILE_BATCH));
897
+ // Strip PII from expired tombstones still inside the horizon.
898
+ const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q => q.eq("reservationExpired", true).eq("contentPurged", undefined)
899
+ .lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH);
900
+ for (const req of expiredContent) {
901
+ await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
794
902
  }
795
- return { folded: toFold.length, expired, purged };
903
+ if (expiredContent.length === RECONCILE_BATCH)
904
+ more = true;
905
+ if (more)
906
+ await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
907
+ return { purged };
796
908
  },
797
909
  });
798
910
  export const setRetention = mutation({
@@ -937,10 +1049,15 @@ export const setBucketLimits = mutation({
937
1049
  },
938
1050
  returns: v.null(),
939
1051
  handler: async (ctx, args) => {
940
- if (args.requestsPerMinute !== undefined &&
941
- (!Number.isSafeInteger(args.requestsPerMinute) || args.requestsPerMinute < 0)) {
942
- throw new Error("requestsPerMinute must be a nonnegative safe integer");
943
- }
1052
+ assertAmount(args.requestsPerMinute, "requestsPerMinute");
1053
+ assertAmount(args.maxConcurrent, "maxConcurrent");
1054
+ assertAmount(args.dailySpendLimitNanos, "dailySpendLimitNanos");
1055
+ assertAmount(args.monthlySpendLimitNanos, "monthlySpendLimitNanos");
1056
+ assertAmount(args.lifetimeSpendLimitNanos, "lifetimeSpendLimitNanos");
1057
+ assertAmount(args.dailyTokenLimit, "dailyTokenLimit");
1058
+ assertAmount(args.monthlyTokenLimit, "monthlyTokenLimit");
1059
+ assertAmount(args.lifetimeTokenLimit, "lifetimeTokenLimit");
1060
+ assertFraction(args.warnAtPct, "warnAtPct");
944
1061
  const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
945
1062
  const { dimension: _d, value: _v, ...limits } = args;
946
1063
  await ctx.db.patch(bucket._id, limits);
@@ -960,6 +1077,9 @@ export const bumpBucket = mutation({
960
1077
  args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
961
1078
  returns: v.null(),
962
1079
  handler: async (ctx, { dimension, value, dailyNanos, monthlyNanos, lifetimeNanos }) => {
1080
+ assertAmount(dailyNanos, "dailyNanos");
1081
+ assertAmount(monthlyNanos, "monthlyNanos");
1082
+ assertAmount(lifetimeNanos, "lifetimeNanos");
963
1083
  const bucket = await getOrCreateBucket(ctx, dimension, value);
964
1084
  const today = dayStamp();
965
1085
  const month = monthStamp();
@@ -989,6 +1109,8 @@ export const adjustBucket = mutation({
989
1109
  },
990
1110
  returns: v.null(),
991
1111
  handler: async (ctx, { dimension, value, deltaNanos, tokens, reason }) => {
1112
+ assertAmount(deltaNanos, "deltaNanos", { signed: true });
1113
+ assertAmount(tokens, "tokens", { signed: true });
992
1114
  const b = await getOrCreateBucket(ctx, dimension, value);
993
1115
  const today = dayStamp();
994
1116
  const month = monthStamp();
@@ -1004,6 +1126,12 @@ export const adjustBucket = mutation({
1004
1126
  tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
1005
1127
  spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
1006
1128
  tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
1129
+ // Advancing the window here must also clear the OLD window's reserved
1130
+ // holds, or an in-flight request from the previous day/month would be
1131
+ // treated as reserving against the new window and its later release would
1132
+ // no longer match — stranding those reserved nanos/tokens.
1133
+ ...(dSame ? {} : { reservedTodayNanos: 0, reservedTodayTokens: 0 }),
1134
+ ...(mSame ? {} : { reservedMonthNanos: 0, reservedMonthTokens: 0 }),
1007
1135
  });
1008
1136
  await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
1009
1137
  await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
@@ -1064,6 +1192,17 @@ export const deleteBucket = mutation({
1064
1192
  .withIndex("userId", (q) => q.eq("userId", value))
1065
1193
  .take(DELETE_BATCH);
1066
1194
  for (const r of rows) {
1195
+ // Before dropping the row, free or settle any hold it placed on OTHER
1196
+ // (shared) buckets — an action/customer bucket this user's request
1197
+ // reserved against. Otherwise deleting the only row that could release
1198
+ // that hold strands the shared bucket's reservation + pendingCount
1199
+ // forever, and a late finish would throw. A finished-but-unfolded row
1200
+ // is folded (the charge lands on the shared buckets); a still-pending
1201
+ // one just has its reservation released.
1202
+ if (r.settled === false)
1203
+ await foldOne(ctx, r);
1204
+ else if (r.status === "pending")
1205
+ await releaseReservation(ctx, r);
1067
1206
  await deleteRequestTags(ctx, r._id);
1068
1207
  await ctx.db.delete(r._id);
1069
1208
  }
@@ -1139,18 +1278,29 @@ export const getGlobalStatus = query({
1139
1278
  });
1140
1279
  export const setGlobalLimits = mutation({
1141
1280
  args: {
1142
- dailySpendLimitNanos: v.optional(v.number()),
1143
- lifetimeSpendLimitNanos: v.optional(v.number()),
1144
- enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
1281
+ // Absent = leave unchanged; explicit null = clear that limit. (A bare
1282
+ // v.optional(number) that always rebuilt the full patch would let a
1283
+ // one-field edit — exactly what the dashboard sends — silently wipe the
1284
+ // other global controls by patching them to undefined.)
1285
+ dailySpendLimitNanos: v.optional(v.union(v.number(), v.null())),
1286
+ lifetimeSpendLimitNanos: v.optional(v.union(v.number(), v.null())),
1287
+ enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"), v.null())),
1145
1288
  },
1146
1289
  returns: v.null(),
1147
1290
  handler: async (ctx, args) => {
1148
- // The settings fields are "global"-prefixed; map the friendly arg names.
1149
- const patch = {
1150
- globalDailySpendLimitNanos: args.dailySpendLimitNanos,
1151
- globalLifetimeSpendLimitNanos: args.lifetimeSpendLimitNanos,
1152
- globalEnforcement: args.enforcement,
1153
- };
1291
+ if (typeof args.dailySpendLimitNanos === "number")
1292
+ assertAmount(args.dailySpendLimitNanos, "dailySpendLimitNanos");
1293
+ if (typeof args.lifetimeSpendLimitNanos === "number")
1294
+ assertAmount(args.lifetimeSpendLimitNanos, "lifetimeSpendLimitNanos");
1295
+ // The settings fields are "global"-prefixed; map the friendly arg names,
1296
+ // touching ONLY the keys the caller actually passed. null -> clear.
1297
+ const patch = {};
1298
+ if ("dailySpendLimitNanos" in args)
1299
+ patch.globalDailySpendLimitNanos = args.dailySpendLimitNanos ?? undefined;
1300
+ if ("lifetimeSpendLimitNanos" in args)
1301
+ patch.globalLifetimeSpendLimitNanos = args.lifetimeSpendLimitNanos ?? undefined;
1302
+ if ("enforcement" in args)
1303
+ patch.globalEnforcement = args.enforcement ?? undefined;
1154
1304
  const existing = await getSettings(ctx);
1155
1305
  if (existing) {
1156
1306
  await ctx.db.patch(existing._id, patch);
@@ -1216,11 +1366,11 @@ export const setPrice = mutation({
1216
1366
  handler: async (ctx, args) => {
1217
1367
  // Negative prices would make costOf return a negative cost, which folds
1218
1368
  // into totals as a spend *refund* — pushing a user back under their cap.
1219
- if (args.inputNanosPerMTok < 0 ||
1220
- args.outputNanosPerMTok < 0 ||
1221
- (args.cachedNanosPerMTok ?? 0) < 0) {
1222
- throw new Error("Prices must be non-negative");
1223
- }
1369
+ // NaN/Infinity (allowed by v.number()) are just as corrupting — a NaN rate
1370
+ // poisons every settled cost for the model — so require finite integers.
1371
+ assertAmount(args.inputNanosPerMTok, "inputNanosPerMTok");
1372
+ assertAmount(args.outputNanosPerMTok, "outputNanosPerMTok");
1373
+ assertAmount(args.cachedNanosPerMTok, "cachedNanosPerMTok");
1224
1374
  const existing = await ctx.db
1225
1375
  .query("prices")
1226
1376
  .withIndex("model", (q) => q.eq("model", args.model))
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "support@convex.dev",
8
8
  "url": "https://github.com/get-convex/ai-budget/issues"
9
9
  },
10
- "version": "0.0.2-alpha.16",
10
+ "version": "0.0.2-alpha.18",
11
11
  "license": "Apache-2.0",
12
12
  "type": "module",
13
13
  "keywords": [
@@ -97,8 +97,18 @@ async function renderBuckets() {
97
97
  const dims = ["user", "action"];
98
98
  const state = { dimension: window.__dim ?? "" };
99
99
  const rows = await get("/buckets", state.dimension ? { dimension: state.dimension } : {});
100
- const grand = rows.reduce((s, b) => s + b.totalSpendNanos, 0);
101
- document.getElementById("total").textContent = rows.length + " buckets · " + usd(grand) + " total";
100
+ // Within ONE dimension each request bills exactly one bucket, so summing is a
101
+ // true total. Across dimensions a request bills several buckets (user + action
102
+ // + tags), so summing multi-counts — use the deployment-wide spend instead.
103
+ let totalText;
104
+ if (state.dimension) {
105
+ const grand = rows.reduce((s, b) => s + b.totalSpendNanos, 0);
106
+ totalText = rows.length + " buckets · " + usd(grand) + " total (" + state.dimension + ")";
107
+ } else {
108
+ const g = await get("/global", {});
109
+ totalText = rows.length + " buckets · " + usd(g.spentTotalNanos ?? 0) + " spent (deployment)";
110
+ }
111
+ document.getElementById("total").textContent = totalText;
102
112
  const dimSet = [...new Set(rows.map((b) => b.dimension).concat(dims))];
103
113
  const sel = el("select", { value: state.dimension, style: "width:140px",
104
114
  onchange: (e) => { window.__dim = e.target.value; render(); } },