@convex-dev/ai-budget 0.0.2-alpha.15 → 0.0.2-alpha.17
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 +49 -4
- package/dist/client/dashboard.js +12 -2
- package/dist/client/index.d.ts +53 -0
- package/dist/client/index.js +157 -59
- package/dist/component/lib.d.ts +3 -3
- package/dist/component/lib.js +101 -28
- package/package.json +1 -1
- package/src/client/dashboard.ts +12 -2
- package/src/client/index.ts +204 -54
- package/src/component/lib.test.ts +95 -11
- package/src/component/lib.ts +104 -30
package/dist/component/lib.js
CHANGED
|
@@ -12,6 +12,30 @@ import { ShardedCounter } from "@convex-dev/sharded-counter";
|
|
|
12
12
|
// currency code alongside these amounts and convert here, at the one boundary.
|
|
13
13
|
const NANOS_PER_DOLLAR = 1e9;
|
|
14
14
|
const fmtUsd = (nanos) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
|
|
15
|
+
// Convex's `v.number()` accepts NaN and ±Infinity. Those are poison here: a NaN
|
|
16
|
+
// cap or count silently defeats every `used > cap` comparison (NaN > x is
|
|
17
|
+
// false), so an unvalidated NaN would make admission fail OPEN and admit
|
|
18
|
+
// unlimited spend; +Infinity in totals is just as corrupting. Validate every
|
|
19
|
+
// externally-supplied accounting amount at the mutation boundary.
|
|
20
|
+
function assertAmount(n, name, { signed = false } = {}) {
|
|
21
|
+
if (n === undefined)
|
|
22
|
+
return;
|
|
23
|
+
if (!Number.isFinite(n) || !Number.isSafeInteger(n)) {
|
|
24
|
+
throw new Error(`${name} must be a finite safe integer (got ${n})`);
|
|
25
|
+
}
|
|
26
|
+
if (!signed && n < 0)
|
|
27
|
+
throw new Error(`${name} must be nonnegative (got ${n})`);
|
|
28
|
+
}
|
|
29
|
+
function assertFraction(n, name) {
|
|
30
|
+
if (n === undefined)
|
|
31
|
+
return;
|
|
32
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
|
33
|
+
throw new Error(`${name} must be a number in [0, 1] (got ${n})`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Coerce a caller/provider-supplied count to a finite nonnegative integer,
|
|
37
|
+
// mapping NaN/Infinity/garbage to 0 rather than poisoning downstream totals.
|
|
38
|
+
const safeCount = (n) => Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
|
|
15
39
|
// Built-in attribution dimensions. `user` and `action` are always populated
|
|
16
40
|
// from a request's userId/actionName; apps can add any other dimensions
|
|
17
41
|
// (team, project, customer, env, …) as tags. These two names are reserved —
|
|
@@ -525,8 +549,14 @@ export const startRequest = mutation({
|
|
|
525
549
|
notices.push(...globalEval.notices);
|
|
526
550
|
}
|
|
527
551
|
// Consume only after every admission check succeeds, in the same
|
|
528
|
-
// transaction as the reservations and request insert.
|
|
529
|
-
//
|
|
552
|
+
// transaction as the reservations and request insert. `throws: true` is
|
|
553
|
+
// deliberate, not a rough edge: we already `.check`ed every bucket above in
|
|
554
|
+
// this same serializable transaction, so a `.limit` here cannot fail on a
|
|
555
|
+
// bucket that passed check — and if it somehow did (or a later bucket did),
|
|
556
|
+
// throwing rolls back the WHOLE transaction, including the rate we already
|
|
557
|
+
// consumed on earlier buckets. Converting this to a graceful `{allowed:false}`
|
|
558
|
+
// return would COMMIT the partial consumption and leak rate capacity, so keep
|
|
559
|
+
// the throw.
|
|
530
560
|
for (const b of buckets) {
|
|
531
561
|
if (b.requestsPerMinute !== undefined) {
|
|
532
562
|
await requestRateLimiter.limit(ctx, "requests", {
|
|
@@ -612,23 +642,28 @@ export const finishRequest = mutation({
|
|
|
612
642
|
returns: v.object({ costNanos: v.number() }),
|
|
613
643
|
handler: async (ctx, args) => {
|
|
614
644
|
const request = await ctx.db.get(args.requestId);
|
|
645
|
+
// The request may be gone — retention purged it, or the owning bucket was
|
|
646
|
+
// deleted. A late/duplicate webhook must be an idempotent no-op, not a 500
|
|
647
|
+
// (the caller can't do anything useful with the error, and it triggers retries).
|
|
615
648
|
if (!request)
|
|
616
|
-
|
|
649
|
+
return { costNanos: 0 };
|
|
617
650
|
// Expiry releases capacity, but is not evidence that the provider charged
|
|
618
651
|
// nothing. Accept one final result even after expiry; duplicates stay no-ops.
|
|
619
652
|
if (request.status !== "pending" && !request.reservationExpired) {
|
|
620
653
|
return { costNanos: request.costNanos ?? 0 };
|
|
621
654
|
}
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
const
|
|
626
|
-
const
|
|
655
|
+
// Coerce caller/provider-supplied token counts to finite nonnegative
|
|
656
|
+
// integers: negatives would refund below a cap, and NaN/Infinity (which
|
|
657
|
+
// v.number() allows) would poison every downstream total and cap check.
|
|
658
|
+
const promptTokens = safeCount(args.promptTokens);
|
|
659
|
+
const completionTokens = safeCount(args.completionTokens);
|
|
660
|
+
const cachedTokens = Math.min(promptTokens, safeCount(args.cachedTokens));
|
|
627
661
|
// Prefer an authoritative gateway cost when supplied (it already includes
|
|
628
662
|
// tool fees); otherwise price from tokens — discounting the cached
|
|
629
|
-
// (prompt-cache-read) slice — plus any server-tool per-call fees.
|
|
663
|
+
// (prompt-cache-read) slice — plus any server-tool per-call fees. Require it
|
|
664
|
+
// finite: +Infinity passes a bare `>= 0` and would corrupt the totals.
|
|
630
665
|
let costNanos;
|
|
631
|
-
if (args.costNanos !== undefined && args.costNanos >= 0) {
|
|
666
|
+
if (args.costNanos !== undefined && Number.isFinite(args.costNanos) && args.costNanos >= 0) {
|
|
632
667
|
costNanos = Math.round(args.costNanos);
|
|
633
668
|
}
|
|
634
669
|
else {
|
|
@@ -937,10 +972,15 @@ export const setBucketLimits = mutation({
|
|
|
937
972
|
},
|
|
938
973
|
returns: v.null(),
|
|
939
974
|
handler: async (ctx, args) => {
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
975
|
+
assertAmount(args.requestsPerMinute, "requestsPerMinute");
|
|
976
|
+
assertAmount(args.maxConcurrent, "maxConcurrent");
|
|
977
|
+
assertAmount(args.dailySpendLimitNanos, "dailySpendLimitNanos");
|
|
978
|
+
assertAmount(args.monthlySpendLimitNanos, "monthlySpendLimitNanos");
|
|
979
|
+
assertAmount(args.lifetimeSpendLimitNanos, "lifetimeSpendLimitNanos");
|
|
980
|
+
assertAmount(args.dailyTokenLimit, "dailyTokenLimit");
|
|
981
|
+
assertAmount(args.monthlyTokenLimit, "monthlyTokenLimit");
|
|
982
|
+
assertAmount(args.lifetimeTokenLimit, "lifetimeTokenLimit");
|
|
983
|
+
assertFraction(args.warnAtPct, "warnAtPct");
|
|
944
984
|
const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
|
|
945
985
|
const { dimension: _d, value: _v, ...limits } = args;
|
|
946
986
|
await ctx.db.patch(bucket._id, limits);
|
|
@@ -960,6 +1000,9 @@ export const bumpBucket = mutation({
|
|
|
960
1000
|
args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
|
|
961
1001
|
returns: v.null(),
|
|
962
1002
|
handler: async (ctx, { dimension, value, dailyNanos, monthlyNanos, lifetimeNanos }) => {
|
|
1003
|
+
assertAmount(dailyNanos, "dailyNanos");
|
|
1004
|
+
assertAmount(monthlyNanos, "monthlyNanos");
|
|
1005
|
+
assertAmount(lifetimeNanos, "lifetimeNanos");
|
|
963
1006
|
const bucket = await getOrCreateBucket(ctx, dimension, value);
|
|
964
1007
|
const today = dayStamp();
|
|
965
1008
|
const month = monthStamp();
|
|
@@ -989,6 +1032,8 @@ export const adjustBucket = mutation({
|
|
|
989
1032
|
},
|
|
990
1033
|
returns: v.null(),
|
|
991
1034
|
handler: async (ctx, { dimension, value, deltaNanos, tokens, reason }) => {
|
|
1035
|
+
assertAmount(deltaNanos, "deltaNanos", { signed: true });
|
|
1036
|
+
assertAmount(tokens, "tokens", { signed: true });
|
|
992
1037
|
const b = await getOrCreateBucket(ctx, dimension, value);
|
|
993
1038
|
const today = dayStamp();
|
|
994
1039
|
const month = monthStamp();
|
|
@@ -1004,6 +1049,12 @@ export const adjustBucket = mutation({
|
|
|
1004
1049
|
tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
|
|
1005
1050
|
spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
|
|
1006
1051
|
tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
|
|
1052
|
+
// Advancing the window here must also clear the OLD window's reserved
|
|
1053
|
+
// holds, or an in-flight request from the previous day/month would be
|
|
1054
|
+
// treated as reserving against the new window and its later release would
|
|
1055
|
+
// no longer match — stranding those reserved nanos/tokens.
|
|
1056
|
+
...(dSame ? {} : { reservedTodayNanos: 0, reservedTodayTokens: 0 }),
|
|
1057
|
+
...(mSame ? {} : { reservedMonthNanos: 0, reservedMonthTokens: 0 }),
|
|
1007
1058
|
});
|
|
1008
1059
|
await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
|
|
1009
1060
|
await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
|
|
@@ -1064,6 +1115,17 @@ export const deleteBucket = mutation({
|
|
|
1064
1115
|
.withIndex("userId", (q) => q.eq("userId", value))
|
|
1065
1116
|
.take(DELETE_BATCH);
|
|
1066
1117
|
for (const r of rows) {
|
|
1118
|
+
// Before dropping the row, free or settle any hold it placed on OTHER
|
|
1119
|
+
// (shared) buckets — an action/customer bucket this user's request
|
|
1120
|
+
// reserved against. Otherwise deleting the only row that could release
|
|
1121
|
+
// that hold strands the shared bucket's reservation + pendingCount
|
|
1122
|
+
// forever, and a late finish would throw. A finished-but-unfolded row
|
|
1123
|
+
// is folded (the charge lands on the shared buckets); a still-pending
|
|
1124
|
+
// one just has its reservation released.
|
|
1125
|
+
if (r.settled === false)
|
|
1126
|
+
await foldOne(ctx, r);
|
|
1127
|
+
else if (r.status === "pending")
|
|
1128
|
+
await releaseReservation(ctx, r);
|
|
1067
1129
|
await deleteRequestTags(ctx, r._id);
|
|
1068
1130
|
await ctx.db.delete(r._id);
|
|
1069
1131
|
}
|
|
@@ -1139,18 +1201,29 @@ export const getGlobalStatus = query({
|
|
|
1139
1201
|
});
|
|
1140
1202
|
export const setGlobalLimits = mutation({
|
|
1141
1203
|
args: {
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1204
|
+
// Absent = leave unchanged; explicit null = clear that limit. (A bare
|
|
1205
|
+
// v.optional(number) that always rebuilt the full patch would let a
|
|
1206
|
+
// one-field edit — exactly what the dashboard sends — silently wipe the
|
|
1207
|
+
// other global controls by patching them to undefined.)
|
|
1208
|
+
dailySpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1209
|
+
lifetimeSpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1210
|
+
enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"), v.null())),
|
|
1145
1211
|
},
|
|
1146
1212
|
returns: v.null(),
|
|
1147
1213
|
handler: async (ctx, args) => {
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1214
|
+
if (typeof args.dailySpendLimitNanos === "number")
|
|
1215
|
+
assertAmount(args.dailySpendLimitNanos, "dailySpendLimitNanos");
|
|
1216
|
+
if (typeof args.lifetimeSpendLimitNanos === "number")
|
|
1217
|
+
assertAmount(args.lifetimeSpendLimitNanos, "lifetimeSpendLimitNanos");
|
|
1218
|
+
// The settings fields are "global"-prefixed; map the friendly arg names,
|
|
1219
|
+
// touching ONLY the keys the caller actually passed. null -> clear.
|
|
1220
|
+
const patch = {};
|
|
1221
|
+
if ("dailySpendLimitNanos" in args)
|
|
1222
|
+
patch.globalDailySpendLimitNanos = args.dailySpendLimitNanos ?? undefined;
|
|
1223
|
+
if ("lifetimeSpendLimitNanos" in args)
|
|
1224
|
+
patch.globalLifetimeSpendLimitNanos = args.lifetimeSpendLimitNanos ?? undefined;
|
|
1225
|
+
if ("enforcement" in args)
|
|
1226
|
+
patch.globalEnforcement = args.enforcement ?? undefined;
|
|
1154
1227
|
const existing = await getSettings(ctx);
|
|
1155
1228
|
if (existing) {
|
|
1156
1229
|
await ctx.db.patch(existing._id, patch);
|
|
@@ -1216,11 +1289,11 @@ export const setPrice = mutation({
|
|
|
1216
1289
|
handler: async (ctx, args) => {
|
|
1217
1290
|
// Negative prices would make costOf return a negative cost, which folds
|
|
1218
1291
|
// into totals as a spend *refund* — pushing a user back under their cap.
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1292
|
+
// NaN/Infinity (allowed by v.number()) are just as corrupting — a NaN rate
|
|
1293
|
+
// poisons every settled cost for the model — so require finite integers.
|
|
1294
|
+
assertAmount(args.inputNanosPerMTok, "inputNanosPerMTok");
|
|
1295
|
+
assertAmount(args.outputNanosPerMTok, "outputNanosPerMTok");
|
|
1296
|
+
assertAmount(args.cachedNanosPerMTok, "cachedNanosPerMTok");
|
|
1224
1297
|
const existing = await ctx.db
|
|
1225
1298
|
.query("prices")
|
|
1226
1299
|
.withIndex("model", (q) => q.eq("model", args.model))
|
package/package.json
CHANGED
package/src/client/dashboard.ts
CHANGED
|
@@ -97,8 +97,18 @@ async function renderBuckets() {
|
|
|
97
97
|
const dims = ["user", "action"];
|
|
98
98
|
const state = { dimension: window.__dim ?? "" };
|
|
99
99
|
const rows = await get("/buckets", state.dimension ? { dimension: state.dimension } : {});
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
// Within ONE dimension each request bills exactly one bucket, so summing is a
|
|
101
|
+
// true total. Across dimensions a request bills several buckets (user + action
|
|
102
|
+
// + tags), so summing multi-counts — use the deployment-wide spend instead.
|
|
103
|
+
let totalText;
|
|
104
|
+
if (state.dimension) {
|
|
105
|
+
const grand = rows.reduce((s, b) => s + b.totalSpendNanos, 0);
|
|
106
|
+
totalText = rows.length + " buckets · " + usd(grand) + " total (" + state.dimension + ")";
|
|
107
|
+
} else {
|
|
108
|
+
const g = await get("/global", {});
|
|
109
|
+
totalText = rows.length + " buckets · " + usd(g.spentTotalNanos ?? 0) + " spent (deployment)";
|
|
110
|
+
}
|
|
111
|
+
document.getElementById("total").textContent = totalText;
|
|
102
112
|
const dimSet = [...new Set(rows.map((b) => b.dimension).concat(dims))];
|
|
103
113
|
const sel = el("select", { value: state.dimension, style: "width:140px",
|
|
104
114
|
onchange: (e) => { window.__dim = e.target.value; render(); } },
|
package/src/client/index.ts
CHANGED
|
@@ -71,6 +71,8 @@ export type BudgetEventInfo = {
|
|
|
71
71
|
export type SoftLimitInfo = BudgetEventInfo & { warnings: string[] };
|
|
72
72
|
export type AIBudgetOptions = {
|
|
73
73
|
defaultModel?: string;
|
|
74
|
+
/** Default model for `decisions()` (the Decisions/"Jev" endpoint). */
|
|
75
|
+
defaultEvalModel?: string;
|
|
74
76
|
/**
|
|
75
77
|
* A *soft* limit was exceeded (request still allowed). Lets you surface budget
|
|
76
78
|
* warnings even on the languageModel/Agent path where they can't be returned.
|
|
@@ -143,6 +145,17 @@ export type ChatResult = {
|
|
|
143
145
|
notices: string[];
|
|
144
146
|
};
|
|
145
147
|
|
|
148
|
+
/** The tracked result of a `decisions()` call: budgeting metadata plus the
|
|
149
|
+
* structured answers from the Decisions ("Jev") endpoint. */
|
|
150
|
+
export type DecisionResult = Omit<ChatResult, "text"> & {
|
|
151
|
+
/** Structured answers keyed by your question names (shape depends on each
|
|
152
|
+
* question type: `choice`, `score`, or `boolean`). */
|
|
153
|
+
answers: Record<string, any>;
|
|
154
|
+
/** The raw gateway response, including provider-specific fields (e.g.
|
|
155
|
+
* `confidence`) under `response.body`. */
|
|
156
|
+
response?: any;
|
|
157
|
+
};
|
|
158
|
+
|
|
146
159
|
// ---------- helpers ----------
|
|
147
160
|
|
|
148
161
|
// Token counts across AI SDK versions come as plain numbers or, in v7, as a
|
|
@@ -159,8 +172,14 @@ function extractUsage(usage: any): {
|
|
|
159
172
|
cachedTokens: number;
|
|
160
173
|
} {
|
|
161
174
|
return {
|
|
162
|
-
|
|
163
|
-
|
|
175
|
+
// Cover AI SDK camelCase (v5/v7) AND raw OpenAI-compatible snake_case, so a
|
|
176
|
+
// `meter` caller passing a raw provider `usage` object still gets counts.
|
|
177
|
+
promptTokens: toTokenCount(
|
|
178
|
+
usage?.inputTokens ?? usage?.promptTokens ?? usage?.prompt_tokens
|
|
179
|
+
),
|
|
180
|
+
completionTokens: toTokenCount(
|
|
181
|
+
usage?.outputTokens ?? usage?.completionTokens ?? usage?.completion_tokens
|
|
182
|
+
),
|
|
164
183
|
// cached prompt tokens. The Convex gateway reports these at
|
|
165
184
|
// `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
|
|
166
185
|
// (`cachedInputTokens`) and raw OpenAI-compatible shapes.
|
|
@@ -262,6 +281,7 @@ export type BumpArgs = {
|
|
|
262
281
|
|
|
263
282
|
export class AIBudget {
|
|
264
283
|
public defaultModel: string;
|
|
284
|
+
public defaultEvalModel: string;
|
|
265
285
|
private onSoftLimit?: AIBudgetOptions["onSoftLimit"];
|
|
266
286
|
private onThreshold?: AIBudgetOptions["onThreshold"];
|
|
267
287
|
private onLimitReached?: AIBudgetOptions["onLimitReached"];
|
|
@@ -270,6 +290,7 @@ export class AIBudget {
|
|
|
270
290
|
options?: AIBudgetOptions
|
|
271
291
|
) {
|
|
272
292
|
this.defaultModel = options?.defaultModel ?? "openai/gpt-4o-mini";
|
|
293
|
+
this.defaultEvalModel = options?.defaultEvalModel ?? "typesafe/jev-1.13";
|
|
273
294
|
this.onSoftLimit = options?.onSoftLimit;
|
|
274
295
|
this.onThreshold = options?.onThreshold;
|
|
275
296
|
this.onLimitReached = options?.onLimitReached;
|
|
@@ -448,35 +469,42 @@ export class AIBudget {
|
|
|
448
469
|
}
|
|
449
470
|
const { requestId, warnings, notices } = started;
|
|
450
471
|
const start = Date.now();
|
|
472
|
+
// Run the provider call. ONLY a failure of the call itself settles as an
|
|
473
|
+
// error (no charge expected).
|
|
474
|
+
let out: Awaited<ReturnType<typeof run>>;
|
|
451
475
|
try {
|
|
452
|
-
|
|
453
|
-
const { costNanos } = await this.settle(ctx, {
|
|
454
|
-
requestId,
|
|
455
|
-
responseText: out.text,
|
|
456
|
-
usage: out.usage,
|
|
457
|
-
promptTokens: out.promptTokens,
|
|
458
|
-
completionTokens: out.completionTokens,
|
|
459
|
-
cachedTokens: out.cachedTokens,
|
|
460
|
-
serverToolUses: out.serverToolUses,
|
|
461
|
-
costNanos: out.costNanos,
|
|
462
|
-
latencyMs: Date.now() - start,
|
|
463
|
-
});
|
|
464
|
-
// Re-derive the recorded usage for the return value.
|
|
465
|
-
const usage =
|
|
466
|
-
out.promptTokens !== undefined ||
|
|
467
|
-
out.completionTokens !== undefined ||
|
|
468
|
-
out.cachedTokens !== undefined
|
|
469
|
-
? {
|
|
470
|
-
promptTokens: out.promptTokens ?? 0,
|
|
471
|
-
completionTokens: out.completionTokens ?? 0,
|
|
472
|
-
cachedTokens: out.cachedTokens ?? 0,
|
|
473
|
-
}
|
|
474
|
-
: extractUsage(out.usage);
|
|
475
|
-
return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
|
|
476
|
+
out = await run();
|
|
476
477
|
} catch (e) {
|
|
477
478
|
await this.settle(ctx, { requestId, error: String(e), latencyMs: Date.now() - start });
|
|
478
479
|
throw e;
|
|
479
480
|
}
|
|
481
|
+
// The call SUCCEEDED (the provider may have charged). Settle the real usage.
|
|
482
|
+
// If settlement itself fails here, do NOT fall into an error-settle that
|
|
483
|
+
// records zero — that would erase a real charge. Rethrow and leave the
|
|
484
|
+
// reservation for the reconciler; billing stays "unknown", never a false zero.
|
|
485
|
+
const { costNanos } = await this.settle(ctx, {
|
|
486
|
+
requestId,
|
|
487
|
+
responseText: out.text,
|
|
488
|
+
usage: out.usage,
|
|
489
|
+
promptTokens: out.promptTokens,
|
|
490
|
+
completionTokens: out.completionTokens,
|
|
491
|
+
cachedTokens: out.cachedTokens,
|
|
492
|
+
serverToolUses: out.serverToolUses,
|
|
493
|
+
costNanos: out.costNanos,
|
|
494
|
+
latencyMs: Date.now() - start,
|
|
495
|
+
});
|
|
496
|
+
// Re-derive the recorded usage for the return value.
|
|
497
|
+
const usage =
|
|
498
|
+
out.promptTokens !== undefined ||
|
|
499
|
+
out.completionTokens !== undefined ||
|
|
500
|
+
out.cachedTokens !== undefined
|
|
501
|
+
? {
|
|
502
|
+
promptTokens: out.promptTokens ?? 0,
|
|
503
|
+
completionTokens: out.completionTokens ?? 0,
|
|
504
|
+
cachedTokens: out.cachedTokens ?? 0,
|
|
505
|
+
}
|
|
506
|
+
: extractUsage(out.usage);
|
|
507
|
+
return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
|
|
480
508
|
}
|
|
481
509
|
|
|
482
510
|
/**
|
|
@@ -534,6 +562,103 @@ export class AIBudget {
|
|
|
534
562
|
);
|
|
535
563
|
}
|
|
536
564
|
|
|
565
|
+
/**
|
|
566
|
+
* Budget a structured decision through the AI Gateway's Decisions ("Jev")
|
|
567
|
+
* endpoint — sugar over `meter`. Evaluates typed `questions` (choice / score /
|
|
568
|
+
* boolean) about the `state` you provide, with the same reserve→settle
|
|
569
|
+
* limits, audit log, cost tracking, and per-tag attribution as `chat`. Call
|
|
570
|
+
* from an action. `userId` defaults to the authenticated caller.
|
|
571
|
+
*
|
|
572
|
+
* Requires `@convex-dev/ai-sdk-provider` >= 0.2.1 and an `ai` version that
|
|
573
|
+
* exposes `experimental_evaluate` (AI SDK 7's evaluation interface); both are
|
|
574
|
+
* imported lazily, so consumers who never call `decisions()` are unaffected.
|
|
575
|
+
*
|
|
576
|
+
* const { answers } = await ai.decisions(ctx, {
|
|
577
|
+
* state: { ticket: "Customer cannot sign in" },
|
|
578
|
+
* questions: {
|
|
579
|
+
* priority: { type: "choice", instructions: "...", criteria: { urgent: "...", normal: "..." } },
|
|
580
|
+
* needsReview: { type: "boolean", instructions: "..." },
|
|
581
|
+
* },
|
|
582
|
+
* });
|
|
583
|
+
* answers.priority.choice; // "urgent" | "normal"
|
|
584
|
+
*/
|
|
585
|
+
async decisions(
|
|
586
|
+
ctx: RunMutationCtx,
|
|
587
|
+
args: {
|
|
588
|
+
/** The evaluation model. Defaults to `defaultEvalModel` ("typesafe/jev-1.13"). */
|
|
589
|
+
model?: string;
|
|
590
|
+
/** Context the questions are evaluated against (a string or an object). */
|
|
591
|
+
state: unknown;
|
|
592
|
+
/** Typed questions (choice / score / boolean) keyed by name. */
|
|
593
|
+
questions: Record<string, unknown>;
|
|
594
|
+
/** Whom to bill. Defaults to the authenticated user (ctx.auth). */
|
|
595
|
+
userId?: string;
|
|
596
|
+
/** Attribute spend to this action name. Defaults to the calling action. */
|
|
597
|
+
action?: string;
|
|
598
|
+
/** Extra attribution dimensions to bill/limit (team, customer, env, …). */
|
|
599
|
+
tags?: Tag[];
|
|
600
|
+
/** Reserve this exact amount (nanodollars) up front — the decision cost
|
|
601
|
+
* isn't known before the call, so a hard cap is only exact with this. */
|
|
602
|
+
estimatedCostNanos?: number;
|
|
603
|
+
rerunOf?: string;
|
|
604
|
+
/** Cancel the underlying request. */
|
|
605
|
+
abortSignal?: AbortSignal;
|
|
606
|
+
}
|
|
607
|
+
): Promise<DecisionResult> {
|
|
608
|
+
const model = args.model ?? this.defaultEvalModel;
|
|
609
|
+
// `evaluate` is an experimental, version-gated export; import it lazily and
|
|
610
|
+
// untyped so consumers on an older `ai` (who never call this) aren't broken.
|
|
611
|
+
const evaluate = ((await import("ai")) as any).experimental_evaluate;
|
|
612
|
+
if (typeof evaluate !== "function") {
|
|
613
|
+
throw new Error(
|
|
614
|
+
"ai-budget: decisions() needs `experimental_evaluate` from the `ai` " +
|
|
615
|
+
"package (AI SDK 7's evaluation interface). Upgrade `ai` to a " +
|
|
616
|
+
"version that exports it."
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
// Likewise, `evaluationModel` exists on @convex-dev/ai-sdk-provider >= 0.2.1.
|
|
620
|
+
const evaluationModel = (convexGateway as any).evaluationModel;
|
|
621
|
+
if (typeof evaluationModel !== "function") {
|
|
622
|
+
throw new Error(
|
|
623
|
+
"ai-budget: decisions() needs `convexGateway.evaluationModel` from " +
|
|
624
|
+
"@convex-dev/ai-sdk-provider >= 0.2.1. Upgrade the provider."
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
let decision: any;
|
|
628
|
+
const result = await this.meter(
|
|
629
|
+
ctx,
|
|
630
|
+
{
|
|
631
|
+
model,
|
|
632
|
+
// Store the structured request for audit/replay.
|
|
633
|
+
messages: [
|
|
634
|
+
{
|
|
635
|
+
role: "user",
|
|
636
|
+
content: JSON.stringify({ state: args.state, questions: args.questions }),
|
|
637
|
+
},
|
|
638
|
+
],
|
|
639
|
+
userId: args.userId,
|
|
640
|
+
action: args.action,
|
|
641
|
+
tags: args.tags,
|
|
642
|
+
estimatedCostNanos: args.estimatedCostNanos,
|
|
643
|
+
rerunOf: args.rerunOf,
|
|
644
|
+
},
|
|
645
|
+
async () => {
|
|
646
|
+
decision = await evaluate({
|
|
647
|
+
model: evaluationModel(model),
|
|
648
|
+
state: args.state,
|
|
649
|
+
questions: args.questions,
|
|
650
|
+
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
|
|
651
|
+
});
|
|
652
|
+
return {
|
|
653
|
+
usage: decision?.usage,
|
|
654
|
+
costNanos: extractGatewayCostNanos(decision),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
);
|
|
658
|
+
const { text: _text, ...tracking } = result;
|
|
659
|
+
return { ...tracking, answers: decision?.answers ?? {}, response: decision?.response };
|
|
660
|
+
}
|
|
661
|
+
|
|
537
662
|
/**
|
|
538
663
|
* An AI SDK LanguageModel that enforces limits and records usage/cost for
|
|
539
664
|
* `userId` on every call. Drop it into `generateText`, `streamText`, or the
|
|
@@ -605,15 +730,10 @@ export class AIBudget {
|
|
|
605
730
|
wrapGenerate: async ({ doGenerate, params }: any) => {
|
|
606
731
|
const requestId = await begin(params);
|
|
607
732
|
const start = Date.now();
|
|
733
|
+
// Only a failure of the generation itself settles as an error.
|
|
734
|
+
let result: any;
|
|
608
735
|
try {
|
|
609
|
-
|
|
610
|
-
await finish(requestId, {
|
|
611
|
-
responseText: extractText(result),
|
|
612
|
-
...extractUsage(result.usage),
|
|
613
|
-
costNanos: extractGatewayCostNanos(result),
|
|
614
|
-
latencyMs: Date.now() - start,
|
|
615
|
-
});
|
|
616
|
-
return result;
|
|
736
|
+
result = await doGenerate();
|
|
617
737
|
} catch (e) {
|
|
618
738
|
await finish(requestId, {
|
|
619
739
|
error: String(e),
|
|
@@ -621,6 +741,15 @@ export class AIBudget {
|
|
|
621
741
|
});
|
|
622
742
|
throw e;
|
|
623
743
|
}
|
|
744
|
+
// Generation succeeded (provider may have charged). Settle the real
|
|
745
|
+
// usage; a failure here rethrows rather than recording a false zero.
|
|
746
|
+
await finish(requestId, {
|
|
747
|
+
responseText: extractText(result),
|
|
748
|
+
...extractUsage(result.usage),
|
|
749
|
+
costNanos: extractGatewayCostNanos(result),
|
|
750
|
+
latencyMs: Date.now() - start,
|
|
751
|
+
});
|
|
752
|
+
return result;
|
|
624
753
|
},
|
|
625
754
|
wrapStream: async ({ doStream, params }: any) => {
|
|
626
755
|
const requestId = await begin(params);
|
|
@@ -635,21 +764,24 @@ export class AIBudget {
|
|
|
635
764
|
// chunk, or a cancel — is safe: the first wins, the rest no-op.
|
|
636
765
|
// Without this an errored or abandoned stream would never settle and
|
|
637
766
|
// its real usage would be lost (recorded as free by the reconciler).
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
767
|
+
// Settle at most once, memoizing the PROMISE so the finish chunk, an
|
|
768
|
+
// error chunk, and flush all await the same settlement instead of
|
|
769
|
+
// racing, dropping it (the old `void settle()`), or flipping a
|
|
770
|
+
// "settled" flag before the mutation actually committed. A stream
|
|
771
|
+
// that is cancelled/never fully consumed won't deliver finish or
|
|
772
|
+
// flush; the reconciler's reservation expiry is the backstop there.
|
|
773
|
+
let settlement: Promise<{ costNanos: number }> | undefined;
|
|
774
|
+
const settle = (error?: string) =>
|
|
775
|
+
(settlement ??= finish(requestId, {
|
|
643
776
|
responseText: text,
|
|
644
777
|
error,
|
|
645
778
|
...extractUsage(usage),
|
|
646
779
|
costNanos: extractGatewayCostNanos({ providerMetadata }),
|
|
647
780
|
latencyMs: Date.now() - start,
|
|
648
|
-
});
|
|
649
|
-
};
|
|
781
|
+
}));
|
|
650
782
|
const tapped = result.stream.pipeThrough(
|
|
651
783
|
new TransformStream({
|
|
652
|
-
transform(chunk: any, controller) {
|
|
784
|
+
async transform(chunk: any, controller) {
|
|
653
785
|
if (chunk?.type === "text-delta") {
|
|
654
786
|
text += chunk.delta ?? chunk.textDelta ?? "";
|
|
655
787
|
}
|
|
@@ -657,8 +789,10 @@ export class AIBudget {
|
|
|
657
789
|
usage = chunk.usage;
|
|
658
790
|
providerMetadata = chunk.providerMetadata ?? providerMetadata;
|
|
659
791
|
}
|
|
660
|
-
if (chunk?.type === "error") void settle(String(chunk.error));
|
|
661
792
|
controller.enqueue(chunk);
|
|
793
|
+
// Settle after forwarding the terminal error chunk, and AWAIT
|
|
794
|
+
// it so a failed settle surfaces instead of being dropped.
|
|
795
|
+
if (chunk?.type === "error") await settle(String(chunk.error));
|
|
662
796
|
},
|
|
663
797
|
async flush() {
|
|
664
798
|
await settle();
|
|
@@ -925,17 +1059,29 @@ export class AIBudget {
|
|
|
925
1059
|
if (!token) return { ok: false, token: "" };
|
|
926
1060
|
const url = new URL(request.url);
|
|
927
1061
|
const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
|
|
928
|
-
// `?token=` is accepted
|
|
929
|
-
//
|
|
930
|
-
// JSON API
|
|
931
|
-
|
|
1062
|
+
// `?token=` is accepted ONLY for the initial page navigation (a browser GET
|
|
1063
|
+
// can't set headers); the page strips it from the URL on load and calls the
|
|
1064
|
+
// JSON API with the bearer header. Restrict it to that GET page route so a
|
|
1065
|
+
// token can't be smuggled in a query string on API/mutation calls (where it
|
|
1066
|
+
// would also land in access logs). Compared in constant time.
|
|
1067
|
+
const sub = url.pathname.slice(prefix.length) || "/";
|
|
1068
|
+
const isPageNav = request.method === "GET" && !sub.startsWith("/api");
|
|
1069
|
+
const provided = bearer || (isPageNav ? url.searchParams.get("token") ?? "" : "");
|
|
932
1070
|
return { ok: timingSafeEqual(provided, token), token };
|
|
933
1071
|
};
|
|
934
1072
|
const json = (data: unknown, status = 200) =>
|
|
935
1073
|
new Response(JSON.stringify(data ?? null), {
|
|
936
1074
|
status,
|
|
937
|
-
|
|
1075
|
+
// Never let a shared cache/proxy retain budget data or the token-bearing
|
|
1076
|
+
// page — these responses are per-viewer and sensitive.
|
|
1077
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
938
1078
|
});
|
|
1079
|
+
// JSON.stringify does NOT escape `<`, so a value containing `</script>`
|
|
1080
|
+
// would close the inline <script> and break out. Escape `<` (and the JS line
|
|
1081
|
+
// separators) before embedding in HTML.
|
|
1082
|
+
const jsonForScript = (x: unknown) =>
|
|
1083
|
+
JSON.stringify(x).replace(/[<\u2028\u2029]/g, (ch) =>
|
|
1084
|
+
"\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
939
1085
|
|
|
940
1086
|
const handle = async (ctx: any, request: Request): Promise<Response> => {
|
|
941
1087
|
const url = new URL(request.url);
|
|
@@ -999,15 +1145,19 @@ export class AIBudget {
|
|
|
999
1145
|
}
|
|
1000
1146
|
}
|
|
1001
1147
|
|
|
1002
|
-
// Inject as JSON literals (function replacers so `$` in the
|
|
1003
|
-
// treated as a replacement pattern
|
|
1004
|
-
//
|
|
1148
|
+
// Inject as script-safe JSON literals (function replacers so `$` in the
|
|
1149
|
+
// value isn't treated as a replacement pattern; `jsonForScript` escapes
|
|
1150
|
+
// `<` so a token containing `</script>` can't break out of the inline JS).
|
|
1005
1151
|
const html = DASHBOARD_HTML.replace(
|
|
1006
1152
|
/__API_BASE__/g,
|
|
1007
|
-
() =>
|
|
1008
|
-
).replace(/__TOKEN__/g, () =>
|
|
1153
|
+
() => jsonForScript(`${prefix}/api`)
|
|
1154
|
+
).replace(/__TOKEN__/g, () => jsonForScript(token));
|
|
1155
|
+
// The page embeds the bearer token — never let a shared cache retain it.
|
|
1009
1156
|
return new Response(html, {
|
|
1010
|
-
headers: {
|
|
1157
|
+
headers: {
|
|
1158
|
+
"content-type": "text/html; charset=utf-8",
|
|
1159
|
+
"cache-control": "no-store",
|
|
1160
|
+
},
|
|
1011
1161
|
});
|
|
1012
1162
|
};
|
|
1013
1163
|
|