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

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,35 @@ 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
+
208
237
  ### Replay
209
238
 
210
239
  ```ts
@@ -390,6 +419,17 @@ ai.prices.set(ctx, { model, inputNanosPerMTok, outputNanosPerMTok, cachedNanosPe
390
419
  ai.prices.list(ctx)
391
420
  ```
392
421
 
422
+ **Server tools.** Provider server-side tools bill a per-call fee on top of
423
+ tokens (e.g. Anthropic web search). Report them from `meter` as
424
+ `serverToolUses: { web_search: 3 }` and they're priced per call (default
425
+ $0.01/`web_search`) — unless you pass an authoritative `costNanos`, which already
426
+ includes them. Override the rate:
427
+
428
+ ```ts
429
+ ai.prices.setServerTool(ctx, { tool: "web_search", nanosPerCall: 12_000_000 })
430
+ ai.prices.listServerTools(ctx)
431
+ ```
432
+
393
433
  The gateway's `provider/model` ids match OpenRouter's, whose public models
394
434
  endpoint returns per-token pricing — so you can keep prices current from your own
395
435
  action (see `example/convex/ai.ts` → `syncPrices`):
@@ -529,22 +569,31 @@ npm run dev # terminal 2 — Vite app
529
569
 
530
570
  ### What's in the component vs. the demo
531
571
 
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
572
+ The published AI Budget component (`src/`) is **only** the metering/budget
573
+ primitive — it knows nothing about agents or evaluation. The example composes
574
+ three isolated pieces:
575
+
576
+ - The **Agent component** owns agent threads, messages, tools, and generation.
577
+ - The local **Evaluation component** (`example/convex/evaluations/`) owns immutable
578
+ case snapshots, run lifecycle, and results. It does not call Agent or AI Budget.
579
+ - App-level adapters in `example/convex/ai.ts` compose the siblings: they obtain
580
+ source traffic, run candidate/judge model calls through AI Budget, attach an
581
+ `evalRun` budget tag, and persist outcomes into Evaluation.
582
+ - The **eval playground** (🧪 Experiment tab) exposes **Matrix** (one prompt across a
538
583
  system-prompt × model grid, ranked by an LLM judge on *your* criteria),
539
584
  **Backtest** (replay a candidate prompt against an action's real historical
540
585
  requests and judge each), and **Evolve** (an LLM iteratively improves a prompt
541
586
  toward a goal on real traffic, **stopping when it hits a spend budget**).
542
587
 
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.
588
+ The demo currently snapshots AI Budget audit traffic as its corpus. An Agent-based
589
+ product should instead make the app adapter snapshot cases through Agent's public
590
+ API; Evaluation must never inspect Agent's private tables. The same Evaluation
591
+ component works with either source because its inputs are explicit snapshots.
592
+
593
+ This local component is a proof of the reusable boundary, not part of the
594
+ `@convex-dev/ai-budget` package. If productized, it should ship independently
595
+ (for example, `@convex-dev/evals`) and accept application adapters/function
596
+ handles rather than taking a dependency on either sibling component.
548
597
 
549
598
  ---
550
599
 
@@ -566,7 +615,9 @@ npm run build # emit dist/ (client + component) for publishing
566
615
  - `src/client/` — **the client** (published): the `AIBudget` class — `chat`,
567
616
  `languageModel`, `registerRoutes`, and the namespaced admin API.
568
617
  - `example/` — **the demo app** (not published): real features, the eval
569
- playground, and the UI, all built on the two directories above.
618
+ playground, and the UI. `example/convex/evaluations/` is a separate local
619
+ component for datasets, runs, and results; `example/convex/ai.ts` is the
620
+ composition layer.
570
621
 
571
622
  ## License
572
623
 
@@ -113,8 +113,35 @@ 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
+ * Meter ANY LLM call gateway, a provider SDK, a raw fetch — with the same
117
+ * budgets, audit log, and cost tracking. Reserves before your `run` (throwing
118
+ * a ConvexError over a hard cap), runs it, then records the actual usage/cost.
119
+ * This is the provider-agnostic core; `chat` is sugar over it for the gateway.
120
+ *
121
+ * `run` returns what happened. Pass a raw provider `usage` object (auto-
122
+ * normalized) OR explicit `promptTokens`/`completionTokens`/`cachedTokens`,
123
+ * plus optional `serverToolUses` (e.g. `{ web_search: 3 }`, priced on top of
124
+ * tokens) and an authoritative `costNanos` (used verbatim if present).
125
+ */
126
+ meter(ctx: RunMutationCtx, opts: {
127
+ model: string;
128
+ messages: Message[];
129
+ userId?: string;
130
+ action?: string;
131
+ tags?: Tag[];
132
+ rerunOf?: string;
133
+ }, run: () => Promise<{
134
+ text?: string;
135
+ usage?: any;
136
+ promptTokens?: number;
137
+ completionTokens?: number;
138
+ cachedTokens?: number;
139
+ serverToolUses?: Record<string, number>;
140
+ costNanos?: number;
141
+ }>): Promise<ChatResult>;
142
+ /**
143
+ * One-shot chat through the AI Gateway with tracking + limits — sugar over
144
+ * `meter`. Call from an action. `userId` defaults to the authenticated caller.
118
145
  */
