@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 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), and per-action budgets.
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
  ![Users & Limits admin table](docs/users.png)
610
614
  ![Actions & Budgets admin table](docs/actions.png)
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
  ```
@@ -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
- recentCount = (await ctx.db
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)).length;
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
@@ -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.13",
10
+ "version": "0.0.2-alpha.14",
11
11
  "license": "Apache-2.0",
12
12
  "type": "module",
13
13
  "keywords": [
@@ -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", () => {
@@ -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
- recentCount = (
549
- await ctx.db
550
- .query("requestTags")
551
- .withIndex("dim_value", (q) =>
552
- q
553
- .eq("dimension", b.dimension)
554
- .eq("value", b.value)
555
- .gt("_creationTime", rateCutoff)
556
- )
557
- .take(limit)
558
- ).length;
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) {