@convex-dev/ai-budget 0.0.2-alpha.16 → 0.0.2-alpha.18

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.
@@ -172,8 +172,21 @@ function extractUsage(usage: any): {
172
172
  cachedTokens: number;
173
173
  } {
174
174
  return {
175
- promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens),
176
- completionTokens: toTokenCount(usage?.outputTokens ?? usage?.completionTokens),
175
+ // Cover AI SDK camelCase (v5/v7), raw OpenAI-compatible snake_case, AND raw
176
+ // Anthropic (`input_tokens`/`output_tokens`), so a `meter` caller passing any
177
+ // provider's raw `usage` object still gets counts.
178
+ promptTokens: toTokenCount(
179
+ usage?.inputTokens ??
180
+ usage?.promptTokens ??
181
+ usage?.prompt_tokens ??
182
+ usage?.input_tokens
183
+ ),
184
+ completionTokens: toTokenCount(
185
+ usage?.outputTokens ??
186
+ usage?.completionTokens ??
187
+ usage?.completion_tokens ??
188
+ usage?.output_tokens
189
+ ),
177
190
  // cached prompt tokens. The Convex gateway reports these at
178
191
  // `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
179
192
  // (`cachedInputTokens`) and raw OpenAI-compatible shapes.
@@ -182,7 +195,8 @@ function extractUsage(usage: any): {
182
195
  usage?.cachedInputTokens ??
183
196
  usage?.promptTokensDetails?.cachedTokens ??
184
197
  usage?.prompt_tokens_details?.cached_tokens ??
185
- usage?.cached_tokens
198
+ usage?.cached_tokens ??
199
+ usage?.cache_read_input_tokens
186
200
  ),
187
201
  };
188
202
  }
@@ -208,6 +222,12 @@ function extractGatewayCostNanos(result: any): number | undefined {
208
222
 
209
223
  const NANOS_PER_DOLLAR = 1e9;
210
224
 
225
+ // Cap stored prompt/response content so one row can't approach Convex's 1 MiB
226
+ // document limit (which would fail the call) or bloat the reconciler's scans.
227
+ const MAX_STORED_CONTENT = 32 * 1024;
228
+ const capContent = (s: string) =>
229
+ s.length > MAX_STORED_CONTENT ? s.slice(0, MAX_STORED_CONTENT) + "…[truncated]" : s;
230
+
211
231
  // Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
212
232
  function simplifyPrompt(prompt: any): Message[] {
213
233
  if (!Array.isArray(prompt)) return [];
@@ -217,14 +237,20 @@ function simplifyPrompt(prompt: any): Message[] {
217
237
  content = m.content;
218
238
  } else if (Array.isArray(m.content)) {
219
239
  content = m.content
220
- .map((part: any) =>
221
- part?.type === "text" ? part.text : JSON.stringify(part)
222
- )
240
+ .map((part: any) => {
241
+ if (part?.type === "text") return part.text ?? "";
242
+ // NEVER inline a base64 image/file part — a data URL or Uint8Array here
243
+ // becomes megabytes, pushing the stored row past the 1 MiB doc limit
244
+ // (failing the call) and turning a 200 KB image into a ~50k-token
245
+ // estimate. Store a compact placeholder.
246
+ const bytes = part?.data?.length ?? part?.image?.length ?? part?.data?.byteLength;
247
+ return `[${part?.type ?? "part"}${typeof bytes === "number" ? ` ${bytes}b` : ""}]`;
248
+ })
223
249
  .join("");
224
250
  } else {
225
251
  content = JSON.stringify(m.content);
226
252
  }
227
- return { role: String(m.role), content };
253
+ return { role: String(m.role), content: capContent(content) };
228
254
  });
229
255
  }
230
256
 
@@ -463,35 +489,42 @@ export class AIBudget {
463
489
  }
464
490
  const { requestId, warnings, notices } = started;
465
491
  const start = Date.now();
