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