@convex-dev/ai-budget 0.0.2-alpha.13 → 0.0.2-alpha.14
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 +7 -2
- package/dist/component/lib.js +16 -3
- package/package.json +1 -1
- package/src/component/lib.test.ts +45 -0
- package/src/component/lib.ts +23 -12
package/README.md
CHANGED
|
@@ -604,14 +604,19 @@ endpoints verbatim. In production:
|
|
|
604
604
|
|
|
605
605
|
`example/` is a full working demo: chat as different personas on the left; a live
|
|
606
606
|
admin panel on the right — the request audit log (inspect → edit → re-run, with
|
|
607
|
-
lineage), a users table (limits, soft toggle, block, bump),
|
|
607
|
+
lineage), a users table (limits, soft toggle, block, bump), per-action budgets,
|
|
608
|
+
and a **⚡ Burst** tab that fires N real concurrent AI requests against one
|
|
609
|
+
tightly-capped budget: watch reservations appear live, some requests get
|
|
610
|
+
admitted (with real settled costs), and the rest get atomically rejected by the
|
|
611
|
+
cap — the reserve-then-settle admission design, visible.
|
|
608
612
|
|
|
609
613
|

|
|
610
614
|

|
|
611
615
|
|
|
612
616
|
```sh
|
|
613
617
|
cd example
|
|
614
|
-
npm install
|
|
618
|
+
npm install # also links the repo-root node_modules the demo's
|
|
619
|
+
# ../../src component imports resolve through (postinstall)
|
|
615
620
|
npx convex dev # terminal 1 — provisions a dev deployment
|
|
616
621
|
npm run dev # terminal 2 — Vite app
|
|
617
622
|
```
|
package/dist/component/lib.js
CHANGED
|
@@ -324,7 +324,7 @@ export const startRequest = mutation({
|
|
|
324
324
|
// bloat the 60s rate-limit window read below.
|
|
325
325
|
const reject = async (code, reason, persist = true) => {
|
|
326
326
|
if (persist) {
|
|
327
|
-
await ctx.db.insert("requests", {
|
|
327
|
+
const requestId = await ctx.db.insert("requests", {
|
|
328
328
|
userId: args.userId,
|
|
329
329
|
actionName: args.actionName,
|
|
330
330
|
...(extraTags.length ? { tags: extraTags } : {}),
|
|
@@ -334,6 +334,15 @@ export const startRequest = mutation({
|
|
|
334
334
|
status: "blocked",
|
|
335
335
|
error: reason,
|
|
336
336
|
});
|
|
337
|
+
// Reverse-index the blocked attempt too, so tag-filtered request logs
|
|
338
|
+
// show rejections alongside admitted traffic.
|
|
339
|
+
for (const t of extraTags) {
|
|
340
|
+
await ctx.db.insert("requestTags", {
|
|
341
|
+
dimension: t.dimension,
|
|
342
|
+
value: t.value,
|
|
343
|
+
requestId,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
337
346
|
}
|
|
338
347
|
return { allowed: false, code, reason };
|
|
339
348
|
};
|
|
@@ -410,13 +419,17 @@ export const startRequest = mutation({
|
|
|
410
419
|
recentCount = recent.filter((r) => r.status !== "blocked").length;
|
|
411
420
|
}
|
|
412
421
|
else {
|
|
413
|
-
|
|
422
|
+
// Tag rows also cover persisted blocked attempts; fetch each request
|
|
423
|
+
// to exclude them, matching the user/action paths above.
|
|
424
|
+
const tagRows = await ctx.db
|
|
414
425
|
.query("requestTags")
|
|
415
426
|
.withIndex("dim_value", (q) => q
|
|
416
427
|
.eq("dimension", b.dimension)
|
|
417
428
|
.eq("value", b.value)
|
|
418
429
|
.gt("_creationTime", rateCutoff))
|
|
419
|
-
.take(limit)
|
|
430
|
+
.take(limit + 50);
|
|
431
|
+
const recent = await Promise.all(tagRows.map((t) => ctx.db.get(t.requestId)));
|
|
432
|
+
recentCount = recent.filter((r) => r !== null && r.status !== "blocked").length;
|
|
420
433
|
}
|
|
421
434
|
if (recentCount >= limit) {
|
|
422
435
|
const code = b.dimension === USER_DIM ? "rate_limit" : `${b.dimension}_rate_limit`;
|
package/package.json
CHANGED
|
@@ -330,6 +330,51 @@ describe("tag-filtered request log", () => {
|
|
|
330
330
|
expect(acme.length).toBe(1);
|
|
331
331
|
expect(acme[0].userId).toBe("u");
|
|
332
332
|
});
|
|
333
|
+
|
|
334
|
+
test("blocked attempts appear in the tag-filtered log", async () => {
|
|
335
|
+
const t = convexTest(schema, modules);
|
|
336
|
+
const tags = [{ dimension: "burst", value: "run-1" }];
|
|
337
|
+
// Cap the tag bucket below one request's reservation so the attempt is
|
|
338
|
+
// budget-blocked (a persisted rejection).
|
|
339
|
+
await t.mutation(api.lib.setBucketLimits, {
|
|
340
|
+
dimension: "burst",
|
|
341
|
+
value: "run-1",
|
|
342
|
+
lifetimeSpendLimitNanos: 1_000,
|
|
343
|
+
});
|
|
344
|
+
const r = await start(t, { userId: "u", tags });
|
|
345
|
+
expect(r.allowed).toBe(false);
|
|
346
|
+
const log = await t.query(api.lib.listRequests, {
|
|
347
|
+
dimension: "burst",
|
|
348
|
+
value: "run-1",
|
|
349
|
+
});
|
|
350
|
+
expect(log.length).toBe(1);
|
|
351
|
+
expect(log[0].status).toBe("blocked");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("persisted blocked attempts don't consume a custom-tag rate limit", async () => {
|
|
355
|
+
const t = convexTest(schema, modules);
|
|
356
|
+
await t.mutation(api.lib.setBucketLimits, {
|
|
357
|
+
dimension: "customer",
|
|
358
|
+
value: "acme",
|
|
359
|
+
requestsPerMinute: 1,
|
|
360
|
+
// also cap spend so attempts get budget-blocked (persisted) first
|
|
361
|
+
lifetimeSpendLimitNanos: 1_000,
|
|
362
|
+
});
|
|
363
|
+
const tags = [{ dimension: "customer", value: "acme" }];
|
|
364
|
+
// A budget-blocked (persisted) attempt writes a requestTags row…
|
|
365
|
+
const blocked = await start(t, { userId: "u1", tags });
|
|
366
|
+
expect(blocked.allowed).toBe(false);
|
|
367
|
+
expect(blocked.code).toBe("customer_lifetime_spend_limit");
|
|
368
|
+
// …which must NOT count toward the 1/min rate limit. Lift the spend cap:
|
|
369
|
+
// with no admitted requests in the window, the next request goes through.
|
|
370
|
+
await t.mutation(api.lib.setBucketLimits, {
|
|
371
|
+
dimension: "customer",
|
|
372
|
+
value: "acme",
|
|
373
|
+
lifetimeSpendLimitNanos: 1_000_000_000,
|
|
374
|
+
});
|
|
375
|
+
const next = await start(t, { userId: "u2", tags });
|
|
376
|
+
expect(next.allowed).toBe(true);
|
|
377
|
+
});
|
|
333
378
|
});
|
|
334
379
|
|
|
335
380
|
describe("tagged attribution buckets", () => {
|
package/src/component/lib.ts
CHANGED
|
@@ -440,7 +440,7 @@ export const startRequest = mutation({
|
|
|
440
440
|
// bloat the 60s rate-limit window read below.
|
|
441
441
|
const reject = async (code: string, reason: string, persist = true) => {
|
|
442
442
|
if (persist) {
|
|
443
|
-
await ctx.db.insert("requests", {
|
|
443
|
+
const requestId = await ctx.db.insert("requests", {
|
|
444
444
|
userId: args.userId,
|
|
445
445
|
actionName: args.actionName,
|
|
446
446
|
...(extraTags.length ? { tags: extraTags } : {}),
|
|
@@ -450,6 +450,15 @@ export const startRequest = mutation({
|
|
|
450
450
|
status: "blocked" as const,
|
|
451
451
|
error: reason,
|
|
452
452
|
});
|
|
453
|
+
// Reverse-index the blocked attempt too, so tag-filtered request logs
|
|
454
|
+
// show rejections alongside admitted traffic.
|
|
455
|
+
for (const t of extraTags) {
|
|
456
|
+
await ctx.db.insert("requestTags", {
|
|
457
|
+
dimension: t.dimension,
|
|
458
|
+
value: t.value,
|
|
459
|
+
requestId,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
453
462
|
}
|
|
454
463
|
return { allowed: false as const, code, reason };
|
|
455
464
|
};
|
|
@@ -545,17 +554,19 @@ export const startRequest = mutation({
|
|
|
545
554
|
.take(limit + 50);
|
|
546
555
|
recentCount = recent.filter((r) => r.status !== "blocked").length;
|
|
547
556
|
} else {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
557
|
+
// Tag rows also cover persisted blocked attempts; fetch each request
|
|
558
|
+
// to exclude them, matching the user/action paths above.
|
|
559
|
+
const tagRows = await ctx.db
|
|
560
|
+
.query("requestTags")
|
|
561
|
+
.withIndex("dim_value", (q) =>
|
|
562
|
+
q
|
|
563
|
+
.eq("dimension", b.dimension)
|
|
564
|
+
.eq("value", b.value)
|
|
565
|
+
.gt("_creationTime", rateCutoff)
|
|
566
|
+
)
|
|
567
|
+
.take(limit + 50);
|
|
568
|
+
const recent = await Promise.all(tagRows.map((t) => ctx.db.get(t.requestId)));
|
|
569
|
+
recentCount = recent.filter((r) => r !== null && r.status !== "blocked").length;
|
|
559
570
|
}
|
|
560
571
|
|
|
561
572
|
if (recentCount >= limit) {
|