@convex-dev/ai-budget 0.0.2-alpha.14 → 0.0.2-alpha.16

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
@@ -23,7 +23,7 @@ full audit log you can replay later.
23
23
  - [Setup](#setup)
24
24
  - [Quickstart](#quickstart)
25
25
  - [Concepts](#concepts) — dimensions, nanodollars, reserve→settle
26
- - [Generating text](#generating-text) — `chat`, `languageModel`, replay
26
+ - [Generating text](#generating-text) — `chat`, `languageModel`, `meter`, `decisions`, replay
27
27
  - [Budgets & limits](#budgets--limits) — set caps, bumps, credits, alerts
28
28
  - [Monitoring](#monitoring) — totals, spend history, the request log
29
29
  - [Deployment-wide controls](#deployment-wide-controls) — global cap, model policy, pricing, retention
@@ -251,6 +251,35 @@ await ai.meter(ctx,
251
251
  });
252
252
  ```
253
253
 
254
+ ### `ai.decisions` — structured decisions (Jev)
255
+
256
+ Budget the gateway's Decisions endpoint ([Jev](https://docs.typesafe.ai)) — typed
257
+ `choice` / `score` / `boolean` questions evaluated against a `state` — with the
258
+ same limits, audit log, cost tracking, and tags as `ai.chat`:
259
+
260
+ ```ts
261
+ const { answers, costNanos } = await ai.decisions(ctx, {
262
+ state: { ticket: "Customer cannot sign in" },
263
+ questions: {
264
+ priority: {
265
+ type: "choice",
266
+ instructions: "Choose the response priority",
267
+ criteria: { urgent: "Respond now", normal: "Respond today" },
268
+ },
269
+ needsReview: { type: "boolean", instructions: "Does a human need to review this?" },
270
+ },
271
+ tags: [{ dimension: "team", value: "support" }],
272
+ });
273
+ answers.priority.choice; // "urgent" | "normal"
274
+ answers.needsReview.probability;
275
+ ```
276
+
277
+ Sugar over `ai.meter`, so cost is the gateway's authoritative amount. Model
278
+ defaults to `defaultEvalModel` (`"typesafe/jev-1.13"`). Requires
279
+ `@convex-dev/ai-sdk-provider` ≥ 0.2.1 and an `ai` version with
280
+ `experimental_evaluate` — both imported lazily, so callers who don't use
281
+ `decisions` are unaffected.
282
+
254
283
  ### `ai.begin` / `ai.settle` — long async jobs (video)
255
284
 
256
285
  A video job is submit → wait minutes → poll/webhook → done, spanning multiple
@@ -282,6 +311,8 @@ ai.registerWebhook(http, {
282
311
 
283
312
  `begin` returns the admission result (it doesn't throw — check `allowed`).
284
313
  `settle` is idempotent (exactly-once server-side), so a retried webhook is safe.
314
+ The webhook resolver receives the original, unread `Request`, allowing signature
315
+ verification over the exact raw body; the parsed JSON comes from a clone.
285
316
 
286
317
  ### Replay
287
318
 
@@ -315,6 +346,25 @@ different dimensions. Each exposes:
315
346
  The identifier field is `userId` for `ai.users`, `name` for `ai.actions`, and
316
347
  `value` for `ai.tag(d)`. For example: `ai.tag("customer").setLimits(ctx, { value: "acme", … })`.
317
348
 
349
+ ### Request rate limits
350
+
351
+ `requestsPerMinute` uses `@convex-dev/rate-limiter` 0.4.0 with a transactional
352
+ **token bucket** for each user, action, or custom tag. A limit of 60 allows an
353
+ initial burst of 60 requests and then refills at one request per second, up to
354
+ 60. Zero blocks all requests. Only admitted requests consume capacity: a budget,
355
+ concurrency, or another bucket's rate rejection consumes none.
356
+
357
+ This replaces the previous rolling 60-second request-log count. On upgrading,
358
+ rate balances start full; historical requests are not imported. Budget balances,
359
+ reservations, and spend history are preserved. Deploy the component update to
360
+ mount its nested Rate Limiter component; applications do not register it separately.
361
+ Changing a rate keeps its existing balance (clamped to the new capacity when
362
+ checked); deleting and recreating a bucket starts a fresh balance.
363
+
364
+ Rate limits remain transactional. Rate Limiter's asynchronous mode is not enabled
365
+ here because simultaneous admissions must respect every configured bucket.
366
+ Global spend accounting continues to use the existing sharded counter.
367
+
318
368
  ### Setting caps
319
369
 
320
370
  ```ts
@@ -549,21 +599,26 @@ this design). `ai-budget` instead **reserves then settles**:
549
599
  schedules a fold of the real cost into the totals, releasing the reservation —
550
600
  so a request is never orphaned mid-flight.
551
601
  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.
602
+ expired reservations using indexed deadlines. Expiry releases the hold but
603
+ leaves billing open: a late completion records its final charge once without
604
+ releasing the hold again. After content retention, unresolved requests retain
605
+ a small billing record with prompts and responses removed.
555
606
 
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.
607
+ Requests record the exact bucket IDs and calendar windows they reserve.
608
+ Settlement cannot release another request's hold, including across midnight or
609
+ month boundaries. Admission reads separate policy documents; only capped buckets
610
+ require reads of accounting state. Reporting updates still share bucket totals,
611
+ but do not write the policy documents used by uncapped admission.
559
612
 
560
613
  **One admission rule, all scopes.** Every per-bucket cap — user, action, or any
561
614
  tag — runs through the *same* admission check: a request is admitted only when
562
615
  `committed + reserved + estimate ≤ cap` (bumps included) for **every** bucket it
563
616
  touches. Each per-bucket cap reserves on a single document, making concurrent
564
617
  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.
618
+ throughput. Its sum is transactional but excludes unfinished and not-yet-folded
619
+ requests and has no cross-request reservation, so global admissions can overshoot.
620
+ Global usage is recorded even when limits are disabled. Historical usage omitted
621
+ by older versions is not automatically reconstructed by this upgrade.
567
622
 
568
623
  Reservations are estimates, not provider-side maximum charges. If a response uses
569
624
  more tokens or costs more than estimated, settlement records the real amount and
@@ -651,6 +706,16 @@ handles rather than taking a dependency on either sibling component.
651
706
 
652
707
  ---
653
708
 
709
+
710
+ ## Accounting upgrade notes
711
+
712
+ New request fields are optional for existing deployments. Policy documents are
713
+ created on first admission or limit update; pending requests without deadlines
714
+ are migrated in batches by reconciliation. Existing reservations without ownership
715
+ metadata use their creation period and current capped buckets as a compatibility
716
+ fallback. Exact historical hold ownership cannot be reconstructed if those caps
717
+ changed before the upgrade. New requests always store explicit ownership.
718
+
654
719
  ## Development
655
720
 
656
721
  ```sh
@@ -39,6 +39,8 @@ export type SoftLimitInfo = BudgetEventInfo & {
39
39
  };
40
40
  export type AIBudgetOptions = {
41
41
  defaultModel?: string;
42
+ /** Default model for `decisions()` (the Decisions/"Jev" endpoint). */
43
+ defaultEvalModel?: string;
42
44
  /**
43
45
  * A *soft* limit was exceeded (request still allowed). Lets you surface budget
44
46
  * warnings even on the languageModel/Agent path where they can't be returned.
@@ -82,8 +84,19 @@ export type ChatResult = {
82
84
  /** Approaching-cap notices (empty unless a warnAtPct threshold was crossed). */
83
85
  notices: string[];
84
86
  };
87
+ /** The tracked result of a `decisions()` call: budgeting metadata plus the
88
+ * structured answers from the Decisions ("Jev") endpoint. */
89
+ export type DecisionResult = Omit<ChatResult, "text"> & {
90
+ /** Structured answers keyed by your question names (shape depends on each
91
+ * question type: `choice`, `score`, or `boolean`). */
92
+ answers: Record<string, any>;
93
+ /** The raw gateway response, including provider-specific fields (e.g.
94
+ * `confidence`) under `response.body`. */
95
+ response?: any;
96
+ };
85
97
  /** Limits/controls settable on any budget bucket (user, action, or tag). */
86
98
  export type BucketLimits = {
99
+ /** Token-bucket refill per minute and burst capacity; 0 blocks all requests. */
87
100
  requestsPerMinute?: number;
88
101
  maxConcurrent?: number;
89
102
  dailySpendLimitNanos?: number;
@@ -106,6 +119,7 @@ export type BumpArgs = {
106
119
  export declare class AIBudget {
107
120
  component: AIBudgetApi;
108
121
  defaultModel: string;
122
+ defaultEvalModel: string;
109
123
  private onSoftLimit?;
110
124
  private onThreshold?;
111
125
  private onLimitReached?;
@@ -207,6 +221,46 @@ export declare class AIBudget {
207
221
  /** Extra attribution dimensions to bill/limit (team, customer, env, …). */
208
222
  tags?: Tag[];
209
223
  }): Promise<ChatResult>;
224
+ /**
225
+ * Budget a structured decision through the AI Gateway's Decisions ("Jev")
226
+ * endpoint — sugar over `meter`. Evaluates typed `questions` (choice / score /
227
+ * boolean) about the `state` you provide, with the same reserve→settle
228
+ * limits, audit log, cost tracking, and per-tag attribution as `chat`. Call
229
+ * from an action. `userId` defaults to the authenticated caller.
230
+ *
231
+ * Requires `@convex-dev/ai-sdk-provider` >= 0.2.1 and an `ai` version that
232
+ * exposes `experimental_evaluate` (AI SDK 7's evaluation interface); both are
233
+ * imported lazily, so consumers who never call `decisions()` are unaffected.
234
+ *
235
+ * const { answers } = await ai.decisions(ctx, {
236
+ * state: { ticket: "Customer cannot sign in" },
237
+ * questions: {
238
+ * priority: { type: "choice", instructions: "...", criteria: { urgent: "...", normal: "..." } },
239
+ * needsReview: { type: "boolean", instructions: "..." },
240
+ * },
241
+ * });
242
+ * answers.priority.choice; // "urgent" | "normal"
243
+ */
244
+ decisions(ctx: RunMutationCtx, args: {
245
+ /** The evaluation model. Defaults to `defaultEvalModel` ("typesafe/jev-1.13"). */
246
+ model?: string;
247
+ /** Context the questions are evaluated against (a string or an object). */
248
+ state: unknown;
249
+ /** Typed questions (choice / score / boolean) keyed by name. */
250
+ questions: Record<string, unknown>;
251
+ /** Whom to bill. Defaults to the authenticated user (ctx.auth). */
252
+ userId?: string;
253
+ /** Attribute spend to this action name. Defaults to the calling action. */
254
+ action?: string;
255
+ /** Extra attribution dimensions to bill/limit (team, customer, env, …). */
256
+ tags?: Tag[];
257
+ /** Reserve this exact amount (nanodollars) up front — the decision cost
258
+ * isn't known before the call, so a hard cap is only exact with this. */
259
+ estimatedCostNanos?: number;
260
+ rerunOf?: string;
261
+ /** Cancel the underlying request. */
262
+ abortSignal?: AbortSignal;
263
+ }): Promise<DecisionResult>;
210
264
  /**
211
265
  * An AI SDK LanguageModel that enforces limits and records usage/cost for
212
266
  * `userId` on every call. Drop it into `generateText`, `streamText`, or the
@@ -237,6 +291,14 @@ export declare class AIBudget {
237
291
  dimension: string;
238
292
  value: string;
239
293
  }[] | undefined;
294
+ heldBucketIds?: string[] | undefined;
295
+ reservationDay?: string | undefined;
296
+ reservationMonth?: string | undefined;
297
+ reservationReleased?: boolean | undefined;
298
+ reservationExpired?: boolean | undefined;
299
+ contentPurged?: boolean | undefined;
300
+ expiresAt?: number | undefined;
301
+ finishedAt?: number | undefined;
240
302
  estimatedNanos?: number | undefined;
241
303
  estimatedTokens?: number | undefined;
242
304
  unpricedModel?: boolean | undefined;
@@ -273,6 +335,14 @@ export declare class AIBudget {
273
335
  dimension: string;
274
336
  value: string;
275
337
  }[] | undefined;
338
+ heldBucketIds?: string[] | undefined;
339
+ reservationDay?: string | undefined;
340
+ reservationMonth?: string | undefined;
341
+ reservationReleased?: boolean | undefined;
342
+ reservationExpired?: boolean | undefined;
343
+ contentPurged?: boolean | undefined;
344
+ expiresAt?: number | undefined;
345
+ finishedAt?: number | undefined;
276
346
  estimatedNanos?: number | undefined;
277
347
  estimatedTokens?: number | undefined;
278
348
  unpricedModel?: boolean | undefined;
@@ -310,6 +380,14 @@ export declare class AIBudget {
310
380
  dimension: string;
311
381
  value: string;
312
382
  }[] | undefined;
383
+ heldBucketIds?: string[] | undefined;
384
+ reservationDay?: string | undefined;
385
+ reservationMonth?: string | undefined;
386
+ reservationReleased?: boolean | undefined;
387
+ reservationExpired?: boolean | undefined;
388
+ contentPurged?: boolean | undefined;
389
+ expiresAt?: number | undefined;
390
+ finishedAt?: number | undefined;
313
391
  estimatedNanos?: number | undefined;
314
392
  estimatedTokens?: number | undefined;
315
393
  unpricedModel?: boolean | undefined;
@@ -343,6 +421,14 @@ export declare class AIBudget {
343
421
  dimension: string;
344
422
  value: string;
345
423
  }[] | undefined;
424
+ heldBucketIds?: string[] | undefined;
425
+ reservationDay?: string | undefined;
426
+ reservationMonth?: string | undefined;
427
+ reservationReleased?: boolean | undefined;
428
+ reservationExpired?: boolean | undefined;
429
+ contentPurged?: boolean | undefined;
430
+ expiresAt?: number | undefined;
431
+ finishedAt?: number | undefined;
346
432
  estimatedNanos?: number | undefined;
347
433
  estimatedTokens?: number | undefined;
348
434
  unpricedModel?: boolean | undefined;
@@ -115,12 +115,14 @@ function timingSafeEqual(a, b) {
115
115
  export class AIBudget {
116
116
  component;
117
117
  defaultModel;
118
+ defaultEvalModel;
118
119
  onSoftLimit;
119
120
  onThreshold;
120
121
  onLimitReached;
121
122
  constructor(component, options) {
122
123
  this.component = component;
123
124
  this.defaultModel = options?.defaultModel ?? "openai/gpt-4o-mini";
125
+ this.defaultEvalModel = options?.defaultEvalModel ?? "typesafe/jev-1.13";
124
126
  this.onSoftLimit = options?.onSoftLimit;
125
127
  this.onThreshold = options?.onThreshold;
126
128
  this.onLimitReached = options?.onLimitReached;
@@ -302,6 +304,72 @@ export class AIBudget {
302
304
  };
303
305
  });
304
306
  }
307
+ /**
308
+ * Budget a structured decision through the AI Gateway's Decisions ("Jev")
309
+ * endpoint — sugar over `meter`. Evaluates typed `questions` (choice / score /
310
+ * boolean) about the `state` you provide, with the same reserve→settle
311
+ * limits, audit log, cost tracking, and per-tag attribution as `chat`. Call
312
+ * from an action. `userId` defaults to the authenticated caller.
313
+ *
314
+ * Requires `@convex-dev/ai-sdk-provider` >= 0.2.1 and an `ai` version that
315
+ * exposes `experimental_evaluate` (AI SDK 7's evaluation interface); both are
316
+ * imported lazily, so consumers who never call `decisions()` are unaffected.
317
+ *
318
+ * const { answers } = await ai.decisions(ctx, {
319
+ * state: { ticket: "Customer cannot sign in" },
320
+ * questions: {
321
+ * priority: { type: "choice", instructions: "...", criteria: { urgent: "...", normal: "..." } },
322
+ * needsReview: { type: "boolean", instructions: "..." },
323
+ * },
324
+ * });
325
+ * answers.priority.choice; // "urgent" | "normal"
326
+ */
327
+ async decisions(ctx, args) {
328
+ const model = args.model ?? this.defaultEvalModel;
329
+ // `evaluate` is an experimental, version-gated export; import it lazily and
330
+ // untyped so consumers on an older `ai` (who never call this) aren't broken.
331
+ const evaluate = (await import("ai")).experimental_evaluate;
332
+ if (typeof evaluate !== "function") {
333
+ throw new Error("ai-budget: decisions() needs `experimental_evaluate` from the `ai` " +
334
+ "package (AI SDK 7's evaluation interface). Upgrade `ai` to a " +
335
+ "version that exports it.");
336
+ }
337
+ // Likewise, `evaluationModel` exists on @convex-dev/ai-sdk-provider >= 0.2.1.
338
+ const evaluationModel = convexGateway.evaluationModel;
339
+ if (typeof evaluationModel !== "function") {
340
+ throw new Error("ai-budget: decisions() needs `convexGateway.evaluationModel` from " +
341
+ "@convex-dev/ai-sdk-provider >= 0.2.1. Upgrade the provider.");
342
+ }
343
+ let decision;
344
+ const result = await this.meter(ctx, {
345
+ model,
346
+ // Store the structured request for audit/replay.
347
+ messages: [
348
+ {
349
+ role: "user",
350
+ content: JSON.stringify({ state: args.state, questions: args.questions }),
351
+ },
352
+ ],
353
+ userId: args.userId,
354
+ action: args.action,
355
+ tags: args.tags,
356
+ estimatedCostNanos: args.estimatedCostNanos,
357
+ rerunOf: args.rerunOf,
358
+ }, async () => {
359
+ decision = await evaluate({
360
+ model: evaluationModel(model),
361
+ state: args.state,
362
+ questions: args.questions,
363
+ ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
364
+ });
365
+ return {
366
+ usage: decision?.usage,
367
+ costNanos: extractGatewayCostNanos(decision),
368
+ };
369
+ });
370
+ const { text: _text, ...tracking } = result;
371
+ return { ...tracking, answers: decision?.answers ?? {}, response: decision?.response };
372
+ }
305
373
  /**
306
374
  * An AI SDK LanguageModel that enforces limits and records usage/cost for
307
375
  * `userId` on every call. Drop it into `generateText`, `streamText`, or the
@@ -692,7 +760,7 @@ export class AIBudget {
692
760
  path,
693
761
  method: "POST",
694
762
  handler: httpActionGeneric(async (ctx, request) => {
695
- const body = await request.json().catch(() => ({}));
763
+ const body = await request.clone().json().catch(() => ({}));
696
764
  const settle = await opts.resolve(ctx, request, body);
697
765
  if (!settle)
698
766
  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;