@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.
@@ -20,6 +20,37 @@ import { ShardedCounter } from "@convex-dev/sharded-counter";
20
20
  const NANOS_PER_DOLLAR = 1e9;
21
21
  const fmtUsd = (nanos: number) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
22
22
 
23
+ // Convex's `v.number()` accepts NaN and ±Infinity. Those are poison here: a NaN
24
+ // cap or count silently defeats every `used > cap` comparison (NaN > x is
25
+ // false), so an unvalidated NaN would make admission fail OPEN and admit
26
+ // unlimited spend; +Infinity in totals is just as corrupting. Validate every
27
+ // externally-supplied accounting amount at the mutation boundary.
28
+ function assertAmount(
29
+ n: number | undefined,
30
+ name: string,
31
+ { signed = false }: { signed?: boolean } = {}
32
+ ) {
33
+ if (n === undefined) return;
34
+ if (!Number.isFinite(n) || !Number.isSafeInteger(n)) {
35
+ throw new Error(`${name} must be a finite safe integer (got ${n})`);
36
+ }
37
+ if (!signed && n < 0) throw new Error(`${name} must be nonnegative (got ${n})`);
38
+ }
39
+ function assertFraction(n: number | undefined, name: string) {
40
+ if (n === undefined) return;
41
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
42
+ throw new Error(`${name} must be a number in [0, 1] (got ${n})`);
43
+ }
44
+ }
45
+ // Coerce a caller/provider-supplied count to a finite nonnegative integer,
46
+ // mapping NaN/Infinity/garbage to 0 rather than poisoning downstream totals.
47
+ const safeCount = (n: number | undefined) =>
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);
53
+
23
54
  // Built-in attribution dimensions. `user` and `action` are always populated
24
55
  // from a request's userId/actionName; apps can add any other dimensions
25
56
  // (team, project, customer, env, …) as tags. These two names are reserved —
@@ -76,6 +107,16 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
76
107
  // audit table — and the sensitive content in it — from growing without bound.
77
108
  // Override per-deployment via setRetention.
78
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
79
120
 
80
121
  const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
81
122
  const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
@@ -91,12 +132,17 @@ const CACHE_DISCOUNT = 0.1;
91
132
  // bills real money. Charging the conservative max instead keeps the caps honest
92
133
  // (over-counting is the safe direction); admins can pin an exact price via
93
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.
94
140
  const CONSERVATIVE_PRICE = Object.values(DEFAULT_PRICES).reduce(
95
141
  (m, p) => ({
96
142
  input: Math.max(m.input, p.input),
97
143
  output: Math.max(m.output, p.output),
98
144
  }),
99
- { input: 0, output: 0 }
145
+ { input: 20_000_000_000, output: 100_000_000_000 }
100
146
  );
101
147
 
