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

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.
@@ -306,97 +306,233 @@ export class AIBudget {
306
306
  }
307
307
 
308
308
  /**
309
- * One-shot chat through the AI Gateway with tracking + limits.
310
- * Call from an action. `userId` defaults to the authenticated caller.
309
+ * Reserve budget for a call WITHOUT running it the async half of the
310
+ * lifecycle. Use for long-running jobs (video generation) where the result
311
+ * arrives minutes later via a poll or webhook: `begin` here, then `settle`
312
+ * from that later context with the request's `requestId`. Returns the
313
+ * admission result (does NOT throw over a cap — check `allowed`). Set
314
+ * `reserveTtlMs` to the job's max duration so the hold isn't reaped mid-flight.
311
315
  */
312
- async chat(
316
+ async begin(
313
317
  ctx: RunMutationCtx,
314
- args: {
315
- /** Whom to bill. Defaults to the authenticated user (ctx.auth). */
316
- userId?: string;
317
- prompt?: string;
318
+ opts: {
319
+ model: string;
318
320
  messages?: Message[];
319
- model?: string;
320
- rerunOf?: string;
321
- /** Attribute spend to this action name. Defaults to the calling Convex action. */
321
+ userId?: string;
322
322
  action?: string;
323
- /** Extra attribution dimensions to bill/limit (team, customer, env, …). */
324
323
  tags?: Tag[];
325
- } = {}
326
- ): Promise<ChatResult> {
327
- const model = args.model ?? this.defaultModel;
328
- const userId = await resolveUserId(ctx, args.userId);
329
- const actionName = await resolveActionName(ctx, args.action);
330
- const messages: Message[] =
331
- args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
324
+ /** Reserve this exact amount (nanodollars) when the cost is known up front. */
325
+ estimatedCostNanos?: number;
326
+ /** Hold the reservation up to this long (ms) for long async jobs. */
327
+ reserveTtlMs?: number;
328
+ rerunOf?: string;
329
+ }
330
+ ): Promise<
331
+ | { allowed: true; requestId: string; warnings: string[]; notices: string[] }
332
+ | { allowed: false; code: string; reason: string }
333
+ > {
334
+ const userId = await resolveUserId(ctx, opts.userId);
335
+ const actionName = await resolveActionName(ctx, opts.action);
332
336
  const started = await ctx.runMutation(this.component.lib.startRequest, {
333
337
  userId,
334
338
  actionName,
335
- tags: args.tags,
336
- model,
337
- messages,
338
- rerunOf: args.rerunOf as any,
339
+ tags: opts.tags,
340
+ model: opts.model,
341
+ messages: opts.messages ?? [],
342
+ estimatedCostNanos: opts.estimatedCostNanos,
343
+ reserveTtlMs: opts.reserveTtlMs,
344
+ rerunOf: opts.rerunOf as any,
339
345
  });
340
- if (!started.allowed) {
346
+ if (started.allowed) {
347
+ await this.fireBudgetEvents(
348
+ { userId, action: actionName, tags: opts.tags, requestId: started.requestId },
349
+ started.warnings,
350
+ started.notices
351
+ );
352
+ } else {
341
353
  await this.fireLimitReached({
342
354
  userId,
343
355
  action: actionName,
344
- tags: args.tags,
356
+ tags: opts.tags,
345
357
  messages: [started.reason],
346
358
  code: started.code,
347
359
  reason: started.reason,
348
360
  });
361
+ }
362
+ return started;
363
+ }
364
+
365
+ /**
366
+ * Record the actual usage/cost of a `begin`-reserved request and release its
367
+ * reservation. Idempotent (exactly-once server-side). Pass a raw provider
368
+ * `usage` (auto-normalized) or explicit token counts, plus optional
369
+ * `serverToolUses` and an authoritative `costNanos`.
370
+ */
371
+ async settle(
372
+ ctx: RunMutationCtx,
373
+ args: {
374
+ requestId: string;
375
+ responseText?: string;
376
+ error?: string;
377
+ usage?: any;
378
+ promptTokens?: number;
379
+ completionTokens?: number;
380
+ cachedTokens?: number;
381
+ serverToolUses?: Record<string, number>;
382
+ costNanos?: number;
383
+ latencyMs?: number;
384
+ }
385
+ ): Promise<{ costNanos: number }> {
386
+ const { requestId, usage, promptTokens, completionTokens, cachedTokens, ...rest } = args;
387
+ const tokens =
388
+ promptTokens !== undefined ||
389
+ completionTokens !== undefined ||
390
+ cachedTokens !== undefined
391
+ ? {
392
+ promptTokens: promptTokens ?? 0,
393
+ completionTokens: completionTokens ?? 0,
394
+ cachedTokens: cachedTokens ?? 0,
395
+ }
396
+ : usage !== undefined
397
+ ? extractUsage(usage)
398
+ : {};
399
+ return ctx.runMutation(this.component.lib.finishRequest, {
400
+ requestId: requestId as any,
401
+ ...tokens,
402
+ ...rest,
403
+ });
404
+ }
405
+
406
+ /**
407
+ * Meter ANY synchronous LLM call — gateway, a provider SDK, a raw fetch — with
408
+ * the same budgets, audit log, and cost tracking. Reserves before your `run`
409
+ * (throwing a ConvexError over a hard cap), runs it, then records the actual
410
+ * usage/cost. The provider-agnostic core; `chat` is sugar over it. (For async
411
+ * jobs that settle later, use `begin`/`settle` instead.)
412
+ *
413
+ * `run` returns what happened. Pass a raw provider `usage` object (auto-
414
+ * normalized) OR explicit `promptTokens`/`completionTokens`/`cachedTokens`,
415
+ * plus optional `serverToolUses` (e.g. `{ web_search: 3 }`) and an
416
+ * authoritative `costNanos` (used verbatim if present).
417
+ */
418
+ async meter(
419
+ ctx: RunMutationCtx,
420
+ opts: {
421
+ model: string;
422
+ messages: Message[];
423
+ userId?: string;
424
+ action?: string;
425
+ tags?: Tag[];
426
+ rerunOf?: string;
427
+ /** Reserve this exact amount (nanodollars) instead of the token estimate. */
428
+ estimatedCostNanos?: number;
429
+ },
430
+ run: () => Promise<{
431
+ text?: string;
432
+ usage?: any;
433
+ promptTokens?: number;
434
+ completionTokens?: number;
435
+ cachedTokens?: number;
436
+ serverToolUses?: Record<string, number>;
437
+ costNanos?: number;
438
+ }>
439
+ ): Promise<ChatResult> {
440
+ const started = await this.begin(ctx, opts);
441
+ if (!started.allowed) {
349
442
  throw new ConvexError({
350
443
  kind: "AIBudgetLimit",
351
444
  code: started.code,
352
445
  reason: started.reason,
353
446
  });
354
447
  }
355
- const requestId = started.requestId;
356
- const warnings = started.warnings;
357
- const notices = started.notices;
358
- await this.fireBudgetEvents(
359
- { userId, action: actionName, tags: args.tags, requestId },
360
- warnings,
361
- notices
362
- );
448
+ const { requestId, warnings, notices } = started;
363
449
  const start = Date.now();
364
450
  try {
365
- // The full chain (incl. system) is stored on the request for audit/replay,
366
- // but the AI SDK wants system prompts in the `system` option, not messages.
367
- const system =
368
- messages
369
- .filter((m) => m.role === "system")
370
- .map((m) => m.content)
371
- .join("\n\n") || undefined;
372
- const convo = messages.filter((m) => m.role !== "system");
373
- const result = await generateText({
374
- model: convexGateway(model),
375
- ...(system ? { system } : {}),
376
- messages: convo as any,
377
- });
378
- const usage = extractUsage(result.usage);
379
- const { costNanos } = await ctx.runMutation(
380
- this.component.lib.finishRequest,
381
- {
382
- requestId,
383
- responseText: result.text,
384
- ...usage,
385
- costNanos: extractGatewayCostNanos(result),
386
- latencyMs: Date.now() - start,
387
- }
388
- );
389
- return { text: result.text, requestId, costNanos, warnings, notices, ...usage };
390
- } catch (e) {
391
- await ctx.runMutation(this.component.lib.finishRequest, {
451
+ const out = await run();
452
+ const { costNanos } = await this.settle(ctx, {
392
453
  requestId,
393
- error: String(e),
454
+ responseText: out.text,
455
+ usage: out.usage,
456
+ promptTokens: out.promptTokens,
457
+ completionTokens: out.completionTokens,
458
+ cachedTokens: out.cachedTokens,
459
+ serverToolUses: out.serverToolUses,
460
+ costNanos: out.costNanos,
394
461
  latencyMs: Date.now() - start,
395
462
  });
463
+ // Re-derive the recorded usage for the return value.
464
+ const usage =
465
+ out.promptTokens !== undefined ||
466
+ out.completionTokens !== undefined ||
467
+ out.cachedTokens !== undefined
468
+ ? {
469
+ promptTokens: out.promptTokens ?? 0,
470
+ completionTokens: out.completionTokens ?? 0,
471
+ cachedTokens: out.cachedTokens ?? 0,
472
+ }
473
+ : extractUsage(out.usage);
474
+ return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
475
+ } catch (e) {
476
+ await this.settle(ctx, { requestId, error: String(e), latencyMs: Date.now() - start });
396
477
  throw e;
397
478
  }
398
479
  }
399
480
 
481
+ /**
482
+ * One-shot chat through the AI Gateway with tracking + limits — sugar over
483
+ * `meter`. Call from an action. `userId` defaults to the authenticated caller.
484
+ */
485
+ async chat(
486
+ ctx: RunMutationCtx,
487
+ args: {
488
+ /** Whom to bill. Defaults to the authenticated user (ctx.auth). */
489
+ userId?: string;
490
+ prompt?: string;
491
+ messages?: Message[];
492
+ model?: string;
493
+ rerunOf?: string;
494
+ /** Attribute spend to this action name. Defaults to the calling Convex action. */
495
+ action?: string;
496
+ /** Extra attribution dimensions to bill/limit (team, customer, env, …). */
497
+ tags?: Tag[];
498
+ } = {}
499
+ ): Promise<ChatResult> {
500
+ const model = args.model ?? this.defaultModel;
501
+ const messages: Message[] =
502
+ args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
503
+ return this.meter(
504
+ ctx,
505
+ {
506
+ model,
507
+ messages,
508
+ userId: args.userId,
509
+ action: args.action,
510
+ tags: args.tags,
511
+ rerunOf: args.rerunOf,
512
+ },
513
+ async () => {
514
+ // The full chain (incl. system) is stored for audit/replay, but the AI
515
+ // SDK wants system prompts in the `system` option, not messages.
516
+ const system =
517
+ messages
518
+ .filter((m) => m.role === "system")
519
+ .map((m) => m.content)
520
+ .join("\n\n") || undefined;
521
+ const convo = messages.filter((m) => m.role !== "system");
522
+ const result = await generateText({
523
+ model: convexGateway(model),
524
+ ...(system ? { system } : {}),
525
+ messages: convo as any,
526
+ });
527
+ return {
528
+ text: result.text,
529
+ usage: result.usage,
530
+ costNanos: extractGatewayCostNanos(result),
531
+ };
532
+ }
533
+ );
534
+ }
535
+
400
536
  /**
401
537
  * An AI SDK LanguageModel that enforces limits and records usage/cost for
402
538
  * `userId` on every call. Drop it into `generateText`, `streamText`, or the
@@ -720,7 +856,7 @@ export class AIBudget {
720
856
  };
721
857
  }
722
858
 
723
- /** Per-model prices (cents per million tokens). */
859
+ /** Per-model prices (nanodollars per million tokens) + server-tool fees. */
724
860
  get prices() {
725
861
  const c = this.component;
726
862
  return {
@@ -735,6 +871,14 @@ export class AIBudget {
735
871
  cachedNanosPerMTok?: number;
736
872
  }
737
873
  ) => ctx.runMutation(c.lib.setPrice, args),
874
+ /** Per-call fees for provider server tools (web search, etc.). */
875
+ listServerTools: (ctx: RunQueryCtx) =>
876
+ ctx.runQuery(c.lib.listServerToolPrices, {}),
877
+ /** Set a server-tool's per-call price, e.g. { tool: "web_search", nanosPerCall }. */
878
+ setServerTool: (
879
+ ctx: RunMutationCtx,
880
+ args: { tool: string; nanosPerCall: number }
881
+ ) => ctx.runMutation(c.lib.setServerToolPrice, args),
738
882
  };
739
883
  }
740
884
 
@@ -871,6 +1015,60 @@ export class AIBudget {
871
1015
  http.route({ pathPrefix: `${prefix}/`, method: "GET", handler });
872
1016
  http.route({ pathPrefix: `${prefix}/`, method: "POST", handler });
873
1017
  }
1018
+
1019
+ /**
1020
+ * Mount a POST webhook that settles a `begin`-reserved request from a
1021
+ * provider's completion callback (async image/video jobs). You supply
1022
+ * `resolve` — verify the payload's signature and map the provider's job id to
1023
+ * your stored `requestId` + final usage/cost; the helper calls `settle`.
1024
+ * Return `null` to ignore an unrecognized/duplicate callback (HTTP 202).
1025
+ *
1026
+ * ai.registerWebhook(http, {
1027
+ * path: "/aibudget/video-done",
1028
+ * resolve: async (ctx, request, body) => {
1029
+ * if (!verifySignature(request, body)) return null;
1030
+ * const job = await lookupJob(ctx, body.id); // your table: { requestId }
1031
+ * return { requestId: job.requestId, serverToolUses: { video_seconds: body.seconds } };
1032
+ * },
1033
+ * });
1034
+ */
1035
+ registerWebhook(
1036
+ http: HttpRouter,
1037
+ opts: {
1038
+ path?: string;
1039
+ resolve: (
1040
+ ctx: any,
1041
+ request: Request,
1042
+ body: any
1043
+ ) => Promise<
1044
+ | ({ requestId: string } & {
1045
+ responseText?: string;
1046
+ error?: string;
1047
+ usage?: any;
1048
+ promptTokens?: number;
1049
+ completionTokens?: number;
1050
+ cachedTokens?: number;
1051
+ serverToolUses?: Record<string, number>;
1052
+ costNanos?: number;
1053
+ })
1054
+ | null
1055
+ >;
1056
+ }
1057
+ ) {
1058
+ const path = opts.path ?? "/aibudget/webhook";
1059
+ const self = this;
1060
+ http.route({
1061
+ path,
1062
+ method: "POST",
1063
+ handler: httpActionGeneric(async (ctx: any, request: Request) => {
1064
+ const body = await request.json().catch(() => ({}));
1065
+ const settle = await opts.resolve(ctx, request, body);
1066
+ if (!settle) return new Response("ignored", { status: 202 });
1067
+ await self.settle(ctx, settle);
1068
+ return new Response("ok");
1069
+ }),
1070
+ });
1071
+ }
874
1072
  }
875
1073
 
876
1074
  /** @deprecated Renamed to `AIBudget`. */
@@ -76,6 +76,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
76
76
  promptTokens?: number;
77
77
  requestId: string;
78
78
  responseText?: string;
79
+ serverToolUses?: Record<string, number>;
79
80
  },
80
81
  { costNanos: number },
81
82
  Name
@@ -145,6 +146,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
145
146
  any,
146
147
  Name
147
148
  >;
149
+ listServerToolPrices: FunctionReference<
150
+ "query",
151
+ "internal",
152
+ {},
153
+ any,
154
+ Name
155
+ >;
148
156
  setAlertDefaults: FunctionReference<
149
157
  "mutation",
150
158
  "internal",
@@ -210,14 +218,23 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
210
218
  null,
211
219
  Name
212
220
  >;
221
+ setServerToolPrice: FunctionReference<
222
+ "mutation",
223
+ "internal",
224
+ { nanosPerCall: number; tool: string },
225
+ null,
226
+ Name
227
+ >;
213
228
  startRequest: FunctionReference<
214
229
  "mutation",
215
230
  "internal",
216
231
  {
217
232
  actionName?: string;
233
+ estimatedCostNanos?: number;
218
234
  messages: Array<{ content: string; role: string }>;
219
235
  model: string;
220
236
  rerunOf?: string;
237
+ reserveTtlMs?: number;
221
238
  tags?: Array<{ dimension: string; value: string }>;
222
239
  userId: string;
223
240
  },
@@ -116,6 +116,109 @@ describe("cache-aware pricing", () => {
116
116
  });
117
117
  });