492
+ // Run the provider call. ONLY a failure of the call itself settles as an
493
+ // error (no charge expected).
494
+ let out: Awaited<ReturnType<typeof run>>;
466
495
  try {
467
- const out = await run();
468
- const { costNanos } = await this.settle(ctx, {
469
- requestId,
470
- responseText: out.text,
471
- usage: out.usage,
472
- promptTokens: out.promptTokens,
473
- completionTokens: out.completionTokens,
474
- cachedTokens: out.cachedTokens,
475
- serverToolUses: out.serverToolUses,
476
- costNanos: out.costNanos,
477
- latencyMs: Date.now() - start,
478
- });
479
- // Re-derive the recorded usage for the return value.
480
- const usage =
481
- out.promptTokens !== undefined ||
482
- out.completionTokens !== undefined ||
483
- out.cachedTokens !== undefined
484
- ? {
485
- promptTokens: out.promptTokens ?? 0,
486
- completionTokens: out.completionTokens ?? 0,
487
- cachedTokens: out.cachedTokens ?? 0,
488
- }
489
- : extractUsage(out.usage);
490
- return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
496
+ out = await run();
491
497
  } catch (e) {
492
498
  await this.settle(ctx, { requestId, error: String(e), latencyMs: Date.now() - start });
493
499
  throw e;
494
500
  }
501
+ // The call SUCCEEDED (the provider may have charged). Settle the real usage.
502
+ // If settlement itself fails here, do NOT fall into an error-settle that
503
+ // records zero — that would erase a real charge. Rethrow and leave the
504
+ // reservation for the reconciler; billing stays "unknown", never a false zero.
505
+ const { costNanos } = await this.settle(ctx, {
506
+ requestId,
507
+ responseText: out.text,
508
+ usage: out.usage,
509
+ promptTokens: out.promptTokens,
510
+ completionTokens: out.completionTokens,
511
+ cachedTokens: out.cachedTokens,
512
+ serverToolUses: out.serverToolUses,
513
+ costNanos: out.costNanos,
514
+ latencyMs: Date.now() - start,
515
+ });
516
+ // Re-derive the recorded usage for the return value.
517
+ const usage =
518
+ out.promptTokens !== undefined ||
519
+ out.completionTokens !== undefined ||
520
+ out.cachedTokens !== undefined
521
+ ? {
522
+ promptTokens: out.promptTokens ?? 0,
523
+ completionTokens: out.completionTokens ?? 0,
524
+ cachedTokens: out.cachedTokens ?? 0,
525
+ }
526
+ : extractUsage(out.usage);
527
+ return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
495
528
  }
496
529
 
