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

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.
@@ -332,8 +332,8 @@ describe("per-bucket rate limits", () => {
332
332
  const t = initTest();
333
333
  await setUserLimits(t, "u", { requestsPerMinute: 0 });
334
334
  expect((await start(t, { userId: "u" })).code).toBe("rate_limit");
335
- for (const requestsPerMinute of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) {
336
- await expect(setUserLimits(t, "u", { requestsPerMinute })).rejects.toThrow("nonnegative safe integer");
335
+ for (const requestsPerMinute of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, NaN, Infinity]) {
336
+ await expect(setUserLimits(t, "u", { requestsPerMinute })).rejects.toThrow(/requestsPerMinute/);
337
337
  }
338
338
  });
339
339
 
@@ -533,15 +533,17 @@ describe("model policy", () => {
533
533
  });
534
534
 
535
535
  describe("D-02 pricing validation", () => {
536
- test("setPrice rejects negative rates", async () => {
537
- const t = initTest();
538
- await expect(
539
- t.mutation(api.lib.setPrice, {
540
- model: "x/y",
541
- inputNanosPerMTok: -1,
542
- outputNanosPerMTok: 5,
543
- })
544
- ).rejects.toThrow(/non-negative/);
536
+ test("setPrice rejects negative and non-finite rates", async () => {
537
+ const t = initTest();
538
+ for (const inputNanosPerMTok of [-1, NaN, Infinity, 0.5]) {
539
+ await expect(
540
+ t.mutation(api.lib.setPrice, {
541
+ model: "x/y",
542
+ inputNanosPerMTok,
543
+ outputNanosPerMTok: 5,
544
+ })
545
+ ).rejects.toThrow(/inputNanosPerMTok/);
546
+ }
545
547
  });
546
548
  });
547
549
 
@@ -707,3 +709,85 @@ test("delayed folding attributes spend to completion day and leaves newer holds
707
709
  expect(history[0].spendNanos).toBe(50);
708
710
  } finally { vi.useRealTimers(); }
709
711
  });
