@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 +74 -9
- package/dist/client/index.d.ts +86 -0
- package/dist/client/index.js +69 -1
- package/dist/component/_generated/api.d.ts +1 -0
- package/dist/component/convex.config.js +2 -0
- package/dist/component/lib.d.ts +32 -0
- package/dist/component/lib.js +166 -122
- package/dist/component/schema.d.ts +53 -1
- package/dist/component/schema.js +31 -1
- package/package.json +3 -2
- package/src/client/index.ts +114 -1
- package/src/client/webhook.test.ts +32 -0
- package/src/component/_generated/api.ts +1 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/lib.test.ts +246 -29
- package/src/component/lib.ts +177 -135
- package/src/component/schema.ts +31 -1
package/src/client/index.ts
CHANGED
|
@@ -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;
|