@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.
- package/README.md +49 -4
- package/dist/client/dashboard.js +12 -2
- package/dist/client/index.d.ts +53 -0
- package/dist/client/index.js +157 -59
- package/dist/component/lib.d.ts +3 -3
- package/dist/component/lib.js +101 -28
- package/package.json +1 -1
- package/src/client/dashboard.ts +12 -2
- package/src/client/index.ts +204 -54
- package/src/component/lib.test.ts +95 -11
- package/src/component/lib.ts +104 -30
|
@@ -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(
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
+
});
|
package/src/component/lib.ts
CHANGED
|
@@ -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.
|
|
687
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
783
|
-
//
|
|
784
|
-
|
|
785
|
-
const
|
|
786
|
-
const
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
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
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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))
|