712
+
713
+ describe("v1 hardening", () => {
714
+ test("setGlobalLimits: a one-field update preserves the other global limits", async () => {
715
+ const t = initTest();
716
+ await t.mutation(api.lib.setGlobalLimits, {
717
+ dailySpendLimitNanos: 100,
718
+ lifetimeSpendLimitNanos: 500,
719
+ enforcement: "soft",
720
+ });
721
+ // Update ONLY the daily cap — must not wipe lifetime/enforcement.
722
+ await t.mutation(api.lib.setGlobalLimits, { dailySpendLimitNanos: 200 });
723
+ const g = await t.query(api.lib.getGlobalStatus, {});
724
+ expect(g.dailySpendLimitNanos).toBe(200);
725
+ expect(g.lifetimeSpendLimitNanos).toBe(500);
726
+ expect(g.enforcement).toBe("soft");
727
+ // Explicit null clears just that field.
728
+ await t.mutation(api.lib.setGlobalLimits, { lifetimeSpendLimitNanos: null });
729
+ const after = await t.query(api.lib.getGlobalStatus, {});
730
+ expect(after.lifetimeSpendLimitNanos).toBe(null);
731
+ expect(after.dailySpendLimitNanos).toBe(200);
732
+ });
733
+
734
+ test("finishRequest on a missing/deleted request is a graceful no-op", async () => {
735
+ const t = initTest();
736
+ const r = await start(t, { userId: "u" });
737
+ await t.mutation(api.lib.deleteBucket, { dimension: "user", value: "u" });
738
+ // The request row is gone; a late/duplicate webhook must not throw.
739
+ const out = await t.mutation(api.lib.finishRequest, {
740
+ requestId: r.requestId,
741
+ costNanos: 1_000_000,
742
+ });
743
+ expect(out.costNanos).toBe(0);
744
+ });
745
+
746
+ test("deleting a user releases holds it placed on a shared bucket", async () => {
747
+ const t = initTest();
748
+ // A shared action bucket with a cap, so requests reserve against it.
749
+ await t.mutation(api.lib.setBucketLimits, {
750
+ dimension: "action",
751
+ value: "shared",
752
+ lifetimeSpendLimitNanos: 1_000_000_000,
753
+ });
754
+ // A pending (unsettled) request from user "u" attributed to that action.
755
+ await start(t, { userId: "u", actionName: "shared" });
756
+ let action = await bucketOf(t, "action", "shared");
757
+ expect(action.reservedTotalNanos).toBeGreaterThan(0);
758
+ expect(action.pendingCount).toBe(1);
759
+ // Deleting the user must free the shared bucket's hold, not strand it.
760
+ await t.mutation(api.lib.deleteBucket, { dimension: "user", value: "u" });
761
+ action = await bucketOf(t, "action", "shared");
762
+ expect(action.reservedTotalNanos ?? 0).toBe(0);
763
+ expect(action.pendingCount ?? 0).toBe(0);
764
+ });
765
+
766
+ test("a NaN/Infinity cost or token count cannot poison bucket totals", async () => {
767
+ const t = initTest();
768
+ const r = await start(t, { userId: "u" });
769
+ await t.mutation(api.lib.finishRequest, {
770
+ requestId: r.requestId,
771
+ costNanos: NaN, // ignored (not finite) -> token pricing
772
+ promptTokens: NaN, // coerced to 0
773
+ completionTokens: 5,
774
+ });
775
+ vi.useFakeTimers();
776
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
777
+ vi.useRealTimers();
778
+ const u = await userOf(t, "u");
779
+ expect(Number.isFinite(u.totalSpendNanos)).toBe(true);
780
+ expect(Number.isFinite(u.spendTodayNanos)).toBe(true);
781
+ expect(u.totalSpendNanos).toBeGreaterThanOrEqual(0);
782
+ });
783
+
784
+ test("a NaN limit is rejected rather than admitting unlimited spend", async () => {
785
+ const t = initTest();
786
+ await expect(
787
+ setUserLimits(t, "u", { dailySpendLimitNanos: NaN })
788
+ ).rejects.toThrow(/dailySpendLimitNanos/);
789
+ await expect(
790
+ setUserLimits(t, "u", { dailySpendLimitNanos: Infinity })
791
+ ).rejects.toThrow(/dailySpendLimitNanos/);
792
+ });
793
+ });
@@ -20,6 +20,33 @@ 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
+
23
50
  // Built-in attribution dimensions. `user` and `action` are always populated
24
51
  // from a request's userId/actionName; apps can add any other dimensions
25
52
  // (team, project, customer, env, …) as tags. These two names are reserved —