497
530
  /**
@@ -717,15 +750,10 @@ export class AIBudget {
717
750
  wrapGenerate: async ({ doGenerate, params }: any) => {
718
751
  const requestId = await begin(params);
719
752
  const start = Date.now();
753
+ // Only a failure of the generation itself settles as an error.
754
+ let result: any;
720
755
  try {
721
- const result = await doGenerate();
722
- await finish(requestId, {
723
- responseText: extractText(result),
724
- ...extractUsage(result.usage),
725
- costNanos: extractGatewayCostNanos(result),
726
- latencyMs: Date.now() - start,
727
- });
728
- return result;
756
+ result = await doGenerate();
729
757
  } catch (e) {
730
758
  await finish(requestId, {
731
759
  error: String(e),
@@ -733,6 +761,15 @@ export class AIBudget {
733
761
  });
734
762
  throw e;
735
763
  }
764
+ // Generation succeeded (provider may have charged). Settle the real
765
+ // usage; a failure here rethrows rather than recording a false zero.
766
+ await finish(requestId, {
767
+ responseText: extractText(result),
768
+ ...extractUsage(result.usage),
769
+ costNanos: extractGatewayCostNanos(result),
770
+ latencyMs: Date.now() - start,
771
+ });
772
+ return result;
736
773
  },
737
774
  wrapStream: async ({ doStream, params }: any) => {
738
775
  const requestId = await begin(params);
@@ -747,21 +784,24 @@ export class AIBudget {
747
784
  // chunk, or a cancel — is safe: the first wins, the rest no-op.
748
785
  // Without this an errored or abandoned stream would never settle and
749
786
  // its real usage would be lost (recorded as free by the reconciler).
750
- let settled = false;
751
- const settle = (error?: string) => {
752
- if (settled) return;
753
- settled = true;
754
- return finish(requestId, {
787
+ // Settle at most once, memoizing the PROMISE so the finish chunk, an
788
+ // error chunk, and flush all await the same settlement instead of
789
+ // racing, dropping it (the old `void settle()`), or flipping a
790
+ // "settled" flag before the mutation actually committed. A stream
791
+ // that is cancelled/never fully consumed won't deliver finish or
792
+ // flush; the reconciler's reservation expiry is the backstop there.
793
+ let settlement: Promise<{ costNanos: number }> | undefined;
794
+ const settle = (error?: string) =>
795
+ (settlement ??= finish(requestId, {
755
796
  responseText: text,
756
797
  error,
757
798
  ...extractUsage(usage),
758
799
  costNanos: extractGatewayCostNanos({ providerMetadata }),
759
800
  latencyMs: Date.now() - start,
760
- });
761
- };
801
+ }));
762
802
  const tapped = result.stream.pipeThrough(
763
803
  new TransformStream({
764
- transform(chunk: any, controller) {
804
+ async transform(chunk: any, controller: any) {
765
805
  if (chunk?.type === "text-delta") {
766
806
  text += chunk.delta ?? chunk.textDelta ?? "";
767
807
  }
@@ -769,13 +809,30 @@ export class AIBudget {
769
809
  usage = chunk.usage;
770
810
  providerMetadata = chunk.providerMetadata ?? providerMetadata;
771
811
  }
772
- if (chunk?.type === "error") void settle(String(chunk.error));
773
812
  controller.enqueue(chunk);
813
+ // Settle after forwarding the terminal error chunk, and AWAIT
814
+ // it so a failed settle surfaces instead of being dropped.
815
+ if (chunk?.type === "error") await settle(String(chunk.error));
774
816
  },
775
817
  async flush() {
776
818
  await settle();
777
819
  },
778
- })
820
+ // A cancelled/aborted stream (client disconnect, AbortSignal, or
821
+ // breaking out of `for await`) does NOT run `flush`. Without this
822
+ // the request would sit pending until the 30-min reservation
823
+ // expiry and be recorded as $0 though the provider charged for
824
+ // what streamed. Settle here with the text so far, estimating the
825
+ // completion tokens when the provider gave no usage.
826
+ // `cancel` is a newer Streams-spec transformer hook not yet in
827
+ // the TS DOM lib types; cast the literal so it compiles (runtimes
828
+ // without it simply won't call it, and the reconciler backstops).
829
+ async cancel(reason: any) {
830
+ if (usage === undefined && text) {
831
+ usage = { outputTokens: Math.ceil(text.length / 4) };
832
+ }
833
+ await settle(`cancelled: ${String(reason)}`);
834
+ },
835
+ } as any)
779
836
  );
780
837
  return { ...result, stream: tapped };
781
838
  } catch (e) {
@@ -1037,17 +1094,29 @@ export class AIBudget {
1037
1094
  if (!token) return { ok: false, token: "" };
1038
1095
  const url = new URL(request.url);
1039
1096
  const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
1040
- // `?token=` is accepted only for the initial page navigation (a browser
1041
- // GET can't set headers); the page strips it from the URL on load and the
1042
- // JSON API is called with the bearer header. Compared in constant time.
1043
- const provided = bearer || url.searchParams.get("token") || "";
1097
+ // `?token=` is accepted ONLY for the initial page navigation (a browser GET
1098
+ // can't set headers); the page strips it from the URL on load and calls the
1099
+ // JSON API with the bearer header. Restrict it to that GET page route so a
1100
+ // token can't be smuggled in a query string on API/mutation calls (where it
1101
+ // would also land in access logs). Compared in constant time.
1102
+ const sub = url.pathname.slice(prefix.length) || "/";
1103
+ const isPageNav = request.method === "GET" && !sub.startsWith("/api");
1104
+ const provided = bearer || (isPageNav ? url.searchParams.get("token") ?? "" : "");
1044
1105
  return { ok: timingSafeEqual(provided, token), token };
1045
1106
  };
1046
1107
  const json = (data: unknown, status = 200) =>
1047
1108
  new Response(JSON.stringify(data ?? null), {
1048
1109
  status,
1049
- headers: { "content-type": "application/json" },
1110
+ // Never let a shared cache/proxy retain budget data or the token-bearing
1111
+ // page — these responses are per-viewer and sensitive.
1112
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
1050
1113
  });
1114
+ // JSON.stringify does NOT escape `<`, so a value containing `</script>`
1115
+ // would close the inline <script> and break out. Escape `<` (and the JS line
1116
+ // separators) before embedding in HTML.
1117
+ const jsonForScript = (x: unknown) =>
1118
+ JSON.stringify(x).replace(/[<\u2028\u2029]/g, (ch) =>
1119
+ "\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0"));
1051
1120
 
1052
1121
  const handle = async (ctx: any, request: Request): Promise<Response> => {
1053
1122
  const url = new URL(request.url);
@@ -1111,15 +1180,19 @@ export class AIBudget {
1111
1180
  }
1112
1181
  }
1113
1182
 
1114
- // Inject as JSON literals (function replacers so `$` in the value isn't
1115
- // treated as a replacement pattern). This keeps a token/prefix containing
1116
- // quotes, backslashes, or `</script>` from breaking out of the JS string.
1183
+ // Inject as script-safe JSON literals (function replacers so `$` in the
1184
+ // value isn't treated as a replacement pattern; `jsonForScript` escapes
1185
+ // `<` so a token containing `</script>` can't break out of the inline JS).
1117
1186
  const html = DASHBOARD_HTML.replace(
1118
1187
  /__API_BASE__/g,
1119
- () => JSON.stringify(`${prefix}/api`)
1120
- ).replace(/__TOKEN__/g, () => JSON.stringify(token));
1188
+ () => jsonForScript(`${prefix}/api`)
1189
+ ).replace(/__TOKEN__/g, () => jsonForScript(token));
1190
+ // The page embeds the bearer token — never let a shared cache retain it.
1121
1191
  return new Response(html, {
1122
- headers: { "content-type": "text/html; charset=utf-8" },
1192
+ headers: {
1193
+ "content-type": "text/html; charset=utf-8",
1194
+ "cache-control": "no-store",
1195
+ },
1123
1196
  });
1124
1197
  };
1125
1198
 
@@ -185,9 +185,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
185
185
  "mutation",
186
186
  "internal",
187
187
  {
188
- dailySpendLimitNanos?: number;
189
- enforcement?: "hard" | "soft";
190
- lifetimeSpendLimitNanos?: number;
188
+ dailySpendLimitNanos?: number | null;
189
+ enforcement?: "hard" | "soft" | null;
190
+ lifetimeSpendLimitNanos?: number | null;
191
191
  },
192
192
  null,
193
193
  Name
@@ -332,8 +332,8 @@ describe("per-bucket rate limits", () => {
332
332
  const t = initTest();
333
333
  await setUserLimits(t, "u", { requestsPerMinute: 0 });
334
334
  expect((await start(t, { userId: "u" })).code).toBe("rate_limit");
335
- for (const requestsPerMinute of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) {
336
- await expect(setUserLimits(t, "u", { requestsPerMinute })).rejects.toThrow("nonnegative safe integer");
335
+ for (const requestsPerMinute of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, NaN, Infinity]) {
336
+ await expect(setUserLimits(t, "u", { requestsPerMinute })).rejects.toThrow(/requestsPerMinute/);
337
337
  }
338
338
  });
339
339
 
@@ -533,15 +533,17 @@ describe("model policy", () => {
533
533
  });
534
534
 
535
535
  describe("D-02 pricing validation", () => {
536
- test("setPrice rejects negative rates", async () => {
537
- const t = initTest();
538
- await expect(
539
- t.mutation(api.lib.setPrice, {
540
- model: "x/y",
541
- inputNanosPerMTok: -1,
542
- outputNanosPerMTok: 5,
543
- })
544
- ).rejects.toThrow(/non-negative/);
536
+ test("setPrice rejects negative and non-finite rates", async () => {
537
+ const t = initTest();
538
+ for (const inputNanosPerMTok of [-1, NaN, Infinity, 0.5]) {
539
+ await expect(
540
+ t.mutation(api.lib.setPrice, {
541
+ model: "x/y",
542
+ inputNanosPerMTok,
543
+ outputNanosPerMTok: 5,
544
+ })
545
+ ).rejects.toThrow(/inputNanosPerMTok/);
546
+ }
545
547
  });
546
548
  });
547
549
 
@@ -552,8 +554,9 @@ describe("F-04 fail-closed pricing", () => {
552
554
  expect(r.allowed).toBe(true);
553
555
  await settle(t, r.requestId, 1_000_000, 1_000_000);
554
556
  const u = await userOf(t, "u");
555
- // conservative = max over table = {$3 in, $15 out}/Mtok => $18 = 18e9 nano.
556
- expect(u.totalSpendNanos).toBe(18_000_000_000);
557
+ // Conservative fallback is a frontier ceiling of {$20 in, $100 out}/Mtok, so
558
+ // 1M in + 1M out => $120 = 120e9 nano (over-count is the safe direction).
559
+ expect(u.totalSpendNanos).toBe(120_000_000_000);
557
560
  const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
558
561
  expect(req.unpricedModel).toBe(true);
559
562
  });
@@ -596,7 +599,7 @@ describe("accounting lifecycle regressions", () => {
596
599
  await setUserLimits(t, "u", { dailySpendLimitNanos: 1000 });
597
600
  const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
598
601
  vi.advanceTimersByTime(2 * 60 * 60_000);
599
- await t.mutation(internal.lib.reconcile, {});
602
+ await t.mutation(internal.lib.expirePhase, {});
600
603
  expect((await userOf(t, "u")).reservedTotalNanos).toBe(0);
601
604
  await start(t, { userId: "u", estimatedCostNanos: 100 });
602
605
  await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 75 });
@@ -623,7 +626,7 @@ describe("accounting lifecycle regressions", () => {
623
626
  });
624
627
  const short = await start(t, { userId: "short" });
625
628
  vi.advanceTimersByTime(31 * 60_000);
626
- const result = await t.mutation(internal.lib.reconcile, {});
629
+ const result = await t.mutation(internal.lib.expirePhase, {});
627
630
  expect(result.expired).toBe(1);
628
631
  expect((await t.run(ctx => ctx.db.get(short.requestId))).reservationExpired).toBe(true);
629
632
  } finally { vi.useRealTimers(); }
@@ -662,9 +665,11 @@ test("legacy pending rows acquire deadlines without starving newer expired work"
662
665
  });
663
666
  const job = await start(t, { userId: "new" });
664
667
  vi.advanceTimersByTime(31 * 60_000);
665
- expect((await t.mutation(internal.lib.reconcile, {})).expired).toBe(1);
668
+ expect((await t.mutation(internal.lib.expirePhase, {})).expired).toBe(1);
666
669
  expect((await t.run(ctx => ctx.db.get(job.requestId))).reservationExpired).toBe(true);
667
- await t.mutation(internal.lib.reconcile, {});
670
+ // The phase self-reschedules to backfill the remaining legacy rows in
671
+ // batches; drain those scheduled continuations.
672
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
668
673
  expect(await t.run(ctx => ctx.db.query("requests").withIndex("status_expires", q =>
669
674
  q.eq("status", "pending").eq("expiresAt", undefined)).take(1))).toHaveLength(0);
670
675
  } finally { vi.useRealTimers(); }
@@ -683,7 +688,7 @@ test("retention progresses past unresolved jobs", async () => {
683
688
  await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 0 });
684
689
  await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
685
690
  vi.advanceTimersByTime(2 * 60 * 60_000);
686
- expect((await t.mutation(internal.lib.reconcile, {})).purged).toBe(1);
691
+ expect((await t.mutation(internal.lib.retentionPhase, {})).purged).toBe(1);
687
692
  expect(await t.run(ctx => ctx.db.get(job.requestId))).toBeNull();
688
693
  } finally { vi.useRealTimers(); }
689
694
  });
@@ -707,3 +712,135 @@ test("delayed folding attributes spend to completion day and leaves newer holds
707
712
  expect(history[0].spendNanos).toBe(50);
708
713
  } finally { vi.useRealTimers(); }
709
714
  });
715
+
716
+ describe("v1 hardening", () => {
717
+ test("setGlobalLimits: a one-field update preserves the other global limits", async () => {
718
+ const t = initTest();
719
+ await t.mutation(api.lib.setGlobalLimits, {
720
+ dailySpendLimitNanos: 100,
721
+ lifetimeSpendLimitNanos: 500,
722
+ enforcement: "soft",
723
+ });
724
+ // Update ONLY the daily cap — must not wipe lifetime/enforcement.
725
+ await t.mutation(api.lib.setGlobalLimits, { dailySpendLimitNanos: 200 });
726
+ const g = await t.query(api.lib.getGlobalStatus, {});
727
+ expect(g.dailySpendLimitNanos).toBe(200);
728
+ expect(g.lifetimeSpendLimitNanos).toBe(500);
729
+ expect(g.enforcement).toBe("soft");
730
+ // Explicit null clears just that field.
731
+ await t.mutation(api.lib.setGlobalLimits, { lifetimeSpendLimitNanos: null });
732
+ const after = await t.query(api.lib.getGlobalStatus, {});
733
+ expect(after.lifetimeSpendLimitNanos).toBe(null);
734
+ expect(after.dailySpendLimitNanos).toBe(200);
735
+ });
736
+
737
+ test("finishRequest on a missing/deleted request is a graceful no-op", async () => {
738
+ const t = initTest();
739
+ const r = await start(t, { userId: "u" });
740
+ await t.mutation(api.lib.deleteBucket, { dimension: "user", value: "u" });
741
+ // The request row is gone; a late/duplicate webhook must not throw.
742
+ const out = await t.mutation(api.lib.finishRequest, {
743
+ requestId: r.requestId,
744
+ costNanos: 1_000_000,
745
+ });
746
+ expect(out.costNanos).toBe(0);
747
+ });
748
+
749
+ test("deleting a user releases holds it placed on a shared bucket", async () => {
750
+ const t = initTest();
751
+ // A shared action bucket with a cap, so requests reserve against it.
752
+ await t.mutation(api.lib.setBucketLimits, {
753
+ dimension: "action",
754
+ value: "shared",
755
+ lifetimeSpendLimitNanos: 1_000_000_000,
756
+ });
757
+ // A pending (unsettled) request from user "u" attributed to that action.
758
+ await start(t, { userId: "u", actionName: "shared" });
759
+ let action = await bucketOf(t, "action", "shared");
760
+ expect(action.reservedTotalNanos).toBeGreaterThan(0);
761
+ expect(action.pendingCount).toBe(1);
762
+ // Deleting the user must free the shared bucket's hold, not strand it.
763
+ await t.mutation(api.lib.deleteBucket, { dimension: "user", value: "u" });
764
+ action = await bucketOf(t, "action", "shared");
765
+ expect(action.reservedTotalNanos ?? 0).toBe(0);
766
+ expect(action.pendingCount ?? 0).toBe(0);
767
+ });
768
+
769
+ test("a NaN/Infinity cost or token count cannot poison bucket totals", async () => {
770
+ const t = initTest();
771
+ const r = await start(t, { userId: "u" });
772
+ await t.mutation(api.lib.finishRequest, {
773
+ requestId: r.requestId,
774
+ costNanos: NaN, // ignored (not finite) -> token pricing
775
+ promptTokens: NaN, // coerced to 0
776
+ completionTokens: 5,
777
+ });
778
+ vi.useFakeTimers();
779
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
780
+ vi.useRealTimers();
781
+ const u = await userOf(t, "u");
782
+ expect(Number.isFinite(u.totalSpendNanos)).toBe(true);
783
+ expect(Number.isFinite(u.spendTodayNanos)).toBe(true);
784
+ expect(u.totalSpendNanos).toBeGreaterThanOrEqual(0);
785
+ });
786
+
787
+ test("a NaN limit is rejected rather than admitting unlimited spend", async () => {
788
+ const t = initTest();
789
+ await expect(
790
+ setUserLimits(t, "u", { dailySpendLimitNanos: NaN })
791
+ ).rejects.toThrow(/dailySpendLimitNanos/);
792
+ await expect(
793
+ setUserLimits(t, "u", { dailySpendLimitNanos: Infinity })
794
+ ).rejects.toThrow(/dailySpendLimitNanos/);
795
+ });
796
+ });
797
+
798
+ describe("v1 hardening (round 2)", () => {
799
+ test("a non-finite estimatedCostNanos is rejected before it can poison a bucket", async () => {
800
+ const t = initTest();
801
+ for (const estimatedCostNanos of [Infinity, NaN, -1]) {
802
+ await expect(
803
+ start(t, { userId: "u", estimatedCostNanos })
804
+ ).rejects.toThrow(/estimatedCostNanos/);
805
+ }
806
+ });
807
+
808
+ test("a settle with no cost signal falls back to the reserved estimate, not $0", async () => {
809
+ const t = initTest();
810
+ // $2 reserved up front (e.g. a video job).
811
+ const r = await start(t, { userId: "u", estimatedCostNanos: 2_000_000_000 });
812
+ // Settle with an unpriced server tool and no tokens/authoritative cost.
813
+ const out = await t.mutation(api.lib.finishRequest, {
814
+ requestId: r.requestId,
815
+ serverToolUses: { video_seconds: 8 }, // no configured price
816
+ });
817
+ expect(out.costNanos).toBe(2_000_000_000); // NOT 0
818
+ vi.useFakeTimers();
819
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
820
+ vi.useRealTimers();
821
+ const u = await userOf(t, "u");
822
+ expect(u.totalSpendNanos).toBe(2_000_000_000);
823
+ });
824
+ });
825
+
826
+ describe("v1 hardening (round 3): reconcile phases", () => {
827
+ test("expired tombstones are deleted after the late-settle horizon", async () => {
828
+ vi.useFakeTimers();
829
+ try {
830
+ const t = initTest();
831
+ const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
832
+ vi.advanceTimersByTime(31 * 60_000);
833
+ // Expire it into a billing tombstone (reservationExpired + settled).
834
+ await t.mutation(internal.lib.expirePhase, {});
835
+ // Within the 7-day late-settle horizon: retention keeps the tombstone.
836
+ await t.mutation(internal.lib.retentionPhase, {});
837
+ expect(await t.run((ctx) => ctx.db.get(job.requestId))).not.toBeNull();
838
+ // Past the horizon: the tombstone is deleted so they can't accumulate.
839
+ vi.advanceTimersByTime(8 * 24 * 60 * 60_000);
840
+ await t.mutation(internal.lib.retentionPhase, {});
841
+ expect(await t.run((ctx) => ctx.db.get(job.requestId))).toBeNull();
842
+ } finally {
843
+ vi.useRealTimers();
844
+ }
845
+ });
846
+ });