102
148
  async function getPrice(ctx: MutationCtx, model: string) {
@@ -473,6 +519,11 @@ export const startRequest = mutation({
473
519
  if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
474
520
  throw new Error("reserveTtlMs must be finite and nonnegative");
475
521
  }
522
+ // Infinity/NaN here is catastrophic: it's reserved onto the bucket, and a
523
+ // later release computes `Infinity - Infinity = NaN`, leaving the reserved
524
+ // fields NaN forever — after which every `used > cap` check is `NaN > cap`
525
+ // (false) and the bucket admits unlimited spend. Reject it up front.
526
+ assertAmount(args.estimatedCostNanos, "estimatedCostNanos");
476
527
  const extraTags = sanitizeExtraTags(args.tags);
477
528
  // Record the blocked attempt and return a rejection (throwing would roll
478
529
  // back the record). `persist` is false for the high-frequency-by-design
@@ -683,8 +734,14 @@ export const startRequest = mutation({
683
734
  }
684
735
 
685
736
  // Consume only after every admission check succeeds, in the same
686
- // transaction as the reservations and request insert. An unexpected
687
- // rejection throws to roll back all previously consumed dimensions.
737
+ // transaction as the reservations and request insert. `throws: true` is
738
+ // deliberate, not a rough edge: we already `.check`ed every bucket above in
739
+ // this same serializable transaction, so a `.limit` here cannot fail on a
740
+ // bucket that passed check — and if it somehow did (or a later bucket did),
741
+ // throwing rolls back the WHOLE transaction, including the rate we already
742
+ // consumed on earlier buckets. Converting this to a graceful `{allowed:false}`
743
+ // return would COMMIT the partial consumption and leak rate capacity, so keep
744
+ // the throw.
688
745
  for (const b of buckets) {
689
746
  if (b.requestsPerMinute !== undefined) {
690
747
  await requestRateLimiter.limit(ctx, "requests", {
@@ -771,7 +828,10 @@ export const finishRequest = mutation({
771
828
  returns: v.object({ costNanos: v.number() }),
772
829
  handler: async (ctx, args) => {
773
830
  const request = await ctx.db.get(args.requestId);
774
- if (!request) throw new Error("Unknown request");
831
+ // The request may be gone — retention purged it, or the owning bucket was
832
+ // deleted. A late/duplicate webhook must be an idempotent no-op, not a 500
833
+ // (the caller can't do anything useful with the error, and it triggers retries).
834
+ if (!request) return { costNanos: 0 };
775
835
 
776
836
  // Expiry releases capacity, but is not evidence that the provider charged
777
837
  // nothing. Accept one final result even after expiry; duplicates stay no-ops.
@@ -779,26 +839,36 @@ export const finishRequest = mutation({
779
839
  return { costNanos: request.costNanos ?? 0 };
780
840
  }
781
841
 
782
- // Clamp caller-supplied token counts: negatives would produce negative cost
783
- // and could refund a user below their cap.
784
- const promptTokens = Math.max(0, args.promptTokens ?? 0);
785
- const completionTokens = Math.max(0, args.completionTokens ?? 0);
786
- const cachedTokens = Math.min(promptTokens, Math.max(0, args.cachedTokens ?? 0));
842
+ // Coerce caller/provider-supplied token counts to finite nonnegative
843
+ // integers: negatives would refund below a cap, and NaN/Infinity (which
844
+ // v.number() allows) would poison every downstream total and cap check.
845
+ const promptTokens = safeCount(args.promptTokens);
846
+ const completionTokens = safeCount(args.completionTokens);
847
+ const cachedTokens = Math.min(promptTokens, safeCount(args.cachedTokens));
787
848
  // Prefer an authoritative gateway cost when supplied (it already includes
788
849
  // tool fees); otherwise price from tokens — discounting the cached
789
- // (prompt-cache-read) slice — plus any server-tool per-call fees.
850
+ // (prompt-cache-read) slice — plus any server-tool per-call fees. Require it
851
+ // finite: +Infinity passes a bare `>= 0` and would corrupt the totals.
790
852
  let costNanos: number;
791
- if (args.costNanos !== undefined && args.costNanos >= 0) {
853
+ if (args.costNanos !== undefined && Number.isFinite(args.costNanos) && args.costNanos >= 0) {
792
854
  costNanos = Math.round(args.costNanos);
793
855
  } else {
794
856
  const settings = await getSettings(ctx);
795
- costNanos =
857
+ const priced =
796
858
  settleCost(
797
859
  promptTokens,
798
860
  cachedTokens,
799
861
  completionTokens,
800
862
  await getPrice(ctx, request.model)
801
863
  ) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
864
+ // Fail closed: if the settle carried NO usable cost signal — no tokens and
865
+ // no priced server-tool fee (an unpriced tool like `video_seconds`, or a
866
+ // bare settle()) — fall back to the reserved estimate rather than recording
867
+ // $0. Known-cost calls (image/video/audio) set estimatedCostNanos at
868
+ // reserve time precisely so this floor is their real cost. A caller that
869
+ // truly wants $0 passes an explicit authoritative costNanos: 0 above.
870
+ const noSignal = promptTokens === 0 && completionTokens === 0 && priced === 0;
871
+ costNanos = noSignal ? (request.estimatedNanos ?? 0) : priced;
802
872
  }
803
873
 
804
874
  // Durable write to the request's OWN row only — uncontended, so it always
@@ -841,13 +911,13 @@ async function releaseReservation(ctx: MutationCtx, req: Doc<"requests">) {
841
911
  const b = await getBucketDoc(ctx, t.dimension, t.value);
842
912
  if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b))) continue;
843
913
  await ctx.db.patch(b._id, {
844
- reservedTodayNanos: Math.max(0, (b.reservedTodayNanos ?? 0) - (b.dayStamp === day ? cost : 0)),
845
- reservedMonthNanos: Math.max(0, (b.reservedMonthNanos ?? 0) - (b.monthStamp === month ? cost : 0)),
846
- reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - cost),
847
- reservedTodayTokens: Math.max(0, (b.reservedTodayTokens ?? 0) - (b.dayStamp === day ? tokens : 0)),
848
- reservedMonthTokens: Math.max(0, (b.reservedMonthTokens ?? 0) - (b.monthStamp === month ? tokens : 0)),
849
- reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - tokens),
850
- pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
914
+ reservedTodayNanos: Math.max(0, fin(b.reservedTodayNanos) - (b.dayStamp === day ? cost : 0)),
915
+ reservedMonthNanos: Math.max(0, fin(b.reservedMonthNanos) - (b.monthStamp === month ? cost : 0)),
916
+ reservedTotalNanos: Math.max(0, fin(b.reservedTotalNanos) - cost),
917
+ reservedTodayTokens: Math.max(0, fin(b.reservedTodayTokens) - (b.dayStamp === day ? tokens : 0)),
918
+ reservedMonthTokens: Math.max(0, fin(b.reservedMonthTokens) - (b.monthStamp === month ? tokens : 0)),
919
+ reservedTotalTokens: Math.max(0, fin(b.reservedTotalTokens) - tokens),
920
+ pendingCount: Math.max(0, fin(b.pendingCount) - 1),
851
921
  });
852
922
  }
853
923
  await ctx.db.patch(req._id, { reservationReleased: true });
@@ -905,29 +975,52 @@ export const foldTotals = internalMutation({
905
975
  // Backstop for both failure modes: folds finished requests whose scheduled fold
906
976
  // lost the retry race, and releases reservations for requests that never
907
977
  // settled (their action crashed). Runs on a cron.
978
+ // Cron entry: kick off each reconciliation phase as its OWN transaction so a
979
+ // failure in one (e.g. an oversized retention scan) can't stall the others, and
980
+ // so the hot fold path doesn't share a transaction with retention. Each phase
981
+ // self-reschedules while it has a full batch of backlog.
908
982
  export const reconcile = internalMutation({
909
983
  args: {},
910
- returns: v.object({
911
- folded: v.number(),
912
- expired: v.number(),
913
- purged: v.number(),
914
- }),
984
+ returns: v.null(),
985
+ handler: async (ctx) => {
986
+ await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
987
+ await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
988
+ await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
989
+ return null;
990
+ },
991
+ });
992
+
993
+ // Fold finished-but-unfolded requests whose scheduled fold lost the OCC race.
994
+ export const foldPhase = internalMutation({
995
+ args: {},
996
+ returns: v.object({ folded: v.number() }),
915
997
  handler: async (ctx) => {
916
998
  const toFold = await ctx.db
917
999
  .query("requests")
918
1000
  .withIndex("settled", (q) => q.eq("settled", false))
919
- .take(200);
1001
+ .take(RECONCILE_BATCH);
920
1002
  for (const req of toFold) await foldOne(ctx, req);
1003
+ if (toFold.length === RECONCILE_BATCH)
1004
+ await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
1005
+ return { folded: toFold.length };
1006
+ },
1007
+ });
921
1008
 
1009
+ // Release reservations for requests that never settled (their action crashed),
1010
+ // and lazily backfill deadlines on legacy pending rows.
1011
+ export const expirePhase = internalMutation({
1012
+ args: {},
1013
+ returns: v.object({ expired: v.number() }),
1014
+ handler: async (ctx) => {
922
1015
  // Lazily migrate old pending rows in bounded batches. Once indexed, long
923
1016
  // TTL jobs cannot hide expired jobs behind them in creation-time order.
924
1017
  const legacy = await ctx.db.query("requests").withIndex("status_expires", q =>
925
- q.eq("status", "pending").eq("expiresAt", undefined)).take(200);
1018
+ q.eq("status", "pending").eq("expiresAt", undefined)).take(RECONCILE_BATCH);
926
1019
  for (const req of legacy) {
927
1020
  await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
928
1021
  }
929
1022
  const candidates = await ctx.db.query("requests").withIndex("status_expires", q =>
930
- q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(200);
1023
+ q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(RECONCILE_BATCH);
931
1024
  for (const req of candidates) {
932
1025
  await releaseReservation(ctx, req);
933
1026
  await ctx.db.patch(req._id, {
@@ -935,37 +1028,58 @@ export const reconcile = internalMutation({
935
1028
  reservationExpired: true, expiresAt: undefined, settled: true,
936
1029
  });
937
1030
  }
938
- const expired = candidates.length;
1031
+ if (legacy.length === RECONCILE_BATCH || candidates.length === RECONCILE_BATCH)
1032
+ await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
1033
+ return { expired: candidates.length };
1034
+ },
1035
+ });
939
1036
 
940
- // Retention: delete terminal, fully-accounted request rows past the window.
1037
+ // Delete terminal, fully-accounted request rows past the retention window; purge
1038
+ // content from expired tombstones; and finally delete tombstones past the
1039
+ // late-settle horizon so they can't accumulate forever.
1040
+ export const retentionPhase = internalMutation({
1041
+ args: {},
1042
+ returns: v.object({ purged: v.number() }),
1043
+ handler: async (ctx) => {
941
1044
  const settings = await getSettings(ctx);
942
1045
  const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
1046
+ if (retentionMs <= 0) return { purged: 0 };
1047
+ const retentionCutoff = Date.now() - retentionMs;
943
1048
  let purged = 0;
944
- if (retentionMs > 0) {
945
- const retentionCutoff = Date.now() - retentionMs;
946
- // Query eligible states directly. Long-lived pending jobs and expired
947
- // billing tombstones must not repeatedly occupy the front of a scan.
948
- const old = [];
949
- for (const expiredFlag of [undefined, false]) {
950
- old.push(...await ctx.db.query("requests").withIndex("retention", q =>
951
- q.eq("reservationExpired", expiredFlag).eq("settled", true)
952
- .lt("_creationTime", retentionCutoff)).take(200));
953
- }
954
- old.push(...await ctx.db.query("requests").withIndex("status", q =>
955
- q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
956
- for (const req of old) {
1049
+ let more = false;
1050
+ const sweep = async (rows: Doc<"requests">[]) => {
1051
+ for (const req of rows) {
957
1052
  await deleteRequestTags(ctx, req._id);
958
1053
  await ctx.db.delete(req._id);
959
1054
  purged++;
960
1055
  }
961
- const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q =>
962
- q.eq("reservationExpired", true).eq("contentPurged", undefined)
963
- .lt("_creationTime", retentionCutoff)).take(200);
964
- for (const req of expiredContent) {
965
- await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
966
- }
1056
+ if (rows.length === RECONCILE_BATCH) more = true;
1057
+ };
1058
+ // Settled, non-expired terminal rows past the window.
1059
+ for (const expiredFlag of [undefined, false] as const) {
1060
+ await sweep(await ctx.db.query("requests").withIndex("retention", q =>
1061
+ q.eq("reservationExpired", expiredFlag).eq("settled", true)
1062
+ .lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
967
1063
  }
968
- return { folded: toFold.length, expired, purged };
1064
+ // Blocked attempts past the window.
1065
+ await sweep(await ctx.db.query("requests").withIndex("status", q =>
1066
+ q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
1067
+ // Expired billing tombstones past the late-settle horizon (content already
1068
+ // gone). Without this they'd live forever (one per crashed request).
1069
+ const tombstoneCutoff = Date.now() - Math.max(retentionMs, LATE_SETTLE_HORIZON_MS);
1070
+ await sweep(await ctx.db.query("requests").withIndex("retention", q =>
1071
+ q.eq("reservationExpired", true).eq("settled", true)
1072
+ .lt("_creationTime", tombstoneCutoff)).take(RECONCILE_BATCH));
1073
+ // Strip PII from expired tombstones still inside the horizon.
1074
+ const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q =>
1075
+ q.eq("reservationExpired", true).eq("contentPurged", undefined)
1076
+ .lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH);
1077
+ for (const req of expiredContent) {
1078
+ await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
1079
+ }
1080
+ if (expiredContent.length === RECONCILE_BATCH) more = true;
1081
+ if (more) await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
1082
+ return { purged };
969
1083
  },
970
1084
  });
971
1085
 
@@ -1115,10 +1229,15 @@ export const setBucketLimits = mutation({
1115
1229
  },
1116
1230
  returns: v.null(),
1117
1231
  handler: async (ctx, args) => {
1118
- if (args.requestsPerMinute !== undefined &&
1119
- (!Number.isSafeInteger(args.requestsPerMinute) || args.requestsPerMinute < 0)) {
1120
- throw new Error("requestsPerMinute must be a nonnegative safe integer");
1121
- }
1232
+ assertAmount(args.requestsPerMinute, "requestsPerMinute");
1233
+ assertAmount(args.maxConcurrent, "maxConcurrent");
1234
+ assertAmount(args.dailySpendLimitNanos, "dailySpendLimitNanos");
1235
+ assertAmount(args.monthlySpendLimitNanos, "monthlySpendLimitNanos");
1236
+ assertAmount(args.lifetimeSpendLimitNanos, "lifetimeSpendLimitNanos");
1237
+ assertAmount(args.dailyTokenLimit, "dailyTokenLimit");
1238
+ assertAmount(args.monthlyTokenLimit, "monthlyTokenLimit");
1239
+ assertAmount(args.lifetimeTokenLimit, "lifetimeTokenLimit");
1240
+ assertFraction(args.warnAtPct, "warnAtPct");
1122
1241
  const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
1123
1242
  const { dimension: _d, value: _v, ...limits } = args;
1124
1243
  await ctx.db.patch(bucket._id, limits);
@@ -1140,6 +1259,9 @@ export const bumpBucket = mutation({
1140
1259
  args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
1141
1260
  returns: v.null(),
1142
1261
  handler: async (ctx, { dimension, value, dailyNanos, monthlyNanos, lifetimeNanos }) => {
1262
+ assertAmount(dailyNanos, "dailyNanos");
1263
+ assertAmount(monthlyNanos, "monthlyNanos");
1264
+ assertAmount(lifetimeNanos, "lifetimeNanos");
1143
1265
  const bucket = await getOrCreateBucket(ctx, dimension, value);
1144
1266
  const today = dayStamp();
1145
1267
  const month = monthStamp();
@@ -1172,6 +1294,8 @@ export const adjustBucket = mutation({
1172
1294
  },
1173
1295
  returns: v.null(),
1174
1296
  handler: async (ctx, { dimension, value, deltaNanos, tokens, reason }) => {
1297
+ assertAmount(deltaNanos, "deltaNanos", { signed: true });
1298
+ assertAmount(tokens, "tokens", { signed: true });
1175
1299
  const b = await getOrCreateBucket(ctx, dimension, value);
1176
1300
  const today = dayStamp();
1177
1301
  const month = monthStamp();
@@ -1187,6 +1311,12 @@ export const adjustBucket = mutation({
1187
1311
  tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
1188
1312
  spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
1189
1313
  tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
1314
+ // Advancing the window here must also clear the OLD window's reserved
1315
+ // holds, or an in-flight request from the previous day/month would be
1316
+ // treated as reserving against the new window and its later release would
1317
+ // no longer match — stranding those reserved nanos/tokens.
1318
+ ...(dSame ? {} : { reservedTodayNanos: 0, reservedTodayTokens: 0 }),
1319
+ ...(mSame ? {} : { reservedMonthNanos: 0, reservedMonthTokens: 0 }),
1190
1320
  });
1191
1321
  await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
1192
1322
  await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
@@ -1253,6 +1383,15 @@ export const deleteBucket = mutation({
1253
1383
  .withIndex("userId", (q) => q.eq("userId", value))
1254
1384
  .take(DELETE_BATCH);
1255
1385
  for (const r of rows) {
1386
+ // Before dropping the row, free or settle any hold it placed on OTHER
1387
+ // (shared) buckets — an action/customer bucket this user's request
1388
+ // reserved against. Otherwise deleting the only row that could release
1389
+ // that hold strands the shared bucket's reservation + pendingCount
1390
+ // forever, and a late finish would throw. A finished-but-unfolded row
1391
+ // is folded (the charge lands on the shared buckets); a still-pending
1392
+ // one just has its reservation released.
1393
+ if (r.settled === false) await foldOne(ctx, r);
1394
+ else if (r.status === "pending") await releaseReservation(ctx, r);
1256
1395
  await deleteRequestTags(ctx, r._id);
1257
1396
  await ctx.db.delete(r._id);
1258
1397
  }
@@ -1335,18 +1474,31 @@ export const getGlobalStatus = query({
1335
1474
 
1336
1475
  export const setGlobalLimits = mutation({
1337
1476
  args: {
1338
- dailySpendLimitNanos: v.optional(v.number()),
1339
- lifetimeSpendLimitNanos: v.optional(v.number()),
1340
- enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
1477
+ // Absent = leave unchanged; explicit null = clear that limit. (A bare
1478
+ // v.optional(number) that always rebuilt the full patch would let a
1479
+ // one-field edit — exactly what the dashboard sends — silently wipe the
1480
+ // other global controls by patching them to undefined.)
1481
+ dailySpendLimitNanos: v.optional(v.union(v.number(), v.null())),
1482
+ lifetimeSpendLimitNanos: v.optional(v.union(v.number(), v.null())),
1483
+ enforcement: v.optional(
1484
+ v.union(v.literal("hard"), v.literal("soft"), v.null())
1485
+ ),
1341
1486
  },
1342
1487
  returns: v.null(),
1343
1488
  handler: async (ctx, args) => {
1344
- // The settings fields are "global"-prefixed; map the friendly arg names.
1345
- const patch = {
1346
- globalDailySpendLimitNanos: args.dailySpendLimitNanos,
1347
- globalLifetimeSpendLimitNanos: args.lifetimeSpendLimitNanos,
1348
- globalEnforcement: args.enforcement,
1349
- };
1489
+ if (typeof args.dailySpendLimitNanos === "number")
1490
+ assertAmount(args.dailySpendLimitNanos, "dailySpendLimitNanos");
1491
+ if (typeof args.lifetimeSpendLimitNanos === "number")
1492
+ assertAmount(args.lifetimeSpendLimitNanos, "lifetimeSpendLimitNanos");
1493
+ // The settings fields are "global"-prefixed; map the friendly arg names,
1494
+ // touching ONLY the keys the caller actually passed. null -> clear.
1495
+ const patch: Record<string, unknown> = {};
1496
+ if ("dailySpendLimitNanos" in args)
1497
+ patch.globalDailySpendLimitNanos = args.dailySpendLimitNanos ?? undefined;
1498
+ if ("lifetimeSpendLimitNanos" in args)
1499
+ patch.globalLifetimeSpendLimitNanos = args.lifetimeSpendLimitNanos ?? undefined;
1500
+ if ("enforcement" in args)
1501
+ patch.globalEnforcement = args.enforcement ?? undefined;
1350
1502
  const existing = await getSettings(ctx);
1351
1503
  if (existing) {
1352
1504
  await ctx.db.patch(existing._id, patch);
@@ -1415,13 +1567,11 @@ export const setPrice = mutation({
1415
1567
  handler: async (ctx, args) => {
1416
1568
  // Negative prices would make costOf return a negative cost, which folds
1417
1569
  // into totals as a spend *refund* — pushing a user back under their cap.
1418
- if (
1419
- args.inputNanosPerMTok < 0 ||
1420
- args.outputNanosPerMTok < 0 ||
1421
- (args.cachedNanosPerMTok ?? 0) < 0
1422
- ) {
1423
- throw new Error("Prices must be non-negative");
1424
- }
1570
+ // NaN/Infinity (allowed by v.number()) are just as corrupting — a NaN rate
1571
+ // poisons every settled cost for the model — so require finite integers.
1572
+ assertAmount(args.inputNanosPerMTok, "inputNanosPerMTok");
1573
+ assertAmount(args.outputNanosPerMTok, "outputNanosPerMTok");
1574
+ assertAmount(args.cachedNanosPerMTok, "cachedNanosPerMTok");
1425
1575
  const existing = await ctx.db
1426
1576
  .query("prices")
1427
1577
  .withIndex("model", (q) => q.eq("model", args.model))