119
146
  chat(ctx: RunMutationCtx, args?: {
120
147
  /** Whom to bill. Defaults to the authenticated user (ctx.auth). */
@@ -168,6 +195,9 @@ export declare class AIBudget {
168
195
  promptTokens?: number | undefined;
169
196
  completionTokens?: number | undefined;
170
197
  cachedTokens?: number | undefined;
198
+ serverToolUses?: {
199
+ [x: string]: number;
200
+ } | undefined;
171
201
  costNanos?: number | undefined;
172
202
  latencyMs?: number | undefined;
173
203
  rerunOf?: string | undefined;
@@ -200,6 +230,9 @@ export declare class AIBudget {
200
230
  promptTokens?: number | undefined;
201
231
  completionTokens?: number | undefined;
202
232
  cachedTokens?: number | undefined;
233
+ serverToolUses?: {
234
+ [x: string]: number;
235
+ } | undefined;
203
236
  costNanos?: number | undefined;
204
237
  latencyMs?: number | undefined;
205
238
  rerunOf?: string | undefined;
@@ -233,6 +266,9 @@ export declare class AIBudget {
233
266
  promptTokens?: number | undefined;
234
267
  completionTokens?: number | undefined;
235
268
  cachedTokens?: number | undefined;
269
+ serverToolUses?: {
270
+ [x: string]: number;
271
+ } | undefined;
236
272
  costNanos?: number | undefined;
237
273
  latencyMs?: number | undefined;
238
274
  rerunOf?: string | undefined;
@@ -262,6 +298,9 @@ export declare class AIBudget {
262
298
  promptTokens?: number | undefined;
263
299
  completionTokens?: number | undefined;
264
300
  cachedTokens?: number | undefined;
301
+ serverToolUses?: {
302
+ [x: string]: number;
303
+ } | undefined;
265
304
  costNanos?: number | undefined;
266
305
  latencyMs?: number | undefined;
267
306
  rerunOf?: string | undefined;
@@ -742,7 +781,7 @@ export declare class AIBudget {
742
781
  models: string[];
743
782
  }) => Promise<null>;
744
783
  };
745
- /** Per-model prices (cents per million tokens). */
784
+ /** Per-model prices (nanodollars per million tokens) + server-tool fees. */
746
785
  get prices(): {
747
786
  list: (ctx: RunQueryCtx) => Promise<{
748
787
  [x: string]: {
@@ -759,6 +798,15 @@ export declare class AIBudget {
759
798
  /** Cache-read rate; defaults to a discount off input if omitted. */
760
799
  cachedNanosPerMTok?: number;
761
800
  }) => Promise<null>;
801
+ /** Per-call fees for provider server tools (web search, etc.). */
802
+ listServerTools: (ctx: RunQueryCtx) => Promise<{
803
+ [x: string]: number;
804
+ }>;
805
+ /** Set a server-tool's per-call price, e.g. { tool: "web_search", nanosPerCall }. */
806
+ setServerTool: (ctx: RunMutationCtx, args: {
807
+ tool: string;
808
+ nanosPerCall: number;
809
+ }) => Promise<null>;
762
810
  };
763
811
  /**
764
812
  * Mount the built-in admin dashboard on your app's HTTP router with one call.
@@ -155,27 +155,32 @@ 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
+ * Meter ANY LLM call gateway, a provider SDK, a raw fetch — with the same
159
+ * budgets, audit log, and cost tracking. Reserves before your `run` (throwing
160
+ * a ConvexError over a hard cap), runs it, then records the actual usage/cost.
161
+ * This is the provider-agnostic core; `chat` is sugar over it for the gateway.
162
+ *
163
+ * `run` returns what happened. Pass a raw provider `usage` object (auto-
164
+ * normalized) OR explicit `promptTokens`/`completionTokens`/`cachedTokens`,
165
+ * plus optional `serverToolUses` (e.g. `{ web_search: 3 }`, priced on top of
166
+ * tokens) and an authoritative `costNanos` (used verbatim if present).
160
167
  */
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 ?? "" }];
168
+ async meter(ctx, opts, run) {
169
+ const userId = await resolveUserId(ctx, opts.userId);
170
+ const actionName = await resolveActionName(ctx, opts.action);
166
171
  const started = await ctx.runMutation(this.component.lib.startRequest, {
167
172
  userId,
168
173
  actionName,
169
- tags: args.tags,
170
- model,
171
- messages,
172
- rerunOf: args.rerunOf,
174
+ tags: opts.tags,
175
+ model: opts.model,
176
+ messages: opts.messages,
177
+ rerunOf: opts.rerunOf,
173
178
  });
174
179
  if (!started.allowed) {
175
180
  await this.fireLimitReached({
176
181
  userId,
177
182
  action: actionName,
178
- tags: args.tags,
183
+ tags: opts.tags,
179
184
  messages: [started.reason],
180
185
  code: started.code,
181
186
  reason: started.reason,
@@ -187,32 +192,30 @@ export class AIBudget {
187
192
  });
188
193
  }
189
194
  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);
195
+ const { warnings, notices } = started;
196
+ await this.fireBudgetEvents({ userId, action: actionName, tags: opts.tags, requestId }, warnings, notices);
193
197
  const start = Date.now();
194
198
  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.
197
- const system = messages
198
- .filter((m) => m.role === "system")
199
- .map((m) => m.content)
200
- .join("\n\n") || undefined;
201
- const convo = messages.filter((m) => m.role !== "system");
202
- const result = await generateText({
203
- model: convexGateway(model),
204
- ...(system ? { system } : {}),
205
- messages: convo,
206
- });
207
- const usage = extractUsage(result.usage);
199
+ const out = await run();
200
+ // Explicit token fields win; otherwise normalize a raw provider usage.
201
+ const usage = out.promptTokens !== undefined ||
202
+ out.completionTokens !== undefined ||
203
+ out.cachedTokens !== undefined
204
+ ? {
205
+ promptTokens: out.promptTokens ?? 0,
206
+ completionTokens: out.completionTokens ?? 0,
207
+ cachedTokens: out.cachedTokens ?? 0,
208
+ }
209
+ : extractUsage(out.usage);
208
210
  const { costNanos } = await ctx.runMutation(this.component.lib.finishRequest, {
209
211
  requestId,
210
- responseText: result.text,
212
+ responseText: out.text,
211
213
  ...usage,
212
- costNanos: extractGatewayCostNanos(result),
214
+ serverToolUses: out.serverToolUses,
215
+ costNanos: out.costNanos,
213
216
  latencyMs: Date.now() - start,
214
217
  });
215
- return { text: result.text, requestId, costNanos, warnings, notices, ...usage };
218
+ return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
216
219
  }
217
220
  catch (e) {
218
221
  await ctx.runMutation(this.component.lib.finishRequest, {
@@ -223,6 +226,40 @@ export class AIBudget {
223
226
  throw e;
224
227
  }
225
228
  }
229
+ /**
230
+ * One-shot chat through the AI Gateway with tracking + limits — sugar over
231
+ * `meter`. Call from an action. `userId` defaults to the authenticated caller.
232
+ */
233
+ async chat(ctx, args = {}) {
234
+ const model = args.model ?? this.defaultModel;
235
+ const messages = args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
236
+ return this.meter(ctx, {
237
+ model,
238
+ messages,
239
+ userId: args.userId,
240
+ action: args.action,
241
+ tags: args.tags,
242
+ rerunOf: args.rerunOf,
243
+ }, async () => {
244
+ // The full chain (incl. system) is stored for audit/replay, but the AI
245
+ // SDK wants system prompts in the `system` option, not messages.
246
+ const system = messages
247
+ .filter((m) => m.role === "system")
248
+ .map((m) => m.content)
249
+ .join("\n\n") || undefined;
250
+ const convo = messages.filter((m) => m.role !== "system");
251
+ const result = await generateText({
252
+ model: convexGateway(model),
253
+ ...(system ? { system } : {}),
254
+ messages: convo,
255
+ });
256
+ return {
257
+ text: result.text,
258
+ usage: result.usage,
259
+ costNanos: extractGatewayCostNanos(result),
260
+ };
261
+ });
262
+ }
226
263
  /**
227
264
  * An AI SDK LanguageModel that enforces limits and records usage/cost for
228
265
  * `userId` on every call. Drop it into `generateText`, `streamText`, or the
@@ -467,12 +504,16 @@ export class AIBudget {
467
504
  setPolicy: (ctx, args) => ctx.runMutation(c.lib.setModelPolicy, args),
468
505
  };
469
506
  }
470
- /** Per-model prices (cents per million tokens). */
507
+ /** Per-model prices (nanodollars per million tokens) + server-tool fees. */
471
508
  get prices() {
472
509
  const c = this.component;
473
510
  return {
474
511
  list: (ctx) => ctx.runQuery(c.lib.listPrices, {}),
475
512
  set: (ctx, args) => ctx.runMutation(c.lib.setPrice, args),
513
+ /** Per-call fees for provider server tools (web search, etc.). */
514
+ listServerTools: (ctx) => ctx.runQuery(c.lib.listServerToolPrices, {}),
515
+ /** Set a server-tool's per-call price, e.g. { tool: "web_search", nanosPerCall }. */
516
+ setServerTool: (ctx, args) => ctx.runMutation(c.lib.setServerToolPrice, args),
476
517
  };
477
518
  }
478
519
  /**
@@ -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,6 +134,10 @@ 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;
137
143
  messages: Array<{
@@ -27,6 +27,7 @@ export declare const finishRequest: import("convex/server").RegisteredMutation<"
27
27
  promptTokens?: number | undefined;
28
28
  completionTokens?: number | undefined;
29
29
  cachedTokens?: number | undefined;
30
+ serverToolUses?: Record<string, number> | undefined;
30
31
  costNanos?: number | undefined;
31
32
  latencyMs?: number | undefined;
32
33
  requestId: import("convex/values").GenericId<"requests">;
@@ -65,6 +66,7 @@ export declare const lineage: import("convex/server").RegisteredQuery<"public",
65
66
  promptTokens?: number | undefined;
66
67
  completionTokens?: number | undefined;
67
68
  cachedTokens?: number | undefined;
69
+ serverToolUses?: Record<string, number> | undefined;
68
70
  costNanos?: number | undefined;
69
71
  latencyMs?: number | undefined;
70
72
  rerunOf?: import("convex/values").GenericId<"requests"> | undefined;
@@ -94,6 +96,7 @@ export declare const lineage: import("convex/server").RegisteredQuery<"public",
94
96
  promptTokens?: number | undefined;
95
97
  completionTokens?: number | undefined;
96
98
  cachedTokens?: number | undefined;
99
+ serverToolUses?: Record<string, number> | undefined;
97
100
  costNanos?: number | undefined;
98
101
  latencyMs?: number | undefined;
99
102
  rerunOf?: import("convex/values").GenericId<"requests"> | undefined;
@@ -126,6 +129,7 @@ export declare const getRequest: import("convex/server").RegisteredQuery<"public
126
129
  promptTokens?: number | undefined;
127
130
  completionTokens?: number | undefined;
128
131
  cachedTokens?: number | undefined;
132
+ serverToolUses?: Record<string, number> | undefined;
129
133
  costNanos?: number | undefined;
130
134
  latencyMs?: number | undefined;
131
135
  rerunOf?: import("convex/values").GenericId<"requests"> | undefined;
@@ -160,6 +164,7 @@ export declare const listRequests: import("convex/server").RegisteredQuery<"publ
160
164
  promptTokens?: number | undefined;
161
165
  completionTokens?: number | undefined;
162
166
  cachedTokens?: number | undefined;
167
+ serverToolUses?: Record<string, number> | undefined;
163
168
  costNanos?: number | undefined;
164
169
  latencyMs?: number | undefined;
165
170
  rerunOf?: import("convex/values").GenericId<"requests"> | undefined;
@@ -359,3 +364,10 @@ export declare const listPrices: import("convex/server").RegisteredQuery<"public
359
364
  cached?: number;
360
365
  overridden: boolean;
361
366
  }>>>;
367
+ export declare const listServerToolPrices: import("convex/server").RegisteredQuery<"public", {}, Promise<{
368
+ [x: string]: number;
369
+ }>>;
370
+ export declare const setServerToolPrice: import("convex/server").RegisteredMutation<"public", {
371
+ tool: string;
372
+ nanosPerCall: number;
373
+ }, Promise<null>>;
@@ -32,6 +32,14 @@ const DEFAULT_PRICES = {
32
32
  "openai/gpt-5": { input: 1_250_000_000, output: 10_000_000_000 },
33
33
  "openai/gpt-5-mini": { input: 250_000_000, output: 2_000_000_000 },
34
34
  };
35
+ // Per-call price (nanodollars) for provider server-side tools that bill a fee on
36
+ // top of tokens — e.g. Anthropic web search at ~$0.01/call. Keyed by the tool
37
+ // name the caller reports in `serverToolUses` (e.g. { web_search: 3 }). Used
38
+ // only when a request settles WITHOUT an authoritative gateway cost; if you pass
39
+ // `costNanos`, that already includes tool fees. Override via setServerToolPrice.
40
+ const DEFAULT_SERVER_TOOL_PRICES = {
41
+ web_search: 10_000_000, // $0.01 per search
42
+ };
35
43
  // Pessimistic assumed output length when reserving budget up front. This makes
36
44
  // concurrent admission atomic against the estimate; a response that exceeds the
37
45
  // estimate can still settle above the cap by the estimation delta.
@@ -99,6 +107,20 @@ const settleCost = (promptTokens, cachedTokens, completionTokens, price) => {
99
107
  (cached / 1e6) * cachedRate(price) +
100
108
  (completionTokens / 1e6) * price.output);
101
109
  };
110
+ // Per-call fees for provider server tools (web search, etc.), merging the
111
+ // defaults with any deployment overrides. Unknown tools price at 0 (recorded
112
+ // but not charged) rather than guessing.
113
+ const serverToolCost = (uses, overrides) => {
114
+ if (!uses)
115
+ return 0;
116
+ const prices = { ...DEFAULT_SERVER_TOOL_PRICES, ...(overrides ?? {}) };
117
+ let total = 0;
118
+ for (const [tool, count] of Object.entries(uses)) {
119
+ if (count > 0 && prices[tool] > 0)
120
+ total += Math.round(count * prices[tool]);
121
+ }
122
+ return total;
123
+ };
102
124
  // Upsert-add a settled amount into the durable per-(bucket, period) usage row.
103
125
  // These rows are never swept by request retention, so spend history survives.
104
126
  async function addUsage(ctx, dimension, value, period, stamp, spendNanos, tokens, requests) {
@@ -525,9 +547,13 @@ export const finishRequest = mutation({
525
547
  promptTokens: v.optional(v.number()),
526
548
  completionTokens: v.optional(v.number()),
527
549
  cachedTokens: v.optional(v.number()),
528
- // Authoritative cost from the gateway, if it ever reports one. When present
529
- // it's recorded verbatim (no token-based estimate); when absent we price
530
- // from tokens (cache-aware). Wired now so adopting a real cost is one line.
550
+ // Provider server-tool invocations that bill a per-call fee (e.g.
551
+ // { web_search: 3 }). Added to the token cost when no authoritative cost is
552
+ // supplied; recorded either way.
553
+ serverToolUses: v.optional(v.record(v.string(), v.number())),
554
+ // Authoritative cost from the gateway/provider, if reported. When present
555
+ // it's recorded verbatim (already includes any tool fees); when absent we
556
+ // price from tokens (cache-aware) plus server-tool fees.
531
557
  costNanos: v.optional(v.number()),
532
558
  latencyMs: v.optional(v.number()),
533
559
  },
@@ -551,11 +577,18 @@ export const finishRequest = mutation({
551
577
  const promptTokens = Math.max(0, args.promptTokens ?? 0);
552
578
  const completionTokens = Math.max(0, args.completionTokens ?? 0);
553
579
  const cachedTokens = Math.min(promptTokens, Math.max(0, args.cachedTokens ?? 0));
554
- // Prefer an authoritative gateway cost when supplied; otherwise price from
555
- // tokens, discounting the cached (prompt-cache-read) slice of the prompt.
556
- const costNanos = args.costNanos !== undefined && args.costNanos >= 0
557
- ? Math.round(args.costNanos)
558
- : settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model));
580
+ // Prefer an authoritative gateway cost when supplied (it already includes
581
+ // tool fees); otherwise price from tokens discounting the cached
582
+ // (prompt-cache-read) slice plus any server-tool per-call fees.
583
+ let costNanos;
584
+ if (args.costNanos !== undefined && args.costNanos >= 0) {
585
+ costNanos = Math.round(args.costNanos);
586
+ }
587
+ else {
588
+ const settings = await getSettings(ctx);
589
+ costNanos =
590
+ settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model)) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
591
+ }
559
592
  // Durable write to the request's OWN row only — uncontended, so it always
560
593
  // lands. `settled: false` hands it to the fold step; the row is never left
561
594
  // orphaned in "pending" even if the totals update below fails and retries.
@@ -566,6 +599,7 @@ export const finishRequest = mutation({
566
599
  promptTokens,
567
600
  completionTokens,
568
601
  ...(cachedTokens > 0 ? { cachedTokens } : {}),
602
+ ...(args.serverToolUses ? { serverToolUses: args.serverToolUses } : {}),
569
603
  costNanos,
570
604
  latencyMs: args.latencyMs,
571
605
  settled: false,
@@ -1139,3 +1173,27 @@ export const listPrices = query({
1139
1173
  return merged;
1140
1174
  },
1141
1175
  });
1176
+ // Per-call fees for provider server tools (web search, etc.), defaults merged
1177
+ // with any deployment overrides.
1178
+ export const listServerToolPrices = query({
1179
+ args: {},
1180
+ handler: async (ctx) => {
1181
+ const s = await getSettings(ctx);
1182
+ return { ...DEFAULT_SERVER_TOOL_PRICES, ...(s?.serverToolPrices ?? {}) };
1183
+ },
1184
+ });
1185
+ export const setServerToolPrice = mutation({
1186
+ args: { tool: v.string(), nanosPerCall: v.number() },
1187
+ returns: v.null(),
1188
+ handler: async (ctx, { tool, nanosPerCall }) => {
1189
+ if (nanosPerCall < 0)
1190
+ throw new Error("Prices must be non-negative");
1191
+ const s = await getSettings(ctx);
1192
+ const serverToolPrices = { ...(s?.serverToolPrices ?? {}), [tool]: nanosPerCall };
1193
+ if (s)
1194
+ await ctx.db.patch(s._id, { serverToolPrices });
1195
+ else
1196
+ await ctx.db.insert("settings", { key: "singleton", serverToolPrices });
1197
+ return null;
1198
+ },
1199
+ });
@@ -150,6 +150,7 @@ declare const _default: import("convex/server").SchemaDefinition<{
150
150
  promptTokens?: number | undefined;
151
151
  completionTokens?: number | undefined;
152
152
  cachedTokens?: number | undefined;
153
+ serverToolUses?: Record<string, number> | undefined;
153
154
  costNanos?: number | undefined;
154
155
  latencyMs?: number | undefined;
155
156
  rerunOf?: import("convex/values").GenericId<"requests"> | undefined;
@@ -195,10 +196,11 @@ declare const _default: import("convex/server").SchemaDefinition<{
195
196
  promptTokens: import("convex/values").VFloat64<number | undefined, "optional">;
196
197
  completionTokens: import("convex/values").VFloat64<number | undefined, "optional">;
197
198
  cachedTokens: import("convex/values").VFloat64<number | undefined, "optional">;
199
+ serverToolUses: import("convex/values").VRecord<Record<string, number> | undefined, import("convex/values").VString<string, "required">, import("convex/values").VFloat64<number, "required">, "optional", string>;
198
200
  costNanos: import("convex/values").VFloat64<number | undefined, "optional">;
199
201
  latencyMs: import("convex/values").VFloat64<number | undefined, "optional">;
200
202
  rerunOf: import("convex/values").VId<import("convex/values").GenericId<"requests"> | undefined, "optional">;
201
- }, "required", "userId" | "actionName" | "tags" | "model" | "estimatedNanos" | "estimatedTokens" | "unpricedModel" | "overBudget" | "settled" | "messages" | "status" | "error" | "responseText" | "promptTokens" | "completionTokens" | "cachedTokens" | "costNanos" | "latencyMs" | "rerunOf">, {
203
+ }, "required", "userId" | "actionName" | "tags" | "model" | "estimatedNanos" | "estimatedTokens" | "unpricedModel" | "overBudget" | "settled" | "messages" | "status" | "error" | "responseText" | "promptTokens" | "completionTokens" | "cachedTokens" | "serverToolUses" | "costNanos" | "latencyMs" | "rerunOf" | `serverToolUses.${string}`>, {
202
204
  userId: ["userId", "_creationTime"];
203
205
  status: ["status", "_creationTime"];
204
206
  rerunOf: ["rerunOf", "_creationTime"];
@@ -229,6 +231,7 @@ declare const _default: import("convex/server").SchemaDefinition<{
229
231
  globalBumpDayStamp?: string | undefined;
230
232
  retentionMs?: number | undefined;
231
233
  defaultWarnAtPct?: number | undefined;
234
+ serverToolPrices?: Record<string, number> | undefined;
232
235
  key: string;
233
236
  }, {
234
237
  key: import("convex/values").VString<string, "required">;
@@ -242,7 +245,8 @@ declare const _default: import("convex/server").SchemaDefinition<{
242
245
  globalBumpDayStamp: import("convex/values").VString<string | undefined, "optional">;
243
246
  retentionMs: import("convex/values").VFloat64<number | undefined, "optional">;
244
247
  defaultWarnAtPct: import("convex/values").VFloat64<number | undefined, "optional">;
245
- }, "required", "key" | "modelMode" | "models" | "globalDailySpendLimitNanos" | "globalLifetimeSpendLimitNanos" | "globalEnforcement" | "globalDailyBumpNanos" | "globalLifetimeBumpNanos" | "globalBumpDayStamp" | "retentionMs" | "defaultWarnAtPct">, {
248
+ serverToolPrices: import("convex/values").VRecord<Record<string, number> | undefined, import("convex/values").VString<string, "required">, import("convex/values").VFloat64<number, "required">, "optional", string>;
249
+ }, "required", "key" | "modelMode" | "models" | "globalDailySpendLimitNanos" | "globalLifetimeSpendLimitNanos" | "globalEnforcement" | "globalDailyBumpNanos" | "globalLifetimeBumpNanos" | "globalBumpDayStamp" | "retentionMs" | "defaultWarnAtPct" | "serverToolPrices" | `serverToolPrices.${string}`>, {
246
250
  key: ["key", "_creationTime"];
247
251
  }, {}, {}>;
248
252
  }, true>;
@@ -120,6 +120,9 @@ export default defineSchema({
120
120
  completionTokens: v.optional(v.number()),
121
121
  // subset of promptTokens served from the provider's prompt cache (cheaper).
122
122
  cachedTokens: v.optional(v.number()),
123
+ // server-side tool invocations that bill a per-call fee on top of tokens
124
+ // (e.g. { web_search: 3 }). Priced via serverToolPrices at settle.
125
+ serverToolUses: v.optional(v.record(v.string(), v.number())),
123
126
  costNanos: v.optional(v.number()),
124
127
  latencyMs: v.optional(v.number()),
125
128
  rerunOf: v.optional(v.id("requests")),
@@ -162,5 +165,8 @@ export default defineSchema({
162
165
  // default approaching-limit alert threshold (fraction of a cap) for buckets
163
166
  // that don't set their own warnAtPct. 0/unset disables threshold alerts.
164
167
  defaultWarnAtPct: v.optional(v.number()),
168
+ // per-call price (nanodollars) overrides for provider server tools, keyed by
169
+ // tool name (e.g. { web_search: 12_000_000 }). Merged over the defaults.
170
+ serverToolPrices: v.optional(v.record(v.string(), v.number())),
165
171
  }).index("key", ["key"]),
166
172
  });
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "support@convex.dev",
8
8
  "url": "https://github.com/get-convex/ai-budget/issues"
9
9
  },
10
- "version": "0.0.2-alpha.10",
10
+ "version": "0.0.2-alpha.12",
11
11
  "license": "Apache-2.0",
12
12
  "type": "module",
13
13
  "keywords": [
@@ -306,42 +306,51 @@ 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
+ * Meter ANY LLM call gateway, a provider SDK, a raw fetch — with the same
310
+ * budgets, audit log, and cost tracking. Reserves before your `run` (throwing
311
+ * a ConvexError over a hard cap), runs it, then records the actual usage/cost.
312
+ * This is the provider-agnostic core; `chat` is sugar over it for the gateway.
313
+ *
314
+ * `run` returns what happened. Pass a raw provider `usage` object (auto-
315
+ * normalized) OR explicit `promptTokens`/`completionTokens`/`cachedTokens`,
316
+ * plus optional `serverToolUses` (e.g. `{ web_search: 3 }`, priced on top of
317
+ * tokens) and an authoritative `costNanos` (used verbatim if present).
311
318
  */
312
- async chat(
319
+ async meter(
313
320
  ctx: RunMutationCtx,
314
- args: {
315
- /** Whom to bill. Defaults to the authenticated user (ctx.auth). */
321
+ opts: {
322
+ model: string;
323
+ messages: Message[];
316
324
  userId?: string;
317
- prompt?: string;
318
- messages?: Message[];
319
- model?: string;
320
- rerunOf?: string;
321
- /** Attribute spend to this action name. Defaults to the calling Convex action. */
322
325
  action?: string;
323
- /** Extra attribution dimensions to bill/limit (team, customer, env, …). */
324
326
  tags?: Tag[];
325
- } = {}
327
+ rerunOf?: string;
328
+ },
329
+ run: () => Promise<{
330
+ text?: string;
331
+ usage?: any;
332
+ promptTokens?: number;
333
+ completionTokens?: number;
334
+ cachedTokens?: number;
335
+ serverToolUses?: Record<string, number>;
336
+ costNanos?: number;
337
+ }>
326
338
  ): 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 ?? "" }];
339
+ const userId = await resolveUserId(ctx, opts.userId);
340
+ const actionName = await resolveActionName(ctx, opts.action);
332
341
  const started = await ctx.runMutation(this.component.lib.startRequest, {
333
342
  userId,
334
343
  actionName,
335
- tags: args.tags,
336
- model,
337
- messages,
338
- rerunOf: args.rerunOf as any,
344
+ tags: opts.tags,
345
+ model: opts.model,
346
+ messages: opts.messages,
347
+ rerunOf: opts.rerunOf as any,
339
348
  });
340
349
  if (!started.allowed) {
341
350
  await this.fireLimitReached({
342
351
  userId,
343
352
  action: actionName,
344
- tags: args.tags,
353
+ tags: opts.tags,
345
354
  messages: [started.reason],
346
355
  code: started.code,
347
356
  reason: started.reason,
@@ -353,40 +362,38 @@ export class AIBudget {
353
362
  });
354
363
  }
355
364
  const requestId = started.requestId;
356
- const warnings = started.warnings;
357
- const notices = started.notices;
365
+ const { warnings, notices } = started;
358
366
  await this.fireBudgetEvents(
359
- { userId, action: actionName, tags: args.tags, requestId },
367
+ { userId, action: actionName, tags: opts.tags, requestId },
360
368
  warnings,
361
369
  notices
362
370
  );
363
371
  const start = Date.now();
364
372
  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);
373
+ const out = await run();
374
+ // Explicit token fields win; otherwise normalize a raw provider usage.
375
+ const usage =
376
+ out.promptTokens !== undefined ||
377
+ out.completionTokens !== undefined ||
378
+ out.cachedTokens !== undefined
379
+ ? {
380
+ promptTokens: out.promptTokens ?? 0,
381
+ completionTokens: out.completionTokens ?? 0,
382
+ cachedTokens: out.cachedTokens ?? 0,
383
+ }
384
+ : extractUsage(out.usage);
379
385
  const { costNanos } = await ctx.runMutation(
380
386
  this.component.lib.finishRequest,
381
387
  {
382
388
  requestId,
383
- responseText: result.text,
389
+ responseText: out.text,
384
390
  ...usage,
385
- costNanos: extractGatewayCostNanos(result),
391
+ serverToolUses: out.serverToolUses,
392
+ costNanos: out.costNanos,
386
393
  latencyMs: Date.now() - start,
387
394
  }
388
395
  );
389
- return { text: result.text, requestId, costNanos, warnings, notices, ...usage };
396
+ return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
390
397
  } catch (e) {
391
398
  await ctx.runMutation(this.component.lib.finishRequest, {
392
399
  requestId,
@@ -397,6 +404,61 @@ export class AIBudget {
397
404
  }
398
405
  }
399
406
 
407
+ /**
408
+ * One-shot chat through the AI Gateway with tracking + limits — sugar over
409
+ * `meter`. Call from an action. `userId` defaults to the authenticated caller.
410
+ */
411
+ async chat(
412
+ ctx: RunMutationCtx,
413
+ args: {
414
+ /** Whom to bill. Defaults to the authenticated user (ctx.auth). */
415
+ userId?: string;
416
+ prompt?: string;
417
+ messages?: Message[];
418
+ model?: string;
419
+ rerunOf?: string;
420
+ /** Attribute spend to this action name. Defaults to the calling Convex action. */
421
+ action?: string;
422
+ /** Extra attribution dimensions to bill/limit (team, customer, env, …). */
423
+ tags?: Tag[];
424
+ } = {}
425
+ ): Promise<ChatResult> {
426
+ const model = args.model ?? this.defaultModel;
427
+ const messages: Message[] =
428
+ args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
429
+ return this.meter(
430
+ ctx,
431
+ {
432
+ model,
433
+ messages,
434
+ userId: args.userId,
435
+ action: args.action,
436
+ tags: args.tags,
437
+ rerunOf: args.rerunOf,
438
+ },
439
+ async () => {
440
+ // The full chain (incl. system) is stored for audit/replay, but the AI
441
+ // SDK wants system prompts in the `system` option, not messages.
442
+ const system =
443
+ messages
444
+ .filter((m) => m.role === "system")
445
+ .map((m) => m.content)
446
+ .join("\n\n") || undefined;
447
+ const convo = messages.filter((m) => m.role !== "system");
448
+ const result = await generateText({
449
+ model: convexGateway(model),
450
+ ...(system ? { system } : {}),
451
+ messages: convo as any,
452
+ });
453
+ return {
454
+ text: result.text,
455
+ usage: result.usage,
456
+ costNanos: extractGatewayCostNanos(result),
457
+ };
458
+ }
459
+ );
460
+ }
461
+
400
462
  /**
401
463
  * An AI SDK LanguageModel that enforces limits and records usage/cost for
402
464
  * `userId` on every call. Drop it into `generateText`, `streamText`, or the
@@ -720,7 +782,7 @@ export class AIBudget {
720
782
  };
721
783
  }
722
784
 
723
- /** Per-model prices (cents per million tokens). */
785
+ /** Per-model prices (nanodollars per million tokens) + server-tool fees. */
724
786
  get prices() {
725
787
  const c = this.component;
726
788
  return {
@@ -735,6 +797,14 @@ export class AIBudget {
735
797
  cachedNanosPerMTok?: number;
736
798
  }
737
799
  ) => ctx.runMutation(c.lib.setPrice, args),
800
+ /** Per-call fees for provider server tools (web search, etc.). */
801
+ listServerTools: (ctx: RunQueryCtx) =>
802
+ ctx.runQuery(c.lib.listServerToolPrices, {}),
803
+ /** Set a server-tool's per-call price, e.g. { tool: "web_search", nanosPerCall }. */
804
+ setServerTool: (
805
+ ctx: RunMutationCtx,
806
+ args: { tool: string; nanosPerCall: number }
807
+ ) => ctx.runMutation(c.lib.setServerToolPrice, args),
738
808
  };
739
809
  }
740
810
 
@@ -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,6 +218,13 @@ 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",
@@ -116,6 +116,51 @@ 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
+
119
164
  describe("durable usage history", () => {
120
165
  test("settled spend lands in a per-day usage row", async () => {
121
166
  const t = convexTest(schema, modules);
@@ -43,6 +43,15 @@ const DEFAULT_PRICES: Record<string, { input: number; output: number }> = {
43
43
  "openai/gpt-5-mini": { input: 250_000_000, output: 2_000_000_000 },
44
44
  };
45
45
 
46
+ // Per-call price (nanodollars) for provider server-side tools that bill a fee on
47
+ // top of tokens — e.g. Anthropic web search at ~$0.01/call. Keyed by the tool
48
+ // name the caller reports in `serverToolUses` (e.g. { web_search: 3 }). Used
49
+ // only when a request settles WITHOUT an authoritative gateway cost; if you pass
50
+ // `costNanos`, that already includes tool fees. Override via setServerToolPrice.
51
+ const DEFAULT_SERVER_TOOL_PRICES: Record<string, number> = {
52
+ web_search: 10_000_000, // $0.01 per search
53
+ };
54
+
46
55
  // Pessimistic assumed output length when reserving budget up front. This makes
47
56
  // concurrent admission atomic against the estimate; a response that exceeds the
48
57
  // estimate can still settle above the cap by the estimation delta.
@@ -136,6 +145,22 @@ const settleCost = (
136
145
  );
137
146
  };
138
147
 
148
+ // Per-call fees for provider server tools (web search, etc.), merging the
149
+ // defaults with any deployment overrides. Unknown tools price at 0 (recorded
150
+ // but not charged) rather than guessing.
151
+ const serverToolCost = (
152
+ uses: Record<string, number> | undefined,
153
+ overrides: Record<string, number> | undefined
154
+ ) => {
155
+ if (!uses) return 0;
156
+ const prices = { ...DEFAULT_SERVER_TOOL_PRICES, ...(overrides ?? {}) };
157
+ let total = 0;
158
+ for (const [tool, count] of Object.entries(uses)) {
159
+ if (count > 0 && prices[tool] > 0) total += Math.round(count * prices[tool]);
160
+ }
161
+ return total;
162
+ };
163
+
139
164
  // Upsert-add a settled amount into the durable per-(bucket, period) usage row.
140
165
  // These rows are never swept by request retention, so spend history survives.
141
166
  async function addUsage(
@@ -684,9 +709,13 @@ export const finishRequest = mutation({
684
709
  promptTokens: v.optional(v.number()),
685
710
  completionTokens: v.optional(v.number()),
686
711
  cachedTokens: v.optional(v.number()),
687
- // Authoritative cost from the gateway, if it ever reports one. When present
688
- // it's recorded verbatim (no token-based estimate); when absent we price
689
- // from tokens (cache-aware). Wired now so adopting a real cost is one line.
712
+ // Provider server-tool invocations that bill a per-call fee (e.g.
713
+ // { web_search: 3 }). Added to the token cost when no authoritative cost is
714
+ // supplied; recorded either way.
715
+ serverToolUses: v.optional(v.record(v.string(), v.number())),
716
+ // Authoritative cost from the gateway/provider, if reported. When present
717
+ // it's recorded verbatim (already includes any tool fees); when absent we
718
+ // price from tokens (cache-aware) plus server-tool fees.
690
719
  costNanos: v.optional(v.number()),
691
720
  latencyMs: v.optional(v.number()),
692
721
  },
@@ -711,17 +740,22 @@ export const finishRequest = mutation({
711
740
  const promptTokens = Math.max(0, args.promptTokens ?? 0);
712
741
  const completionTokens = Math.max(0, args.completionTokens ?? 0);
713
742
  const cachedTokens = Math.min(promptTokens, Math.max(0, args.cachedTokens ?? 0));
714
- // Prefer an authoritative gateway cost when supplied; otherwise price from
715
- // tokens, discounting the cached (prompt-cache-read) slice of the prompt.
716
- const costNanos =
717
- args.costNanos !== undefined && args.costNanos >= 0
718
- ? Math.round(args.costNanos)
719
- : settleCost(
720
- promptTokens,
721
- cachedTokens,
722
- completionTokens,
723
- await getPrice(ctx, request.model)
724
- );
743
+ // Prefer an authoritative gateway cost when supplied (it already includes
744
+ // tool fees); otherwise price from tokens discounting the cached
745
+ // (prompt-cache-read) slice — plus any server-tool per-call fees.
746
+ let costNanos: number;
747
+ if (args.costNanos !== undefined && args.costNanos >= 0) {
748
+ costNanos = Math.round(args.costNanos);
749
+ } else {
750
+ const settings = await getSettings(ctx);
751
+ costNanos =
752
+ settleCost(
753
+ promptTokens,
754
+ cachedTokens,
755
+ completionTokens,
756
+ await getPrice(ctx, request.model)
757
+ ) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
758
+ }
725
759
 
726
760
  // Durable write to the request's OWN row only — uncontended, so it always
727
761
  // lands. `settled: false` hands it to the fold step; the row is never left
@@ -733,6 +767,7 @@ export const finishRequest = mutation({
733
767
  promptTokens,
734
768
  completionTokens,
735
769
  ...(cachedTokens > 0 ? { cachedTokens } : {}),
770
+ ...(args.serverToolUses ? { serverToolUses: args.serverToolUses } : {}),
736
771
  costNanos,
737
772
  latencyMs: args.latencyMs,
738
773
  settled: false,
@@ -1347,3 +1382,26 @@ export const listPrices = query({
1347
1382
  return merged;
1348
1383
  },
1349
1384
  });
1385
+
1386
+ // Per-call fees for provider server tools (web search, etc.), defaults merged
1387
+ // with any deployment overrides.
1388
+ export const listServerToolPrices = query({
1389
+ args: {},
1390
+ handler: async (ctx) => {
1391
+ const s = await getSettings(ctx as any);
1392
+ return { ...DEFAULT_SERVER_TOOL_PRICES, ...(s?.serverToolPrices ?? {}) };
1393
+ },
1394
+ });
1395
+
1396
+ export const setServerToolPrice = mutation({
1397
+ args: { tool: v.string(), nanosPerCall: v.number() },
1398
+ returns: v.null(),
1399
+ handler: async (ctx, { tool, nanosPerCall }) => {
1400
+ if (nanosPerCall < 0) throw new Error("Prices must be non-negative");
1401
+ const s = await getSettings(ctx);
1402
+ const serverToolPrices = { ...(s?.serverToolPrices ?? {}), [tool]: nanosPerCall };
1403
+ if (s) await ctx.db.patch(s._id, { serverToolPrices });
1404
+ else await ctx.db.insert("settings", { key: "singleton", serverToolPrices });
1405
+ return null;
1406
+ },
1407
+ });
@@ -132,6 +132,9 @@ export default defineSchema({
132
132
  completionTokens: v.optional(v.number()),
133
133
  // subset of promptTokens served from the provider's prompt cache (cheaper).
134
134
  cachedTokens: v.optional(v.number()),
135
+ // server-side tool invocations that bill a per-call fee on top of tokens
136
+ // (e.g. { web_search: 3 }). Priced via serverToolPrices at settle.
137
+ serverToolUses: v.optional(v.record(v.string(), v.number())),
135
138
  costNanos: v.optional(v.number()),
136
139
  latencyMs: v.optional(v.number()),
137
140
  rerunOf: v.optional(v.id("requests")),
@@ -184,5 +187,8 @@ export default defineSchema({
184
187
  // default approaching-limit alert threshold (fraction of a cap) for buckets
185
188
  // that don't set their own warnAtPct. 0/unset disables threshold alerts.
186
189
  defaultWarnAtPct: v.optional(v.number()),
190
+ // per-call price (nanodollars) overrides for provider server tools, keyed by
191
+ // tool name (e.g. { web_search: 12_000_000 }). Merged over the defaults.
192
+ serverToolPrices: v.optional(v.record(v.string(), v.number())),
187
193
  }).index("key", ["key"]),
188
194
  });