118
118
 
119
+ describe("server-tool pricing", () => {
120
+ test("server-tool uses add a per-call fee on top of tokens", async () => {
121
+ const t = convexTest(schema, modules);
122
+ const r = await start(t, { userId: "u" });
123
+ // 0 tokens; 3 web searches at the $0.01 default = 30_000_000 nano.
124
+ await settleWith(t, r.requestId, {
125
+ promptTokens: 0,
126
+ completionTokens: 0,
127
+ serverToolUses: { web_search: 3 },
128
+ });
129
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
130
+ expect(req.costNanos).toBe(30_000_000);
131
+ expect(req.serverToolUses).toEqual({ web_search: 3 });
132
+ });
133
+
134
+ test("an override price is applied", async () => {
135
+ const t = convexTest(schema, modules);
136
+ await t.mutation(api.lib.setServerToolPrice, {
137
+ tool: "web_search",
138
+ nanosPerCall: 12_000_000,
139
+ });
140
+ const r = await start(t, { userId: "u" });
141
+ await settleWith(t, r.requestId, {
142
+ promptTokens: 0,
143
+ completionTokens: 0,
144
+ serverToolUses: { web_search: 2 },
145
+ });
146
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
147
+ expect(req.costNanos).toBe(24_000_000);
148
+ });
149
+
150
+ test("an authoritative cost already includes tool fees (not double-charged)", async () => {
151
+ const t = convexTest(schema, modules);
152
+ const r = await start(t, { userId: "u" });
153
+ await settleWith(t, r.requestId, {
154
+ promptTokens: 1_000_000,
155
+ completionTokens: 0,
156
+ serverToolUses: { web_search: 5 },
157
+ costNanos: 999,
158
+ });
159
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
160
+ expect(req.costNanos).toBe(999);
161
+ });
162
+ });
163
+
164
+ describe("cost known up front (image gen, per-call APIs)", () => {
165
+ test("estimatedCostNanos drives the reservation for a hard cap", async () => {
166
+ const t = convexTest(schema, modules);
167
+ await setUserLimits(t, "u", { dailySpendLimitNanos: 100_000_000 }); // $0.10
168
+ // A $0.13 image is known before the call; reserving it exceeds the cap,
169
+ // even though the token estimate for the prompt alone would pass.
170
+ const r = await start(t, {
171
+ userId: "u",
172
+ model: "openai/gpt-image-1",
173
+ estimatedCostNanos: 130_000_000,
174
+ });
175
+ expect(r.allowed).toBe(false);
176
+ expect(r.code).toBe("user_daily_spend_limit");
177
+ });
178
+
179
+ test("admits when it fits, then settles to the real per-image cost", async () => {
180
+ const t = convexTest(schema, modules);
181
+ await setUserLimits(t, "u", { dailySpendLimitNanos: 500_000_000 });
182
+ const r = await start(t, {
183
+ userId: "u",
184
+ model: "openai/gpt-image-1",
185
+ estimatedCostNanos: 130_000_000,
186
+ });
187
+ expect(r.allowed).toBe(true);
188
+ await settleWith(t, r.requestId, { costNanos: 130_000_000 });
189
+ const u = await userOf(t, "u");
190
+ expect(u.totalSpendNanos).toBe(130_000_000);
191
+ expect(u.reservedTotalNanos ?? 0).toBe(0);
192
+ });
193
+ });
194
+
195
+ describe("async lifecycle (video jobs): begin now, settle later", () => {
196
+ test("reserveTtlMs is stored, and settle records the real cost", async () => {
197
+ const t = convexTest(schema, modules);
198
+ await setUserLimits(t, "u", { dailySpendLimitNanos: 5_000_000_000 });
199
+ // Reserve $2 for a long job that will settle minutes later.
200
+ const r = await start(t, {
201
+ userId: "u",
202
+ model: "openai/sora",
203
+ estimatedCostNanos: 2_000_000_000,
204
+ reserveTtlMs: 30 * 60 * 1000,
205
+ });
206
+ expect(r.allowed).toBe(true);
207
+ const pending = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
208
+ expect(pending.status).toBe("pending");
209
+ expect(pending.reserveTtlMs).toBe(30 * 60 * 1000);
210
+ // held reservation
211
+ let u = await userOf(t, "u");
212
+ expect(u.reservedTotalNanos).toBe(2_000_000_000);
213
+
214
+ // …later: the webhook fires and settles the actual cost.
215
+ await settleWith(t, r.requestId, { costNanos: 1_800_000_000 });
216
+ u = await userOf(t, "u");
217
+ expect(u.totalSpendNanos).toBe(1_800_000_000);
218
+ expect(u.reservedTotalNanos ?? 0).toBe(0);
219
+ });
220
+ });
221
+
119
222
  describe("durable usage history", () => {
120
223
  test("settled spend lands in a per-day usage row", async () => {
121
224
  const t = convexTest(schema, modules);