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