@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.
package/README.md CHANGED
@@ -205,6 +205,84 @@ const result = await agent.generateText(ctx, { threadId }, { prompt });
205
205
  Every generation is now tracked and budgeted, attributed to `userId` and the
206
206
  calling action. See `example/convex/agentDemo.ts`.
207
207
 
208
+ ### `ai.meter` — budget *any* provider call
209
+
210
+ `chat`/`languageModel` go through the gateway. When you need something the
211
+ gateway can't serve (Anthropic web search, computer-use, a different provider,
212
+ a raw `fetch`), `meter` brings that call under the *same* caps, audit log, and
213
+ cost tracking. It reserves before your `run` (throwing over a hard cap), runs
214
+ it, and records the actual usage:
215
+
216
+ ```ts
217
+ await ai.meter(ctx, { userId, model: "anthropic/claude-…", messages }, async () => {
218
+ const res = await anthropic.messages.create({
219
+ messages,
220
+ tools: [{ type: "web_search_20250305", name: "web_search", max_uses: 3 }],
221
+ });
222
+ return {
223
+ text: extractText(res),
224
+ promptTokens: res.usage.input_tokens,
225
+ completionTokens: res.usage.output_tokens,
226
+ cachedTokens: res.usage.cache_read_input_tokens ?? 0,
227
+ serverToolUses: { web_search: res.usage.server_tool_use?.web_search_requests ?? 0 },
228
+ // costNanos? — pass an authoritative total to skip token/tool pricing
229
+ };
230
+ });
231
+ ```
232
+
233
+ `chat` is sugar over `meter`. Return either a raw provider `usage` (auto-
234
+ normalized) or explicit `promptTokens`/`completionTokens`/`cachedTokens`, plus
235
+ optional `serverToolUses` (see [pricing](#pricing--cost)) and `costNanos`.
236
+
237
+ **Cost known up front (image gen, per-call APIs).** When you know the price
238
+ before the call — image generation (`n` × per-image), audio, anything per-call —
239
+ pass `estimatedCostNanos` so the *reservation* holds the real amount and a hard
240
+ cap is exact (the token estimate is meaningless for these). Price the units with
241
+ `serverToolUses` + [`setServerTool`](#pricing--cost):
242
+
243
+ ```ts
244
+ const IMG = 130_000_000; // $0.13/image
245
+ await ai.meter(ctx,
246
+ { userId, model: "openai/gpt-image-1", messages: [{ role: "user", content: prompt }],
247
+ estimatedCostNanos: IMG * n },
248
+ async () => {
249
+ const res = await openrouter.images.generate({ model: "openai/gpt-image-1", prompt, n });
250
+ return { serverToolUses: { image: n } }; // priced via setServerTool({ tool: "image", … })
251
+ });
252
+ ```
253
+
254
+ ### `ai.begin` / `ai.settle` — long async jobs (video)
255
+
256
+ A video job is submit → wait minutes → poll/webhook → done, spanning multiple
257
+ Convex functions, so the synchronous `meter` bracket doesn't fit. Reserve with
258
+ `begin` at submit, `settle` from the later context. Set `reserveTtlMs` to the
259
+ job's max duration so the reconciler doesn't reap the hold mid-flight:
260
+
261
+ ```ts
262
+ // submit (action): reserve, then kick off the job
263
+ const started = await ai.begin(ctx, {
264
+ userId, model: "openai/sora",
265
+ estimatedCostNanos: perSecond * seconds,
266
+ reserveTtlMs: 20 * 60 * 1000, // hold up to 20 min
267
+ });
268
+ if (!started.allowed) throw new ConvexError(started.reason);
269
+ const job = await sora.videos.create({ … });
270
+ await ctx.db.insert("videoJobs", { requestId: started.requestId, jobId: job.id });
271
+
272
+ // later — settle from a poll, or a provider webhook:
273
+ ai.registerWebhook(http, {
274
+ path: "/aibudget/video-done",
275
+ resolve: async (ctx, request, body) => {
276
+ if (!verifySignature(request, body)) return null; // 202, ignored
277
+ const job = await lookupJob(ctx, body.id); // → { requestId }
278
+ return { requestId: job.requestId, serverToolUses: { video_seconds: body.seconds } };
279
+ },
280
+ });
281
+ ```
282
+
283
+ `begin` returns the admission result (it doesn't throw — check `allowed`).
284
+ `settle` is idempotent (exactly-once server-side), so a retried webhook is safe.
285
+
208
286
  ### Replay
209
287
 
210
288
  ```ts
@@ -390,6 +468,17 @@ ai.prices.set(ctx, { model, inputNanosPerMTok, outputNanosPerMTok, cachedNanosPe
390
468
  ai.prices.list(ctx)
391
469
  ```
392
470
 
471
+ **Server tools.** Provider server-side tools bill a per-call fee on top of
472
+ tokens (e.g. Anthropic web search). Report them from `meter` as
473
+ `serverToolUses: { web_search: 3 }` and they're priced per call (default
474
+ $0.01/`web_search`) — unless you pass an authoritative `costNanos`, which already
475
+ includes them. Override the rate:
476
+
477
+ ```ts
478
+ ai.prices.setServerTool(ctx, { tool: "web_search", nanosPerCall: 12_000_000 })
479
+ ai.prices.listServerTools(ctx)
480
+ ```
481
+
393
482
  The gateway's `provider/model` ids match OpenRouter's, whose public models
394
483
  endpoint returns per-token pricing — so you can keep prices current from your own
395
484
  action (see `example/convex/ai.ts` → `syncPrices`):
@@ -529,22 +618,31 @@ npm run dev # terminal 2 — Vite app
529
618
 
530
619
  ### What's in the component vs. the demo
531
620
 
532
- The component (`src/`) is **only** the metering/budget primitive — it knows
533
- nothing about chat or evaluation. Everything below lives in `example/` as
534
- **application code that uses the component**, not part of the published API:
535
-
536
- - The real features being metered `sendMessage`, `summarize`, the agent.
537
- - An **eval playground** (🧪 Experiment tab): **Matrix** (one prompt across a
621
+ The published AI Budget component (`src/`) is **only** the metering/budget
622
+ primitive — it knows nothing about agents or evaluation. The example composes
623
+ three isolated pieces:
624
+
625
+ - The **Agent component** owns agent threads, messages, tools, and generation.
626
+ - The local **Evaluation component** (`example/convex/evaluations/`) owns immutable
627
+ case snapshots, run lifecycle, and results. It does not call Agent or AI Budget.
628
+ - App-level adapters in `example/convex/ai.ts` compose the siblings: they obtain
629
+ source traffic, run candidate/judge model calls through AI Budget, attach an
630
+ `evalRun` budget tag, and persist outcomes into Evaluation.
631
+ - The **eval playground** (🧪 Experiment tab) exposes **Matrix** (one prompt across a
538
632
  system-prompt × model grid, ranked by an LLM judge on *your* criteria),
539
633
  **Backtest** (replay a candidate prompt against an action's real historical
540
634
  requests and judge each), and **Evolve** (an LLM iteratively improves a prompt
541
635
  toward a goal on real traffic, **stopping when it hits a spend budget**).
542
636
 
543
- These are built on two primitives `ai.chat(...)` (every eval call is budgeted)
544
- and `ai.requests.list(...)` (the audit log *is* the eval dataset) a "how to
545
- build on it" reference, not the component's surface. If you productize this it
546
- belongs in your app or a component that *composes* `@convex-dev/ai-budget`, never
547
- folded back into it.
637
+ The demo currently snapshots AI Budget audit traffic as its corpus. An Agent-based
638
+ product should instead make the app adapter snapshot cases through Agent's public
639
+ API; Evaluation must never inspect Agent's private tables. The same Evaluation
640
+ component works with either source because its inputs are explicit snapshots.
641
+
642
+ This local component is a proof of the reusable boundary, not part of the
643
+ `@convex-dev/ai-budget` package. If productized, it should ship independently
644
+ (for example, `@convex-dev/evals`) and accept application adapters/function
645
+ handles rather than taking a dependency on either sibling component.
548
646
 
549
647
  ---
550
648
 
@@ -566,7 +664,9 @@ npm run build # emit dist/ (client + component) for publishing
566
664
  - `src/client/` — **the client** (published): the `AIBudget` class — `chat`,
567
665
  `languageModel`, `registerRoutes`, and the namespaced admin API.
568
666
  - `example/` — **the demo app** (not published): real features, the eval
569
- playground, and the UI, all built on the two directories above.
667
+ playground, and the UI. `example/convex/evaluations/` is a separate local
668
+ component for datasets, runs, and results; `example/convex/ai.ts` is the
669
+ composition layer.
570
670
 
571
671
  ## License
572
672
 
@@ -113,8 +113,87 @@ export declare class AIBudget {
113
113
  private fireBudgetEvents;
114
114
  private fireLimitReached;
115
115
  /**
116
- * One-shot chat through the AI Gateway with tracking + limits.
117
- * Call from an action. `userId` defaults to the authenticated caller.
116
+ * Reserve budget for a call WITHOUT running it the async half of the
117
+ * lifecycle. Use for long-running jobs (video generation) where the result
118
+ * arrives minutes later via a poll or webhook: `begin` here, then `settle`
119
+ * from that later context with the request's `requestId`. Returns the
120
+ * admission result (does NOT throw over a cap — check `allowed`). Set
121
+ * `reserveTtlMs` to the job's max duration so the hold isn't reaped mid-flight.
122
+ */
123
+ begin(ctx: RunMutationCtx, opts: {
124
+ model: string;
125
+ messages?: Message[];
126
+ userId?: string;
127
+ action?: string;
128
+ tags?: Tag[];
129
+ /** Reserve this exact amount (nanodollars) when the cost is known up front. */
130
+ estimatedCostNanos?: number;
131
+ /** Hold the reservation up to this long (ms) for long async jobs. */
132
+ reserveTtlMs?: number;
133
+ rerunOf?: string;
134
+ }): Promise<{
135
+ allowed: true;
136
+ requestId: string;
137
+ warnings: string[];
138
+ notices: string[];
139
+ } | {
140
+ allowed: false;
141
+ code: string;
142
+ reason: string;
143
+ }>;
144
+ /**
145
+ * Record the actual usage/cost of a `begin`-reserved request and release its
146
+ * reservation. Idempotent (exactly-once server-side). Pass a raw provider
147
+ * `usage` (auto-normalized) or explicit token counts, plus optional
148
+ * `serverToolUses` and an authoritative `costNanos`.
149
+ */
150
+ settle(ctx: RunMutationCtx, args: {
151
+ requestId: string;
152
+ responseText?: string;
153
+ error?: string;
154
+ usage?: any;
155
+ promptTokens?: number;
156
+ completionTokens?: number;
157
+ cachedTokens?: number;
158
+ serverToolUses?: Record<string, number>;
159
+ costNanos?: number;
160
+ latencyMs?: number;
161
+ }): Promise<{
162
+ costNanos: number;
163
+ }>;
164
+ /**
165
+ * Meter ANY synchronous LLM call — gateway, a provider SDK, a raw fetch — with
166
+ * the same budgets, audit log, and cost tracking. Reserves before your `run`
167
+ * (throwing a ConvexError over a hard cap), runs it, then records the actual
168
+ * usage/cost. The provider-agnostic core; `chat` is sugar over it. (For async
169
+ * jobs that settle later, use `begin`/`settle` instead.)
170
+ *
171
+ * `run` returns what happened. Pass a raw provider `usage` object (auto-
172
+ * normalized) OR explicit `promptTokens`/`completionTokens`/`cachedTokens`,
173
+ * plus optional `serverToolUses` (e.g. `{ web_search: 3 }`) and an
174
+ * authoritative `costNanos` (used verbatim if present).
175
+ */
176
+ meter(ctx: RunMutationCtx, opts: {
177
+ model: string;
178
+ messages: Message[];
179
+ userId?: string;
180
+ action?: string;
181
+ tags?: Tag[];
182
+ rerunOf?: string;
183
+ /** Reserve this exact amount (nanodollars) instead of the token estimate. */
184
+ estimatedCostNanos?: number;
185
+ }, run: () => Promise<{
186
+ text?: string;
187
+ usage?: any;
188
+ promptTokens?: number;
189
+ completionTokens?: number;
190
+ cachedTokens?: number;
191
+ serverToolUses?: Record<string, number>;
192
+ costNanos?: number;
193
+ }>): Promise<ChatResult>;
194
+ /**
195
+ * One-shot chat through the AI Gateway with tracking + limits — sugar over
196
+ * `meter`. Call from an action. `userId` defaults to the authenticated caller.
118
197
  */
119
198
  chat(ctx: RunMutationCtx, args?: {
120
199
  /** Whom to bill. Defaults to the authenticated user (ctx.auth). */
@@ -168,6 +247,10 @@ export declare class AIBudget {
168
247
  promptTokens?: number | undefined;
169
248
  completionTokens?: number | undefined;
170
249
  cachedTokens?: number | undefined;
250
+ serverToolUses?: {
251
+ [x: string]: number;
252
+ } | undefined;
253
+ reserveTtlMs?: number | undefined;
171
254
  costNanos?: number | undefined;
172
255
  latencyMs?: number | undefined;
173
256
  rerunOf?: string | undefined;
@@ -200,6 +283,10 @@ export declare class AIBudget {
200
283
  promptTokens?: number | undefined;
201
284
  completionTokens?: number | undefined;
202
285
  cachedTokens?: number | undefined;
286
+ serverToolUses?: {
287
+ [x: string]: number;
288
+ } | undefined;
289
+ reserveTtlMs?: number | undefined;
203
290
  costNanos?: number | undefined;
204
291
  latencyMs?: number | undefined;
205
292
  rerunOf?: string | undefined;
@@ -233,6 +320,10 @@ export declare class AIBudget {
233
320
  promptTokens?: number | undefined;
234
321
  completionTokens?: number | undefined;
235
322
  cachedTokens?: number | undefined;
323
+ serverToolUses?: {
324
+ [x: string]: number;
325
+ } | undefined;
326
+ reserveTtlMs?: number | undefined;
236
327
  costNanos?: number | undefined;
237
328
  latencyMs?: number | undefined;
238
329
  rerunOf?: string | undefined;
@@ -262,6 +353,10 @@ export declare class AIBudget {
262
353
  promptTokens?: number | undefined;
263
354
  completionTokens?: number | undefined;
264
355
  cachedTokens?: number | undefined;
356
+ serverToolUses?: {
357
+ [x: string]: number;
358
+ } | undefined;
359
+ reserveTtlMs?: number | undefined;
265
360
  costNanos?: number | undefined;
266
361
  latencyMs?: number | undefined;
267
362
  rerunOf?: string | undefined;
@@ -742,7 +837,7 @@ export declare class AIBudget {
742
837
  models: string[];
743
838
  }) => Promise<null>;
744
839
  };
745
- /** Per-model prices (cents per million tokens). */
840
+ /** Per-model prices (nanodollars per million tokens) + server-tool fees. */
746
841
  get prices(): {
747
842
  list: (ctx: RunQueryCtx) => Promise<{
748
843
  [x: string]: {
@@ -759,6 +854,15 @@ export declare class AIBudget {
759
854
  /** Cache-read rate; defaults to a discount off input if omitted. */
760
855
  cachedNanosPerMTok?: number;
761
856
  }) => Promise<null>;
857
+ /** Per-call fees for provider server tools (web search, etc.). */
858
+ listServerTools: (ctx: RunQueryCtx) => Promise<{
859
+ [x: string]: number;
860
+ }>;
861
+ /** Set a server-tool's per-call price, e.g. { tool: "web_search", nanosPerCall }. */
862
+ setServerTool: (ctx: RunMutationCtx, args: {
863
+ tool: string;
864
+ nanosPerCall: number;
865
+ }) => Promise<null>;
762
866
  };
763
867
  /**
764
868
  * Mount the built-in admin dashboard on your app's HTTP router with one call.
@@ -786,6 +890,37 @@ export declare class AIBudget {
786
890
  /** Return true to allow the request. Runs on the HTML page and every API call. */
787
891
  authorize?: (ctx: any, request: Request) => boolean | Promise<boolean>;
788
892
  }): void;
893
+ /**
894
+ * Mount a POST webhook that settles a `begin`-reserved request from a
895
+ * provider's completion callback (async image/video jobs). You supply
896
+ * `resolve` — verify the payload's signature and map the provider's job id to
897
+ * your stored `requestId` + final usage/cost; the helper calls `settle`.
898
+ * Return `null` to ignore an unrecognized/duplicate callback (HTTP 202).
899
+ *
900
+ * ai.registerWebhook(http, {
901
+ * path: "/aibudget/video-done",
902
+ * resolve: async (ctx, request, body) => {
903
+ * if (!verifySignature(request, body)) return null;
904
+ * const job = await lookupJob(ctx, body.id); // your table: { requestId }
905
+ * return { requestId: job.requestId, serverToolUses: { video_seconds: body.seconds } };
906
+ * },
907
+ * });
908
+ */
909
+ registerWebhook(http: HttpRouter, opts: {
910
+ path?: string;
911
+ resolve: (ctx: any, request: Request, body: any) => Promise<({
912
+ requestId: string;
913
+ } & {
914
+ responseText?: string;
915
+ error?: string;
916
+ usage?: any;
917
+ promptTokens?: number;
918
+ completionTokens?: number;
919
+ cachedTokens?: number;
920
+ serverToolUses?: Record<string, number>;
921
+ costNanos?: number;
922
+ }) | null>;
923
+ }): void;
789
924
  }
790
925
  /** @deprecated Renamed to `AIBudget`. */
791
926
  export declare const WorryFreeAI: typeof AIBudget;
@@ -155,45 +155,136 @@ export class AIBudget {
155
155
  }
156
156
  }
157
157
  /**
158
- * One-shot chat through the AI Gateway with tracking + limits.
159
- * Call from an action. `userId` defaults to the authenticated caller.
158
+ * Reserve budget for a call WITHOUT running it the async half of the
159
+ * lifecycle. Use for long-running jobs (video generation) where the result
160
+ * arrives minutes later via a poll or webhook: `begin` here, then `settle`
161
+ * from that later context with the request's `requestId`. Returns the
162
+ * admission result (does NOT throw over a cap — check `allowed`). Set
163
+ * `reserveTtlMs` to the job's max duration so the hold isn't reaped mid-flight.
160
164
  */
161
- async chat(ctx, args = {}) {
162
- const model = args.model ?? this.defaultModel;
163
- const userId = await resolveUserId(ctx, args.userId);
164
- const actionName = await resolveActionName(ctx, args.action);
165
- const messages = args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
165
+ async begin(ctx, opts) {
166
+ const userId = await resolveUserId(ctx, opts.userId);
167
+ const actionName = await resolveActionName(ctx, opts.action);
166
168
  const started = await ctx.runMutation(this.component.lib.startRequest, {
167
169
  userId,
168
170
  actionName,
169
- tags: args.tags,
170
- model,
171
- messages,
172
- rerunOf: args.rerunOf,
171
+ tags: opts.tags,
172
+ model: opts.model,
173
+ messages: opts.messages ?? [],
174
+ estimatedCostNanos: opts.estimatedCostNanos,
175
+ reserveTtlMs: opts.reserveTtlMs,
176
+ rerunOf: opts.rerunOf,
173
177
  });
174
- if (!started.allowed) {
178
+ if (started.allowed) {
179
+ await this.fireBudgetEvents({ userId, action: actionName, tags: opts.tags, requestId: started.requestId }, started.warnings, started.notices);
180
+ }
181
+ else {
175
182
  await this.fireLimitReached({
176
183
  userId,
177
184
  action: actionName,
178
- tags: args.tags,
185
+ tags: opts.tags,
179
186
  messages: [started.reason],
180
187
  code: started.code,
181
188
  reason: started.reason,
182
189
  });
190
+ }
191
+ return started;
192
+ }
193
+ /**
194
+ * Record the actual usage/cost of a `begin`-reserved request and release its
195
+ * reservation. Idempotent (exactly-once server-side). Pass a raw provider
196
+ * `usage` (auto-normalized) or explicit token counts, plus optional
197
+ * `serverToolUses` and an authoritative `costNanos`.
198
+ */
199
+ async settle(ctx, args) {
200
+ const { requestId, usage, promptTokens, completionTokens, cachedTokens, ...rest } = args;
201
+ const tokens = promptTokens !== undefined ||
202
+ completionTokens !== undefined ||
203
+ cachedTokens !== undefined
204
+ ? {
205
+ promptTokens: promptTokens ?? 0,
206
+ completionTokens: completionTokens ?? 0,
207
+ cachedTokens: cachedTokens ?? 0,
208
+ }
209
+ : usage !== undefined
210
+ ? extractUsage(usage)
211
+ : {};
212
+ return ctx.runMutation(this.component.lib.finishRequest, {
213
+ requestId: requestId,
214
+ ...tokens,
215
+ ...rest,
216
+ });
217
+ }
218
+ /**
219
+ * Meter ANY synchronous LLM call — gateway, a provider SDK, a raw fetch — with
220
+ * the same budgets, audit log, and cost tracking. Reserves before your `run`
221
+ * (throwing a ConvexError over a hard cap), runs it, then records the actual
222
+ * usage/cost. The provider-agnostic core; `chat` is sugar over it. (For async
223
+ * jobs that settle later, use `begin`/`settle` instead.)
224
+ *
225
+ * `run` returns what happened. Pass a raw provider `usage` object (auto-
226
+ * normalized) OR explicit `promptTokens`/`completionTokens`/`cachedTokens`,
227
+ * plus optional `serverToolUses` (e.g. `{ web_search: 3 }`) and an
228
+ * authoritative `costNanos` (used verbatim if present).
229
+ */
230
+ async meter(ctx, opts, run) {
231
+ const started = await this.begin(ctx, opts);
232
+ if (!started.allowed) {
183
233
  throw new ConvexError({
184
234
  kind: "AIBudgetLimit",
185
235
  code: started.code,
186
236
  reason: started.reason,
187
237
  });
188
238
  }
189
- const requestId = started.requestId;
190
- const warnings = started.warnings;
191
- const notices = started.notices;
192
- await this.fireBudgetEvents({ userId, action: actionName, tags: args.tags, requestId }, warnings, notices);
239
+ const { requestId, warnings, notices } = started;
193
240
  const start = Date.now();
194
241
  try {
195
- // The full chain (incl. system) is stored on the request for audit/replay,
196
- // but the AI SDK wants system prompts in the `system` option, not messages.
242
+ const out = await run();
243
+ const { costNanos } = await this.settle(ctx, {
244
+ requestId,
245
+ responseText: out.text,
246
+ usage: out.usage,
247
+ promptTokens: out.promptTokens,
248
+ completionTokens: out.completionTokens,
249
+ cachedTokens: out.cachedTokens,
250
+ serverToolUses: out.serverToolUses,
251
+ costNanos: out.costNanos,
252
+ latencyMs: Date.now() - start,
253
+ });
254
+ // Re-derive the recorded usage for the return value.
255
+ const usage = out.promptTokens !== undefined ||
256
+ out.completionTokens !== undefined ||
257
+ out.cachedTokens !== undefined
258
+ ? {
259
+ promptTokens: out.promptTokens ?? 0,
260
+ completionTokens: out.completionTokens ?? 0,
261
+ cachedTokens: out.cachedTokens ?? 0,
262
+ }
263
+ : extractUsage(out.usage);
264
+ return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
265
+ }
266
+ catch (e) {
267
+ await this.settle(ctx, { requestId, error: String(e), latencyMs: Date.now() - start });
268
+ throw e;
269
+ }
270
+ }
271
+ /**
272
+ * One-shot chat through the AI Gateway with tracking + limits — sugar over
273
+ * `meter`. Call from an action. `userId` defaults to the authenticated caller.
274
+ */
275
+ async chat(ctx, args = {}) {
276
+ const model = args.model ?? this.defaultModel;
277
+ const messages = args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
278
+ return this.meter(ctx, {
279
+ model,
280
+ messages,
281
+ userId: args.userId,
282
+ action: args.action,
283
+ tags: args.tags,
284
+ rerunOf: args.rerunOf,
285
+ }, async () => {
286
+ // The full chain (incl. system) is stored for audit/replay, but the AI
287
+ // SDK wants system prompts in the `system` option, not messages.
197
288
  const system = messages
198
289
  .filter((m) => m.role === "system")
199
290
  .map((m) => m.content)
@@ -204,24 +295,12 @@ export class AIBudget {
204
295
  ...(system ? { system } : {}),
205
296
  messages: convo,
206
297
  });
207
- const usage = extractUsage(result.usage);
208
- const { costNanos } = await ctx.runMutation(this.component.lib.finishRequest, {
209
- requestId,
210
- responseText: result.text,
211
- ...usage,
298
+ return {
299
+ text: result.text,
300
+ usage: result.usage,
212
301
  costNanos: extractGatewayCostNanos(result),
213
- latencyMs: Date.now() - start,
214
- });
215
- return { text: result.text, requestId, costNanos, warnings, notices, ...usage };
216
- }
217
- catch (e) {
218
- await ctx.runMutation(this.component.lib.finishRequest, {
219
- requestId,
220
- error: String(e),
221
- latencyMs: Date.now() - start,
222
- });
223
- throw e;
224
- }
302
+ };
303
+ });
225
304
  }
226
305
  /**
227
306
  * An AI SDK LanguageModel that enforces limits and records usage/cost for
@@ -467,12 +546,16 @@ export class AIBudget {
467
546
  setPolicy: (ctx, args) => ctx.runMutation(c.lib.setModelPolicy, args),
468
547
  };
469
548
  }
470
- /** Per-model prices (cents per million tokens). */
549
+ /** Per-model prices (nanodollars per million tokens) + server-tool fees. */
471
550
  get prices() {
472
551
  const c = this.component;
473
552
  return {
474
553
  list: (ctx) => ctx.runQuery(c.lib.listPrices, {}),
475
554
  set: (ctx, args) => ctx.runMutation(c.lib.setPrice, args),
555
+ /** Per-call fees for provider server tools (web search, etc.). */
556
+ listServerTools: (ctx) => ctx.runQuery(c.lib.listServerToolPrices, {}),
557
+ /** Set a server-tool's per-call price, e.g. { tool: "web_search", nanosPerCall }. */
558
+ setServerTool: (ctx, args) => ctx.runMutation(c.lib.setServerToolPrice, args),
476
559
  };
477
560
  }
478
561
  /**
@@ -586,6 +669,38 @@ export class AIBudget {
586
669
  http.route({ pathPrefix: `${prefix}/`, method: "GET", handler });
587
670
  http.route({ pathPrefix: `${prefix}/`, method: "POST", handler });
588
671
  }
672
+ /**
673
+ * Mount a POST webhook that settles a `begin`-reserved request from a
674
+ * provider's completion callback (async image/video jobs). You supply
675
+ * `resolve` — verify the payload's signature and map the provider's job id to
676
+ * your stored `requestId` + final usage/cost; the helper calls `settle`.
677
+ * Return `null` to ignore an unrecognized/duplicate callback (HTTP 202).
678
+ *
679
+ * ai.registerWebhook(http, {
680
+ * path: "/aibudget/video-done",
681
+ * resolve: async (ctx, request, body) => {
682
+ * if (!verifySignature(request, body)) return null;
683
+ * const job = await lookupJob(ctx, body.id); // your table: { requestId }
684
+ * return { requestId: job.requestId, serverToolUses: { video_seconds: body.seconds } };
685
+ * },
686
+ * });
687
+ */
688
+ registerWebhook(http, opts) {
689
+ const path = opts.path ?? "/aibudget/webhook";
690
+ const self = this;
691
+ http.route({
692
+ path,
693
+ method: "POST",
694
+ handler: httpActionGeneric(async (ctx, request) => {
695
+ const body = await request.json().catch(() => ({}));
696
+ const settle = await opts.resolve(ctx, request, body);
697
+ if (!settle)
698
+ return new Response("ignored", { status: 202 });
699
+ await self.settle(ctx, settle);
700
+ return new Response("ok");
701
+ }),
702
+ });
703
+ }
589
704
  }
590
705
  /** @deprecated Renamed to `AIBudget`. */
591
706
  export const WorryFreeAI = AIBudget;
@@ -55,6 +55,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
55
55
  promptTokens?: number;
56
56
  requestId: string;
57
57
  responseText?: string;
58
+ serverToolUses?: Record<string, number>;
58
59
  }, {
59
60
  costNanos: number;
60
61
  }, Name>;
@@ -96,6 +97,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
96
97
  userId?: string;
97
98
  value?: string;
98
99
  }, any, Name>;
100
+ listServerToolPrices: FunctionReference<"query", "internal", {}, any, Name>;
99
101
  setAlertDefaults: FunctionReference<"mutation", "internal", {
100
102
  warnAtPct?: number;
101
103
  }, null, Name>;
@@ -132,14 +134,20 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
132
134
  setRetention: FunctionReference<"mutation", "internal", {
133
135
  retentionMs: number;
134
136
  }, null, Name>;
137
+ setServerToolPrice: FunctionReference<"mutation", "internal", {
138
+ nanosPerCall: number;
139
+ tool: string;
140
+ }, null, Name>;
135
141
  startRequest: FunctionReference<"mutation", "internal", {
136
142
  actionName?: string;
143
+ estimatedCostNanos?: number;
137
144
  messages: Array<{
138
145
  content: string;
139
146
  role: string;
140
147
  }>;
141
148
  model: string;
142
149
  rerunOf?: string;
150
+ reserveTtlMs?: number;
143
151
  tags?: Array<{
144
152
  dimension: string;
145
153
  value: string;