@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 +63 -12
- package/dist/client/index.d.ts +51 -3
- package/dist/client/index.js +73 -32
- package/dist/component/_generated/component.d.ts +6 -0
- package/dist/component/lib.d.ts +12 -0
- package/dist/component/lib.js +66 -8
- package/dist/component/schema.d.ts +6 -2
- package/dist/component/schema.js +6 -0
- package/package.json +1 -1
- package/src/client/index.ts +113 -43
- package/src/component/_generated/component.ts +15 -0
- package/src/component/lib.test.ts +45 -0
- package/src/component/lib.ts +72 -14
- package/src/component/schema.ts +6 -0
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
|
|
533
|
-
nothing about
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
- The
|
|
537
|
-
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
|
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
|
|
package/dist/client/index.d.ts
CHANGED
|
@@ -113,8 +113,35 @@ export declare class AIBudget {
|
|
|
113
113
|
private fireBudgetEvents;
|
|
114
114
|
private fireLimitReached;
|
|
115
115
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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 (
|
|
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.
|
package/dist/client/index.js
CHANGED
|
@@ -155,27 +155,32 @@ export class AIBudget {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
|
162
|
-
const
|
|
163
|
-
const
|
|
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:
|
|
170
|
-
model,
|
|
171
|
-
messages,
|
|
172
|
-
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:
|
|
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
|
|
191
|
-
|
|
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
|
-
|
|
196
|
-
//
|
|
197
|
-
const
|
|
198
|
-
.
|
|
199
|
-
.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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:
|
|
212
|
+
responseText: out.text,
|
|
211
213
|
...usage,
|
|
212
|
-
|
|
214
|
+
serverToolUses: out.serverToolUses,
|
|
215
|
+
costNanos: out.costNanos,
|
|
213
216
|
latencyMs: Date.now() - start,
|
|
214
217
|
});
|
|
215
|
-
return { text:
|
|
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 (
|
|
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<{
|
package/dist/component/lib.d.ts
CHANGED
|
@@ -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>>;
|
package/dist/component/lib.js
CHANGED
|
@@ -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
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
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
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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>;
|
package/dist/component/schema.js
CHANGED
|
@@ -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
package/src/client/index.ts
CHANGED
|
@@ -306,42 +306,51 @@ export class AIBudget {
|
|
|
306
306
|
}
|
|
307
307
|
|
|
308
308
|
/**
|
|
309
|
-
*
|
|
310
|
-
*
|
|
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
|
|
319
|
+
async meter(
|
|
313
320
|
ctx: RunMutationCtx,
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
328
|
-
const
|
|
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:
|
|
336
|
-
model,
|
|
337
|
-
messages,
|
|
338
|
-
rerunOf:
|
|
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:
|
|
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
|
|
357
|
-
const notices = started.notices;
|
|
365
|
+
const { warnings, notices } = started;
|
|
358
366
|
await this.fireBudgetEvents(
|
|
359
|
-
{ userId, action: actionName, tags:
|
|
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
|
-
|
|
366
|
-
//
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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:
|
|
389
|
+
responseText: out.text,
|
|
384
390
|
...usage,
|
|
385
|
-
|
|
391
|
+
serverToolUses: out.serverToolUses,
|
|
392
|
+
costNanos: out.costNanos,
|
|
386
393
|
latencyMs: Date.now() - start,
|
|
387
394
|
}
|
|
388
395
|
);
|
|
389
|
-
return { text:
|
|
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 (
|
|
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);
|
package/src/component/lib.ts
CHANGED
|
@@ -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
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
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
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
+
});
|
package/src/component/schema.ts
CHANGED
|
@@ -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
|
});
|