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

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
@@ -282,6 +282,8 @@ ai.registerWebhook(http, {
282
282
 
283
283
  `begin` returns the admission result (it doesn't throw — check `allowed`).
284
284
  `settle` is idempotent (exactly-once server-side), so a retried webhook is safe.
285
+ The webhook resolver receives the original, unread `Request`, allowing signature
286
+ verification over the exact raw body; the parsed JSON comes from a clone.
285
287
 
286
288
  ### Replay
287
289
 
@@ -315,6 +317,25 @@ different dimensions. Each exposes:
315
317
  The identifier field is `userId` for `ai.users`, `name` for `ai.actions`, and
316
318
  `value` for `ai.tag(d)`. For example: `ai.tag("customer").setLimits(ctx, { value: "acme", … })`.
317
319
 
320
+ ### Request rate limits
321
+
322
+ `requestsPerMinute` uses `@convex-dev/rate-limiter` 0.4.0 with a transactional
323
+ **token bucket** for each user, action, or custom tag. A limit of 60 allows an
324
+ initial burst of 60 requests and then refills at one request per second, up to
325
+ 60. Zero blocks all requests. Only admitted requests consume capacity: a budget,
326
+ concurrency, or another bucket's rate rejection consumes none.
327
+
328
+ This replaces the previous rolling 60-second request-log count. On upgrading,
329
+ rate balances start full; historical requests are not imported. Budget balances,
330
+ reservations, and spend history are preserved. Deploy the component update to
331
+ mount its nested Rate Limiter component; applications do not register it separately.
332
+ Changing a rate keeps its existing balance (clamped to the new capacity when
333
+ checked); deleting and recreating a bucket starts a fresh balance.
334
+
335
+ Rate limits remain transactional. Rate Limiter's asynchronous mode is not enabled
336
+ here because simultaneous admissions must respect every configured bucket.
337
+ Global spend accounting continues to use the existing sharded counter.
338
+
318
339
  ### Setting caps
319
340
 
320
341
  ```ts
@@ -549,21 +570,26 @@ this design). `ai-budget` instead **reserves then settles**:
549
570
  schedules a fold of the real cost into the totals, releasing the reservation —
550
571
  so a request is never orphaned mid-flight.
551
572
  3. **Reconcile.** A once-a-minute cron folds any stragglers and releases
552
- reservations for requests that died before settling. Settlement is
553
- **exactly-once** (a terminal request is never re-folded), so a slow request the
554
- reconciler already swept can't double-count when it finally returns.
573
+ expired reservations using indexed deadlines. Expiry releases the hold but
574
+ leaves billing open: a late completion records its final charge once without
575
+ releasing the hold again. After content retention, unresolved requests retain
576
+ a small billing record with prompts and responses removed.
555
577
 
556
- Reservations are taken only on buckets that actually have a cap, so uncapped
557
- traffic never serializes this is what makes arbitrary `tags` cheap: a request
558
- reserves on one row per *capped* dimension it carries, and nothing else.
578
+ Requests record the exact bucket IDs and calendar windows they reserve.
579
+ Settlement cannot release another request's hold, including across midnight or
580
+ month boundaries. Admission reads separate policy documents; only capped buckets
581
+ require reads of accounting state. Reporting updates still share bucket totals,
582
+ but do not write the policy documents used by uncapped admission.
559
583
 
560
584
  **One admission rule, all scopes.** Every per-bucket cap — user, action, or any
561
585
  tag — runs through the *same* admission check: a request is admitted only when
562
586
  `committed + reserved + estimate ≤ cap` (bumps included) for **every** bucket it
563
587
  touches. Each per-bucket cap reserves on a single document, making concurrent
564
588
  admission atomic. The **global** killswitch is backed by a sharded counter for
565
- throughput, read as an eventually-consistent sum with no cross-request
566
- reservation, so concurrent global admissions can overshoot under a burst.
589
+ throughput. Its sum is transactional but excludes unfinished and not-yet-folded
590
+ requests and has no cross-request reservation, so global admissions can overshoot.
591
+ Global usage is recorded even when limits are disabled. Historical usage omitted
592
+ by older versions is not automatically reconstructed by this upgrade.
567
593
 
568
594
  Reservations are estimates, not provider-side maximum charges. If a response uses
569
595
  more tokens or costs more than estimated, settlement records the real amount and
@@ -604,14 +630,19 @@ endpoints verbatim. In production:
604
630
 
605
631
  `example/` is a full working demo: chat as different personas on the left; a live
606
632
  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.
633
+ lineage), a users table (limits, soft toggle, block, bump), per-action budgets,
634
+ and a **⚡ Burst** tab that fires N real concurrent AI requests against one
635
+ tightly-capped budget: watch reservations appear live, some requests get
636
+ admitted (with real settled costs), and the rest get atomically rejected by the
637
+ cap — the reserve-then-settle admission design, visible.
608
638
 
609
639
  ![Users & Limits admin table](docs/users.png)
610
640
  ![Actions & Budgets admin table](docs/actions.png)
611
641
 
612
642
  ```sh
613
643
  cd example
614
- npm install
644
+ npm install # also links the repo-root node_modules the demo's
645
+ # ../../src component imports resolve through (postinstall)
615
646
  npx convex dev # terminal 1 — provisions a dev deployment
616
647
  npm run dev # terminal 2 — Vite app
617
648
  ```
@@ -646,6 +677,16 @@ handles rather than taking a dependency on either sibling component.
646
677
 
647
678
  ---
648
679
 
680
+
681
+ ## Accounting upgrade notes
682
+
683
+ New request fields are optional for existing deployments. Policy documents are
684
+ created on first admission or limit update; pending requests without deadlines
685
+ are migrated in batches by reconciliation. Existing reservations without ownership
686
+ metadata use their creation period and current capped buckets as a compatibility
687
+ fallback. Exact historical hold ownership cannot be reconstructed if those caps
688
+ changed before the upgrade. New requests always store explicit ownership.
689
+
649
690
  ## Development
650
691
 
651
692
  ```sh
@@ -84,6 +84,7 @@ export type ChatResult = {
84
84
  };
85
85
  /** Limits/controls settable on any budget bucket (user, action, or tag). */
86
86
  export type BucketLimits = {
87
+ /** Token-bucket refill per minute and burst capacity; 0 blocks all requests. */
87
88
  requestsPerMinute?: number;
88
89
  maxConcurrent?: number;
89
90
  dailySpendLimitNanos?: number;
@@ -237,6 +238,14 @@ export declare class AIBudget {
237
238
  dimension: string;
238
239
  value: string;
239
240
  }[] | undefined;
241
+ heldBucketIds?: string[] | undefined;
242
+ reservationDay?: string | undefined;
243
+ reservationMonth?: string | undefined;
244
+ reservationReleased?: boolean | undefined;
245
+ reservationExpired?: boolean | undefined;
246
+ contentPurged?: boolean | undefined;
247
+ expiresAt?: number | undefined;
248
+ finishedAt?: number | undefined;
240
249
  estimatedNanos?: number | undefined;
241
250
  estimatedTokens?: number | undefined;
242
251
  unpricedModel?: boolean | undefined;
@@ -273,6 +282,14 @@ export declare class AIBudget {
273
282
  dimension: string;
274
283
  value: string;
275
284
  }[] | undefined;
285
+ heldBucketIds?: string[] | undefined;
286
+ reservationDay?: string | undefined;
287
+ reservationMonth?: string | undefined;
288
+ reservationReleased?: boolean | undefined;
289
+ reservationExpired?: boolean | undefined;
290
+ contentPurged?: boolean | undefined;
291
+ expiresAt?: number | undefined;
292
+ finishedAt?: number | undefined;
276
293
  estimatedNanos?: number | undefined;
277
294
  estimatedTokens?: number | undefined;
278
295
  unpricedModel?: boolean | undefined;
@@ -310,6 +327,14 @@ export declare class AIBudget {
310
327
  dimension: string;
311
328
  value: string;
312
329
  }[] | undefined;
330
+ heldBucketIds?: string[] | undefined;
331
+ reservationDay?: string | undefined;
332
+ reservationMonth?: string | undefined;
333
+ reservationReleased?: boolean | undefined;
334
+ reservationExpired?: boolean | undefined;
335
+ contentPurged?: boolean | undefined;
336
+ expiresAt?: number | undefined;
337
+ finishedAt?: number | undefined;
313
338
  estimatedNanos?: number | undefined;
314
339
  estimatedTokens?: number | undefined;
315
340
  unpricedModel?: boolean | undefined;
@@ -343,6 +368,14 @@ export declare class AIBudget {
343
368
  dimension: string;
344
369
  value: string;
345
370
  }[] | undefined;
371
+ heldBucketIds?: string[] | undefined;
372
+ reservationDay?: string | undefined;
373
+ reservationMonth?: string | undefined;
374
+ reservationReleased?: boolean | undefined;
375
+ reservationExpired?: boolean | undefined;
376
+ contentPurged?: boolean | undefined;
377
+ expiresAt?: number | undefined;
378
+ finishedAt?: number | undefined;
346
379
  estimatedNanos?: number | undefined;
347
380
  estimatedTokens?: number | undefined;
348
381
  unpricedModel?: boolean | undefined;
@@ -692,7 +692,7 @@ export class AIBudget {
692
692
  path,
693
693
  method: "POST",
694
694
  handler: httpActionGeneric(async (ctx, request) => {
695
- const body = await request.json().catch(() => ({}));
695
+ const body = await request.clone().json().catch(() => ({}));
696
696
  const settle = await opts.resolve(ctx, request, body);
697
697
  if (!settle)
698
698
  return new Response("ignored", { status: 202 });
@@ -33,5 +33,6 @@ export declare const api: FilterApi<typeof fullApi, FunctionReference<any, "publ
33
33
  export declare const internal: FilterApi<typeof fullApi, FunctionReference<any, "internal">>;
34
34
  export declare const components: {
35
35
  shardedCounter: import("@convex-dev/sharded-counter/_generated/component.js").ComponentApi<"shardedCounter">;
36
+ rateLimiter: import("@convex-dev/rate-limiter/_generated/component.js").ComponentApi<"rateLimiter">;
36
37
  };
37
38
  export {};
@@ -1,6 +1,8 @@
1
1
  import { defineComponent } from "convex/server";
2
2
  import shardedCounter from "@convex-dev/sharded-counter/convex.config";
3
+ import rateLimiter from "@convex-dev/rate-limiter/convex.config";
3
4
  const component = defineComponent("aiBudget");
4
5
  // Global spend totals use a sharded counter for high write throughput.
5
6
  component.use(shardedCounter);
7
+ component.use(rateLimiter);
6
8
  export default component;
@@ -58,6 +58,14 @@ export declare const lineage: import("convex/server").RegisteredQuery<"public",
58
58
  dimension: string;
59
59
  value: string;
60
60
  }[] | undefined;
61
+ heldBucketIds?: import("convex/values").GenericId<"buckets">[] | undefined;
62
+ reservationDay?: string | undefined;
63
+ reservationMonth?: string | undefined;
64
+ reservationReleased?: boolean | undefined;
65
+ reservationExpired?: boolean | undefined;
66
+ contentPurged?: boolean | undefined;
67
+ expiresAt?: number | undefined;
68
+ finishedAt?: number | undefined;
61
69
  estimatedNanos?: number | undefined;
62
70
  estimatedTokens?: number | undefined;
63
71
  unpricedModel?: boolean | undefined;
@@ -89,6 +97,14 @@ export declare const lineage: import("convex/server").RegisteredQuery<"public",
89
97
  dimension: string;
90
98
  value: string;
91
99
  }[] | undefined;
100
+ heldBucketIds?: import("convex/values").GenericId<"buckets">[] | undefined;
101
+ reservationDay?: string | undefined;
102
+ reservationMonth?: string | undefined;
103
+ reservationReleased?: boolean | undefined;
104
+ reservationExpired?: boolean | undefined;
105
+ contentPurged?: boolean | undefined;
106
+ expiresAt?: number | undefined;
107
+ finishedAt?: number | undefined;
92
108
  estimatedNanos?: number | undefined;
93
109
  estimatedTokens?: number | undefined;
94
110
  unpricedModel?: boolean | undefined;
@@ -123,6 +139,14 @@ export declare const getRequest: import("convex/server").RegisteredQuery<"public
123
139
  dimension: string;
124
140
  value: string;
125
141
  }[] | undefined;
142
+ heldBucketIds?: import("convex/values").GenericId<"buckets">[] | undefined;
143
+ reservationDay?: string | undefined;
144
+ reservationMonth?: string | undefined;
145
+ reservationReleased?: boolean | undefined;
146
+ reservationExpired?: boolean | undefined;
147
+ contentPurged?: boolean | undefined;
148
+ expiresAt?: number | undefined;
149
+ finishedAt?: number | undefined;
126
150
  estimatedNanos?: number | undefined;
127
151
  estimatedTokens?: number | undefined;
128
152
  unpricedModel?: boolean | undefined;
@@ -159,6 +183,14 @@ export declare const listRequests: import("convex/server").RegisteredQuery<"publ
159
183
  dimension: string;
160
184
  value: string;
161
185
  }[] | undefined;
186
+ heldBucketIds?: import("convex/values").GenericId<"buckets">[] | undefined;
187
+ reservationDay?: string | undefined;
188
+ reservationMonth?: string | undefined;
189
+ reservationReleased?: boolean | undefined;
190
+ reservationExpired?: boolean | undefined;
191
+ contentPurged?: boolean | undefined;
192
+ expiresAt?: number | undefined;
193
+ finishedAt?: number | undefined;
162
194
  estimatedNanos?: number | undefined;
163
195
  estimatedTokens?: number | undefined;
164
196
  unpricedModel?: boolean | undefined;