@@ -683,8 +710,14 @@ export const startRequest = mutation({
683
710
  }
684
711
 
685
712
  // 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.
713
+ // transaction as the reservations and request insert. `throws: true` is
714
+ // deliberate, not a rough edge: we already `.check`ed every bucket above in
715
+ // this same serializable transaction, so a `.limit` here cannot fail on a
716
+ // bucket that passed check — and if it somehow did (or a later bucket did),
717
+ // throwing rolls back the WHOLE transaction, including the rate we already
718
+ // consumed on earlier buckets. Converting this to a graceful `{allowed:false}`
719
+ // return would COMMIT the partial consumption and leak rate capacity, so keep
720
+ // the throw.
688
721
  for (const b of buckets) {
689
722
  if (b.requestsPerMinute !== undefined) {
690
723
  await requestRateLimiter.limit(ctx, "requests", {
@@ -771,7 +804,10 @@ export const finishRequest = mutation({
771
804
  returns: v.object({ costNanos: v.number() }),
772
805
  handler: async (ctx, args) => {
773
806
  const request = await ctx.db.get(args.requestId);
774
- if (!request) throw new Error("Unknown request");
807
+ // The request may be gone — retention purged it, or the owning bucket was
808
+ // deleted. A late/duplicate webhook must be an idempotent no-op, not a 500
809
+ // (the caller can't do anything useful with the error, and it triggers retries).
810
+ if (!request) return { costNanos: 0 };
775
811
 
776
812
  // Expiry releases capacity, but is not evidence that the provider charged
777
813
  // nothing. Accept one final result even after expiry; duplicates stay no-ops.
@@ -779,16 +815,18 @@ export const finishRequest = mutation({
779
815
  return { costNanos: request.costNanos ?? 0 };
780
816
  }
781
817
 
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));
818
+ // Coerce caller/provider-supplied token counts to finite nonnegative
819
+ // integers: negatives would refund below a cap, and NaN/Infinity (which
820
+ // v.number() allows) would poison every downstream total and cap check.
821
+ const promptTokens = safeCount(args.promptTokens);
822
+ const completionTokens = safeCount(args.completionTokens);
823
+ const cachedTokens = Math.min(promptTokens, safeCount(args.cachedTokens));
787
824
  // Prefer an authoritative gateway cost when supplied (it already includes
788
825
  // tool fees); otherwise price from tokens — discounting the cached
789
- // (prompt-cache-read) slice — plus any server-tool per-call fees.
826
+ // (prompt-cache-read) slice — plus any server-tool per-call fees. Require it
827
+ // finite: +Infinity passes a bare `>= 0` and would corrupt the totals.
790
828
  let costNanos: number;
791
- if (args.costNanos !== undefined && args.costNanos >= 0) {
829
+ if (args.costNanos !== undefined && Number.isFinite(args.costNanos) && args.costNanos >= 0) {
792
830
  costNanos = Math.round(args.costNanos);
793
831
  } else {
794
832
  const settings = await getSettings(ctx);
@@ -1115,10 +1153,15 @@ export const setBucketLimits = mutation({
1115
1153
  },
1116
1154
  returns: v.null(),
1117
1155
  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
- }
1156
+ assertAmount(args.requestsPerMinute, "requestsPerMinute");
1157
+ assertAmount(args.maxConcurrent, "maxConcurrent");
1158
+ assertAmount(args.dailySpendLimitNanos, "dailySpendLimitNanos");
1159
+ assertAmount(args.monthlySpendLimitNanos, "monthlySpendLimitNanos");
1160
+ assertAmount(args.lifetimeSpendLimitNanos, "lifetimeSpendLimitNanos");
1161
+ assertAmount(args.dailyTokenLimit, "dailyTokenLimit");
1162
+ assertAmount(args.monthlyTokenLimit, "monthlyTokenLimit");
1163
+ assertAmount(args.lifetimeTokenLimit, "lifetimeTokenLimit");
1164
+ assertFraction(args.warnAtPct, "warnAtPct");
1122
1165
  const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
1123
1166
  const { dimension: _d, value: _v, ...limits } = args;
1124
1167
  await ctx.db.patch(bucket._id, limits);
@@ -1140,6 +1183,9 @@ export const bumpBucket = mutation({
1140
1183
  args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
1141
1184
  returns: v.null(),
1142
1185
  handler: async (ctx, { dimension, value, dailyNanos, monthlyNanos, lifetimeNanos }) => {
1186
+ assertAmount(dailyNanos, "dailyNanos");
1187
+ assertAmount(monthlyNanos, "monthlyNanos");
1188
+ assertAmount(lifetimeNanos, "lifetimeNanos");
1143
1189
  const bucket = await getOrCreateBucket(ctx, dimension, value);
1144
1190
  const today = dayStamp();
1145
1191
  const month = monthStamp();
@@ -1172,6 +1218,8 @@ export const adjustBucket = mutation({
1172
1218
  },
1173
1219
  returns: v.null(),
1174
1220
  handler: async (ctx, { dimension, value, deltaNanos, tokens, reason }) => {
1221
+ assertAmount(deltaNanos, "deltaNanos", { signed: true });
1222
+ assertAmount(tokens, "tokens", { signed: true });
1175
1223
  const b = await getOrCreateBucket(ctx, dimension, value);
1176
1224
  const today = dayStamp();
1177
1225
  const month = monthStamp();
@@ -1187,6 +1235,12 @@ export const adjustBucket = mutation({
1187
1235
  tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
1188
1236
  spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
1189
1237
  tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
1238
+ // Advancing the window here must also clear the OLD window's reserved
1239
+ // holds, or an in-flight request from the previous day/month would be
1240
+ // treated as reserving against the new window and its later release would
1241
+ // no longer match — stranding those reserved nanos/tokens.
1242
+ ...(dSame ? {} : { reservedTodayNanos: 0, reservedTodayTokens: 0 }),
1243
+ ...(mSame ? {} : { reservedMonthNanos: 0, reservedMonthTokens: 0 }),
1190
1244
  });
1191
1245
  await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
1192
1246
  await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
@@ -1253,6 +1307,15 @@ export const deleteBucket = mutation({
1253
1307
  .withIndex("userId", (q) => q.eq("userId", value))
1254
1308
  .take(DELETE_BATCH);
1255
1309
  for (const r of rows) {
1310
+ // Before dropping the row, free or settle any hold it placed on OTHER
1311
+ // (shared) buckets — an action/customer bucket this user's request
1312
+ // reserved against. Otherwise deleting the only row that could release
1313
+ // that hold strands the shared bucket's reservation + pendingCount
1314
+ // forever, and a late finish would throw. A finished-but-unfolded row
1315
+ // is folded (the charge lands on the shared buckets); a still-pending
1316
+ // one just has its reservation released.
1317
+ if (r.settled === false) await foldOne(ctx, r);
1318
+ else if (r.status === "pending") await releaseReservation(ctx, r);
1256
1319
  await deleteRequestTags(ctx, r._id);
1257
1320
  await ctx.db.delete(r._id);
1258
1321
  }
@@ -1335,18 +1398,31 @@ export const getGlobalStatus = query({
1335
1398
 
1336
1399
  export const setGlobalLimits = mutation({
1337
1400
  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"))),
1401
+ // Absent = leave unchanged; explicit null = clear that limit. (A bare
1402
+ // v.optional(number) that always rebuilt the full patch would let a
1403
+ // one-field edit — exactly what the dashboard sends — silently wipe the
1404
+ // other global controls by patching them to undefined.)
1405
+ dailySpendLimitNanos: v.optional(v.union(v.number(), v.null())),
1406
+ lifetimeSpendLimitNanos: v.optional(v.union(v.number(), v.null())),
1407
+ enforcement: v.optional(
1408
+ v.union(v.literal("hard"), v.literal("soft"), v.null())
1409
+ ),
1341
1410
  },
1342
1411
  returns: v.null(),
1343
1412
  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
- };
1413
+ if (typeof args.dailySpendLimitNanos === "number")
1414
+ assertAmount(args.dailySpendLimitNanos, "dailySpendLimitNanos");
1415
+ if (typeof args.lifetimeSpendLimitNanos === "number")
1416
+ assertAmount(args.lifetimeSpendLimitNanos, "lifetimeSpendLimitNanos");
1417
+ // The settings fields are "global"-prefixed; map the friendly arg names,
1418
+ // touching ONLY the keys the caller actually passed. null -> clear.
1419
+ const patch: Record<string, unknown> = {};
1420
+ if ("dailySpendLimitNanos" in args)
1421
+ patch.globalDailySpendLimitNanos = args.dailySpendLimitNanos ?? undefined;
1422
+ if ("lifetimeSpendLimitNanos" in args)
1423
+ patch.globalLifetimeSpendLimitNanos = args.lifetimeSpendLimitNanos ?? undefined;
1424
+ if ("enforcement" in args)
1425
+ patch.globalEnforcement = args.enforcement ?? undefined;
1350
1426
  const existing = await getSettings(ctx);
1351
1427
  if (existing) {
1352
1428
  await ctx.db.patch(existing._id, patch);
@@ -1415,13 +1491,11 @@ export const setPrice = mutation({
1415
1491
  handler: async (ctx, args) => {
1416
1492
  // Negative prices would make costOf return a negative cost, which folds
1417
1493
  // 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
- }
1494
+ // NaN/Infinity (allowed by v.number()) are just as corrupting — a NaN rate
1495
+ // poisons every settled cost for the model — so require finite integers.
1496
+ assertAmount(args.inputNanosPerMTok, "inputNanosPerMTok");
1497
+ assertAmount(args.outputNanosPerMTok, "outputNanosPerMTok");
1498
+ assertAmount(args.cachedNanosPerMTok, "cachedNanosPerMTok");
1425
1499
  const existing = await ctx.db
1426
1500
  .query("prices")
1427
1501
  .withIndex("model", (q) => q.eq("model", args.model))