@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.
@@ -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. An unexpected
529
- // rejection throws to roll back all previously consumed dimensions.
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
- throw new Error("Unknown request");
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
- // Clamp caller-supplied token counts: negatives would produce negative cost
623
- // and could refund a user below their cap.
624
- const promptTokens = Math.max(0, args.promptTokens ?? 0);
625
- const completionTokens = Math.max(0, args.completionTokens ?? 0);
626
- const cachedTokens = Math.min(promptTokens, Math.max(0, args.cachedTokens ?? 0));
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
- if (args.requestsPerMinute !== undefined &&
941
- (!Number.isSafeInteger(args.requestsPerMinute) || args.requestsPerMinute < 0)) {
942
- throw new Error("requestsPerMinute must be a nonnegative safe integer");
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
- dailySpendLimitNanos: v.optional(v.number()),
1143
- lifetimeSpendLimitNanos: v.optional(v.number()),
1144
- enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
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
- // The settings fields are "global"-prefixed; map the friendly arg names.
1149
- const patch = {
1150
- globalDailySpendLimitNanos: args.dailySpendLimitNanos,
1151
- globalLifetimeSpendLimitNanos: args.lifetimeSpendLimitNanos,
1152
- globalEnforcement: args.enforcement,
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
- if (args.inputNanosPerMTok < 0 ||
1220
- args.outputNanosPerMTok < 0 ||
1221
- (args.cachedNanosPerMTok ?? 0) < 0) {
1222
- throw new Error("Prices must be non-negative");
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
@@ -7,7 +7,7 @@
7
7
  "email": "support@convex.dev",
8
8
  "url": "https://github.com/get-convex/ai-budget/issues"
9
9
  },
10
- "version": "0.0.2-alpha.15",
10
+ "version": "0.0.2-alpha.17",
11
11
  "license": "Apache-2.0",
12
12
  "type": "module",
13
13
  "keywords": [
@@ -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
- const grand = rows.reduce((s, b) => s + b.totalSpendNanos, 0);
101
- document.getElementById("total").textContent = rows.length + " buckets · " + usd(grand) + " total";
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(); } },
@@ -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
- promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens),
163
- completionTokens: toTokenCount(usage?.outputTokens ?? usage?.completionTokens),
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
- const out = await run();
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
- const result = await doGenerate();
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
- let settled = false;
639
- const settle = (error?: string) => {
640
- if (settled) return;
641
- settled = true;
642
- return finish(requestId, {
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 only for the initial page navigation (a browser
929
- // GET can't set headers); the page strips it from the URL on load and the
930
- // JSON API is called with the bearer header. Compared in constant time.
931
- const provided = bearer || url.searchParams.get("token") || "";
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
- headers: { "content-type": "application/json" },
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 value isn't
1003
- // treated as a replacement pattern). This keeps a token/prefix containing
1004
- // quotes, backslashes, or `</script>` from breaking out of the JS string.
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
- () => JSON.stringify(`${prefix}/api`)
1008
- ).replace(/__TOKEN__/g, () => JSON.stringify(token));
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: { "content-type": "text/html; charset=utf-8" },
1157
+ headers: {
1158
+ "content-type": "text/html; charset=utf-8",
1159
+ "cache-control": "no-store",
1160
+ },
1011
1161
  });
1012
1162
  };
1013
1163