@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.
@@ -71,6 +71,8 @@ export type BudgetEventInfo = {
71
71
  export type SoftLimitInfo = BudgetEventInfo & { warnings: string[] };
72
72
  export type AIBudgetOptions = {
73
73
  defaultModel?: string;
74
+ /** Default model for `decisions()` (the Decisions/"Jev" endpoint). */
75
+ defaultEvalModel?: string;
74
76
  /**
75
77
  * A *soft* limit was exceeded (request still allowed). Lets you surface budget
76
78
  * warnings even on the languageModel/Agent path where they can't be returned.
@@ -143,6 +145,17 @@ export type ChatResult = {
143
145
  notices: string[];
144
146
  };
145
147
 
148
+ /** The tracked result of a `decisions()` call: budgeting metadata plus the
149
+ * structured answers from the Decisions ("Jev") endpoint. */
150
+ export type DecisionResult = Omit<ChatResult, "text"> & {
151
+ /** Structured answers keyed by your question names (shape depends on each
152
+ * question type: `choice`, `score`, or `boolean`). */
153
+ answers: Record<string, any>;
154
+ /** The raw gateway response, including provider-specific fields (e.g.
155
+ * `confidence`) under `response.body`. */
156
+ response?: any;
157
+ };
158
+
146
159
  // ---------- helpers ----------
147
160
 
148
161
  // Token counts across AI SDK versions come as plain numbers or, in v7, as a
@@ -239,6 +252,7 @@ function timingSafeEqual(a: string, b: string): boolean {
239
252
 
240
253
  /** Limits/controls settable on any budget bucket (user, action, or tag). */
241
254
  export type BucketLimits = {
255
+ /** Token-bucket refill per minute and burst capacity; 0 blocks all requests. */
242
256
  requestsPerMinute?: number;
243
257
  maxConcurrent?: number;
244
258
  dailySpendLimitNanos?: number;
@@ -261,6 +275,7 @@ export type BumpArgs = {
261
275
 
262
276
  export class AIBudget {
263
277
  public defaultModel: string;
278
+ public defaultEvalModel: string;
264
279
  private onSoftLimit?: AIBudgetOptions["onSoftLimit"];
265
280
  private onThreshold?: AIBudgetOptions["onThreshold"];
266
281
  private onLimitReached?: AIBudgetOptions["onLimitReached"];
@@ -269,6 +284,7 @@ export class AIBudget {
269
284
  options?: AIBudgetOptions
270
285
  ) {
271
286
  this.defaultModel = options?.defaultModel ?? "openai/gpt-4o-mini";
287
+ this.defaultEvalModel = options?.defaultEvalModel ?? "typesafe/jev-1.13";
272
288
  this.onSoftLimit = options?.onSoftLimit;
273
289
  this.onThreshold = options?.onThreshold;
274
290
  this.onLimitReached = options?.onLimitReached;
@@ -533,6 +549,103 @@ export class AIBudget {
533
549
  );
534
550
  }
535
551
 
552
+ /**
553
+ * Budget a structured decision through the AI Gateway's Decisions ("Jev")
554
+ * endpoint — sugar over `meter`. Evaluates typed `questions` (choice / score /
555
+ * boolean) about the `state` you provide, with the same reserve→settle
556
+ * limits, audit log, cost tracking, and per-tag attribution as `chat`. Call
557
+ * from an action. `userId` defaults to the authenticated caller.
558
+ *
559
+ * Requires `@convex-dev/ai-sdk-provider` >= 0.2.1 and an `ai` version that
560
+ * exposes `experimental_evaluate` (AI SDK 7's evaluation interface); both are
561
+ * imported lazily, so consumers who never call `decisions()` are unaffected.
562
+ *
563
+ * const { answers } = await ai.decisions(ctx, {
564
+ * state: { ticket: "Customer cannot sign in" },
565
+ * questions: {
566
+ * priority: { type: "choice", instructions: "...", criteria: { urgent: "...", normal: "..." } },
567
+ * needsReview: { type: "boolean", instructions: "..." },
568
+ * },
569
+ * });
570
+ * answers.priority.choice; // "urgent" | "normal"
571
+ */
572
+ async decisions(
573
+ ctx: RunMutationCtx,
574
+ args: {
575
+ /** The evaluation model. Defaults to `defaultEvalModel` ("typesafe/jev-1.13"). */
576
+ model?: string;
577
+ /** Context the questions are evaluated against (a string or an object). */
578
+ state: unknown;
579
+ /** Typed questions (choice / score / boolean) keyed by name. */
580
+ questions: Record<string, unknown>;
581
+ /** Whom to bill. Defaults to the authenticated user (ctx.auth). */
582
+ userId?: string;
583
+ /** Attribute spend to this action name. Defaults to the calling action. */
584
+ action?: string;
585
+ /** Extra attribution dimensions to bill/limit (team, customer, env, …). */
586
+ tags?: Tag[];
587
+ /** Reserve this exact amount (nanodollars) up front — the decision cost
588
+ * isn't known before the call, so a hard cap is only exact with this. */
589
+ estimatedCostNanos?: number;
590
+ rerunOf?: string;
591
+ /** Cancel the underlying request. */
592
+ abortSignal?: AbortSignal;
593
+ }
594
+ ): Promise<DecisionResult> {
595
+ const model = args.model ?? this.defaultEvalModel;
596
+ // `evaluate` is an experimental, version-gated export; import it lazily and
597
+ // untyped so consumers on an older `ai` (who never call this) aren't broken.
598
+ const evaluate = ((await import("ai")) as any).experimental_evaluate;
599
+ if (typeof evaluate !== "function") {
600
+ throw new Error(
601
+ "ai-budget: decisions() needs `experimental_evaluate` from the `ai` " +
602
+ "package (AI SDK 7's evaluation interface). Upgrade `ai` to a " +
603
+ "version that exports it."
604
+ );
605
+ }
606
+ // Likewise, `evaluationModel` exists on @convex-dev/ai-sdk-provider >= 0.2.1.
607
+ const evaluationModel = (convexGateway as any).evaluationModel;
608
+ if (typeof evaluationModel !== "function") {
609
+ throw new Error(
610
+ "ai-budget: decisions() needs `convexGateway.evaluationModel` from " +
611
+ "@convex-dev/ai-sdk-provider >= 0.2.1. Upgrade the provider."
612
+ );
613
+ }
614
+ let decision: any;
615
+ const result = await this.meter(
616
+ ctx,
617
+ {
618
+ model,
619
+ // Store the structured request for audit/replay.
620
+ messages: [
621
+ {
622
+ role: "user",
623
+ content: JSON.stringify({ state: args.state, questions: args.questions }),
624
+ },
625
+ ],
626
+ userId: args.userId,
627
+ action: args.action,
628
+ tags: args.tags,
629
+ estimatedCostNanos: args.estimatedCostNanos,
630
+ rerunOf: args.rerunOf,
631
+ },
632
+ async () => {
633
+ decision = await evaluate({
634
+ model: evaluationModel(model),
635
+ state: args.state,
636
+ questions: args.questions,
637
+ ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
638
+ });
639
+ return {
640
+ usage: decision?.usage,
641
+ costNanos: extractGatewayCostNanos(decision),
642
+ };
643
+ }
644
+ );
645
+ const { text: _text, ...tracking } = result;
646
+ return { ...tracking, answers: decision?.answers ?? {}, response: decision?.response };
647
+ }
648
+
536
649
  /**
537
650
  * An AI SDK LanguageModel that enforces limits and records usage/cost for
538
651
  * `userId` on every call. Drop it into `generateText`, `streamText`, or the
@@ -1061,7 +1174,7 @@ export class AIBudget {
1061
1174
  path,
1062
1175
  method: "POST",
1063
1176
  handler: httpActionGeneric(async (ctx: any, request: Request) => {
1064
- const body = await request.json().catch(() => ({}));
1177
+ const body = await request.clone().json().catch(() => ({}));
1065
1178
  const settle = await opts.resolve(ctx, request, body);
1066
1179
  if (!settle) return new Response("ignored", { status: 202 });
1067
1180
  await self.settle(ctx, settle);
@@ -0,0 +1,32 @@
1
+ import { test, expect, vi } from "vitest";
2
+ import { httpRouter } from "convex/server";
3
+ import { AIBudget } from "./index";
4
+
5
+ test("webhook resolver can verify the exact raw body before settlement", async () => {
6
+ const budget = new AIBudget({} as any);
7
+ const settle = vi.spyOn(budget, "settle").mockResolvedValue({ costNanos: 123 });
8
+ const http = httpRouter();
9
+ const raw = '{ "id": "job", "cost": 123 }\n';
10
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode("test-secret"),
11
+ { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
12
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(raw));
13
+ budget.registerWebhook(http, {
14
+ resolve: async (_ctx, request, body) => {
15
+ const bytes = await request.arrayBuffer();
16
+ if (!await crypto.subtle.verify("HMAC", key, signature, bytes)) return null;
17
+ expect(body.id).toBe("job");
18
+ return { requestId: "request", costNanos: body.cost };
19
+ },
20
+ });
21
+ const [handler] = http.lookup("/aibudget/webhook", "POST")!;
22
+ const response = await (handler as any)._handler({}, new Request("https://example.test/aibudget/webhook", {
23
+ method: "POST", body: raw,
24
+ }));
25
+ expect(response.status).toBe(200);
26
+ expect(settle).toHaveBeenCalledOnce();
27
+ const rejected = await (handler as any)._handler({}, new Request("https://example.test/aibudget/webhook", {
28
+ method: "POST", body: raw.replace("123", "999"),
29
+ }));
30
+ expect(rejected.status).toBe(202);
31
+ expect(settle).toHaveBeenCalledOnce();
32
+ });
@@ -51,4 +51,5 @@ export const internal: FilterApi<
51
51
 
52
52
  export const components = componentsGeneric() as unknown as {
53
53
  shardedCounter: import("@convex-dev/sharded-counter/_generated/component.js").ComponentApi<"shardedCounter">;
54
+ rateLimiter: import("@convex-dev/rate-limiter/_generated/component.js").ComponentApi<"rateLimiter">;
54
55
  };
@@ -1,7 +1,10 @@
1
1
  import { defineComponent } from "convex/server";
2
2
  import shardedCounter from "@convex-dev/sharded-counter/convex.config";
3
3
 
4
+ import rateLimiter from "@convex-dev/rate-limiter/convex.config";
5
+
4
6
  const component = defineComponent("aiBudget");
5
7
  // Global spend totals use a sharded counter for high write throughput.
6
8
  component.use(shardedCounter);
9
+ component.use(rateLimiter);
7
10
  export default component;