@convex-dev/ai-budget 0.0.2-alpha.17 → 0.0.2-alpha.19
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 +73 -13
- package/dist/client/index.d.ts +38 -5
- package/dist/client/index.js +56 -8
- package/dist/component/_generated/component.d.ts +8 -4
- package/dist/component/lib.d.ts +19 -3
- package/dist/component/lib.js +233 -80
- package/dist/component/schema.d.ts +20 -4
- package/dist/component/schema.js +28 -2
- package/package.json +1 -1
- package/src/client/index.ts +65 -15
- package/src/component/_generated/component.ts +11 -4
- package/src/component/lib.test.ts +109 -9
- package/src/component/lib.ts +249 -88
- package/src/component/schema.ts +28 -2
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -172,13 +172,20 @@ function extractUsage(usage: any): {
|
|
|
172
172
|
cachedTokens: number;
|
|
173
173
|
} {
|
|
174
174
|
return {
|
|
175
|
-
// Cover AI SDK camelCase (v5/v7)
|
|
176
|
-
// `
|
|
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.
|
|
177
178
|
promptTokens: toTokenCount(
|
|
178
|
-
usage?.inputTokens ??
|
|
179
|
+
usage?.inputTokens ??
|
|
180
|
+
usage?.promptTokens ??
|
|
181
|
+
usage?.prompt_tokens ??
|
|
182
|
+
usage?.input_tokens
|
|
179
183
|
),
|
|
180
184
|
completionTokens: toTokenCount(
|
|
181
|
-
usage?.outputTokens ??
|
|
185
|
+
usage?.outputTokens ??
|
|
186
|
+
usage?.completionTokens ??
|
|
187
|
+
usage?.completion_tokens ??
|
|
188
|
+
usage?.output_tokens
|
|
182
189
|
),
|
|
183
190
|
// cached prompt tokens. The Convex gateway reports these at
|
|
184
191
|
// `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
|
|
@@ -188,7 +195,8 @@ function extractUsage(usage: any): {
|
|
|
188
195
|
usage?.cachedInputTokens ??
|
|
189
196
|
usage?.promptTokensDetails?.cachedTokens ??
|
|
190
197
|
usage?.prompt_tokens_details?.cached_tokens ??
|
|
191
|
-
usage?.cached_tokens
|
|
198
|
+
usage?.cached_tokens ??
|
|
199
|
+
usage?.cache_read_input_tokens
|
|
192
200
|
),
|
|
193
201
|
};
|
|
194
202
|
}
|
|
@@ -214,6 +222,12 @@ function extractGatewayCostNanos(result: any): number | undefined {
|
|
|
214
222
|
|
|
215
223
|
const NANOS_PER_DOLLAR = 1e9;
|
|
216
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
|
+
|
|
217
231
|
// Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
|
|
218
232
|
function simplifyPrompt(prompt: any): Message[] {
|
|
219
233
|
if (!Array.isArray(prompt)) return [];
|
|
@@ -223,14 +237,20 @@ function simplifyPrompt(prompt: any): Message[] {
|
|
|
223
237
|
content = m.content;
|
|
224
238
|
} else if (Array.isArray(m.content)) {
|
|
225
239
|
content = m.content
|
|
226
|
-
.map((part: any) =>
|
|
227
|
-
part?.type === "text"
|
|
228
|
-
|
|
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
|
+
})
|
|
229
249
|
.join("");
|
|
230
250
|
} else {
|
|
231
251
|
content = JSON.stringify(m.content);
|
|
232
252
|
}
|
|
233
|
-
return { role: String(m.role), content };
|
|
253
|
+
return { role: String(m.role), content: capContent(content) };
|
|
234
254
|
});
|
|
235
255
|
}
|
|
236
256
|
|
|
@@ -781,7 +801,7 @@ export class AIBudget {
|
|
|
781
801
|
}));
|
|
782
802
|
const tapped = result.stream.pipeThrough(
|
|
783
803
|
new TransformStream({
|
|
784
|
-
async transform(chunk: any, controller) {
|
|
804
|
+
async transform(chunk: any, controller: any) {
|
|
785
805
|
if (chunk?.type === "text-delta") {
|
|
786
806
|
text += chunk.delta ?? chunk.textDelta ?? "";
|
|
787
807
|
}
|
|
@@ -797,7 +817,22 @@ export class AIBudget {
|
|
|
797
817
|
async flush() {
|
|
798
818
|
await settle();
|
|
799
819
|
},
|
|
800
|
-
|
|
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)
|
|
801
836
|
);
|
|
802
837
|
return { ...result, stream: tapped };
|
|
803
838
|
} catch (e) {
|
|
@@ -956,13 +991,17 @@ export class AIBudget {
|
|
|
956
991
|
return {
|
|
957
992
|
/** Limits + spend today/total. */
|
|
958
993
|
status: (ctx: RunQueryCtx) => ctx.runQuery(c.lib.getGlobalStatus, {}),
|
|
959
|
-
/**
|
|
994
|
+
/**
|
|
995
|
+
* A killswitch spend cap across all users/actions. Enforced
|
|
996
|
+
* **approximately** (bounded overshoot, no per-request reservation) — for
|
|
997
|
+
* an exact ceiling use a per-bucket cap. Pass `null` to clear a field.
|
|
998
|
+
*/
|
|
960
999
|
setLimits: (
|
|
961
1000
|
ctx: RunMutationCtx,
|
|
962
1001
|
args: {
|
|
963
|
-
dailySpendLimitNanos?: number;
|
|
964
|
-
lifetimeSpendLimitNanos?: number;
|
|
965
|
-
enforcement?: "
|
|
1002
|
+
dailySpendLimitNanos?: number | null;
|
|
1003
|
+
lifetimeSpendLimitNanos?: number | null;
|
|
1004
|
+
enforcement?: "approximate" | "soft" | null;
|
|
966
1005
|
}
|
|
967
1006
|
) => ctx.runMutation(c.lib.setGlobalLimits, args),
|
|
968
1007
|
bump: (
|
|
@@ -975,6 +1014,17 @@ export class AIBudget {
|
|
|
975
1014
|
/** Request-row retention window in ms (default 1h; 0 disables). */
|
|
976
1015
|
setRetention: (ctx: RunMutationCtx, args: { retentionMs: number }) =>
|
|
977
1016
|
ctx.runMutation(c.lib.setRetention, args),
|
|
1017
|
+
/**
|
|
1018
|
+
* Deployment-wide data/pricing policy (only the fields you pass change):
|
|
1019
|
+
* - `allowUnpricedModels: false` rejects models with no configured price
|
|
1020
|
+
* under hard enforcement (default true — charge the conservative fallback).
|
|
1021
|
+
* - `storeContent: false` stops persisting prompt/response content on
|
|
1022
|
+
* request rows (default true).
|
|
1023
|
+
*/
|
|
1024
|
+
setPolicy: (
|
|
1025
|
+
ctx: RunMutationCtx,
|
|
1026
|
+
args: { allowUnpricedModels?: boolean; storeContent?: boolean }
|
|
1027
|
+
) => ctx.runMutation(c.lib.setDeploymentPolicy, args),
|
|
978
1028
|
};
|
|
979
1029
|
}
|
|
980
1030
|
|
|
@@ -95,7 +95,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
95
95
|
{
|
|
96
96
|
dailySpendLimitNanos: number | null;
|
|
97
97
|
defaultWarnAtPct: number | null;
|
|
98
|
-
enforcement: "
|
|
98
|
+
enforcement: "approximate" | "soft";
|
|
99
99
|
lifetimeSpendLimitNanos: number | null;
|
|
100
100
|
retentionMs: number | null;
|
|
101
101
|
spentTodayNanos: number;
|
|
@@ -181,13 +181,20 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
181
181
|
null,
|
|
182
182
|
Name
|
|
183
183
|
>;
|
|
184
|
+
setDeploymentPolicy: FunctionReference<
|
|
185
|
+
"mutation",
|
|
186
|
+
"internal",
|
|
187
|
+
{ allowUnpricedModels?: boolean; storeContent?: boolean },
|
|
188
|
+
null,
|
|
189
|
+
Name
|
|
190
|
+
>;
|
|
184
191
|
setGlobalLimits: FunctionReference<
|
|
185
192
|
"mutation",
|
|
186
193
|
"internal",
|
|
187
194
|
{
|
|
188
|
-
dailySpendLimitNanos?: number;
|
|
189
|
-
enforcement?: "
|
|
190
|
-
lifetimeSpendLimitNanos?: number;
|
|
195
|
+
dailySpendLimitNanos?: number | null;
|
|
196
|
+
enforcement?: "approximate" | "soft" | null;
|
|
197
|
+
lifetimeSpendLimitNanos?: number | null;
|
|
191
198
|
},
|
|
192
199
|
null,
|
|
193
200
|
Name
|
|
@@ -245,7 +245,7 @@ describe("durable usage history", () => {
|
|
|
245
245
|
});
|
|
246
246
|
|
|
247
247
|
describe("manual adjustments", () => {
|
|
248
|
-
test("a credit
|
|
248
|
+
test("a credit accrues separately from gross spend and grants headroom", async () => {
|
|
249
249
|
const t = initTest();
|
|
250
250
|
const r = await start(t, { userId: "u" });
|
|
251
251
|
await settleWith(t, r.requestId, { promptTokens: 1_000_000, completionTokens: 1_000_000 });
|
|
@@ -256,11 +256,26 @@ describe("manual adjustments", () => {
|
|
|
256
256
|
reason: "goodwill credit",
|
|
257
257
|
});
|
|
258
258
|
const u = await userOf(t, "u");
|
|
259
|
-
|
|
259
|
+
// Gross spend is unchanged (credits never reduce it); the credit is tracked
|
|
260
|
+
// separately. Net = gross - credits = 500M.
|
|
261
|
+
expect(u.totalSpendNanos).toBe(750_000_000);
|
|
262
|
+
expect(u.creditsNanos).toBe(250_000_000);
|
|
260
263
|
const log = await t.query(api.lib.listAdjustments, { dimension: "user", value: "u" });
|
|
261
264
|
expect(log.length).toBe(1);
|
|
262
265
|
expect(log[0].deltaNanos).toBe(-250_000_000);
|
|
263
266
|
});
|
|
267
|
+
|
|
268
|
+
test("a credit grants headroom under a cap; a debit consumes gross spend", async () => {
|
|
269
|
+
const t = initTest();
|
|
270
|
+
await setUserLimits(t, "u", { lifetimeSpendLimitNanos: 1_000_000_000 });
|
|
271
|
+
const r = await start(t, { userId: "u" });
|
|
272
|
+
await settleWith(t, r.requestId, { costNanos: 900_000_000 }); // $0.90 gross
|
|
273
|
+
// Right at the edge: a $0.20 estimate would exceed the $1 cap...
|
|
274
|
+
expect((await start(t, { userId: "u", estimatedCostNanos: 200_000_000 })).allowed).toBe(false);
|
|
275
|
+
// ...but a $0.30 credit (net spend $0.60) reopens headroom.
|
|
276
|
+
await t.mutation(api.lib.adjustBucket, { dimension: "user", value: "u", deltaNanos: -300_000_000 });
|
|
277
|
+
expect((await start(t, { userId: "u", estimatedCostNanos: 200_000_000 })).allowed).toBe(true);
|
|
278
|
+
});
|
|
264
279
|
});
|
|
265
280
|
|
|
266
281
|
describe("threshold alerts", () => {
|
|
@@ -554,8 +569,9 @@ describe("F-04 fail-closed pricing", () => {
|
|
|
554
569
|
expect(r.allowed).toBe(true);
|
|
555
570
|
await settle(t, r.requestId, 1_000_000, 1_000_000);
|
|
556
571
|
const u = await userOf(t, "u");
|
|
557
|
-
//
|
|
558
|
-
|
|
572
|
+
// Conservative fallback is a frontier ceiling of {$20 in, $100 out}/Mtok, so
|
|
573
|
+
// 1M in + 1M out => $120 = 120e9 nano (over-count is the safe direction).
|
|
574
|
+
expect(u.totalSpendNanos).toBe(120_000_000_000);
|
|
559
575
|
const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
|
|
560
576
|
expect(req.unpricedModel).toBe(true);
|
|
561
577
|
});
|
|
@@ -598,7 +614,7 @@ describe("accounting lifecycle regressions", () => {
|
|
|
598
614
|
await setUserLimits(t, "u", { dailySpendLimitNanos: 1000 });
|
|
599
615
|
const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
600
616
|
vi.advanceTimersByTime(2 * 60 * 60_000);
|
|
601
|
-
await t.mutation(internal.lib.
|
|
617
|
+
await t.mutation(internal.lib.expirePhase, {});
|
|
602
618
|
expect((await userOf(t, "u")).reservedTotalNanos).toBe(0);
|
|
603
619
|
await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
604
620
|
await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 75 });
|
|
@@ -625,7 +641,7 @@ describe("accounting lifecycle regressions", () => {
|
|
|
625
641
|
});
|
|
626
642
|
const short = await start(t, { userId: "short" });
|
|
627
643
|
vi.advanceTimersByTime(31 * 60_000);
|
|
628
|
-
const result = await t.mutation(internal.lib.
|
|
644
|
+
const result = await t.mutation(internal.lib.expirePhase, {});
|
|
629
645
|
expect(result.expired).toBe(1);
|
|
630
646
|
expect((await t.run(ctx => ctx.db.get(short.requestId))).reservationExpired).toBe(true);
|
|
631
647
|
} finally { vi.useRealTimers(); }
|
|
@@ -638,6 +654,11 @@ describe("accounting lifecycle regressions", () => {
|
|
|
638
654
|
await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
|
|
639
655
|
expect((await t.query(api.lib.getGlobalStatus, {})).spentTotalNanos).toBe(100);
|
|
640
656
|
await t.mutation(api.lib.setGlobalLimits, { lifetimeSpendLimitNanos: 100 });
|
|
657
|
+
// The killswitch trips out-of-band (H4): the reconciler's globalPhase
|
|
658
|
+
// compares the sharded total to the cap and flags settings; admission reads
|
|
659
|
+
// the flag. So a request admits until the flag is set, then blocks.
|
|
660
|
+
expect((await start(t, { userId: "u", estimatedCostNanos: 1 })).allowed).toBe(true);
|
|
661
|
+
await t.mutation(internal.lib.globalPhase, {});
|
|
641
662
|
expect((await start(t, { userId: "u", estimatedCostNanos: 1 })).allowed).toBe(false);
|
|
642
663
|
});
|
|
643
664
|
|
|
@@ -664,9 +685,11 @@ test("legacy pending rows acquire deadlines without starving newer expired work"
|
|
|
664
685
|
});
|
|
665
686
|
const job = await start(t, { userId: "new" });
|
|
666
687
|
vi.advanceTimersByTime(31 * 60_000);
|
|
667
|
-
expect((await t.mutation(internal.lib.
|
|
688
|
+
expect((await t.mutation(internal.lib.expirePhase, {})).expired).toBe(1);
|
|
668
689
|
expect((await t.run(ctx => ctx.db.get(job.requestId))).reservationExpired).toBe(true);
|
|
669
|
-
|
|
690
|
+
// The phase self-reschedules to backfill the remaining legacy rows in
|
|
691
|
+
// batches; drain those scheduled continuations.
|
|
692
|
+
await t.finishAllScheduledFunctions(vi.runAllTimers);
|
|
670
693
|
expect(await t.run(ctx => ctx.db.query("requests").withIndex("status_expires", q =>
|
|
671
694
|
q.eq("status", "pending").eq("expiresAt", undefined)).take(1))).toHaveLength(0);
|
|
672
695
|
} finally { vi.useRealTimers(); }
|
|
@@ -685,7 +708,7 @@ test("retention progresses past unresolved jobs", async () => {
|
|
|
685
708
|
await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 0 });
|
|
686
709
|
await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
|
|
687
710
|
vi.advanceTimersByTime(2 * 60 * 60_000);
|
|
688
|
-
expect((await t.mutation(internal.lib.
|
|
711
|
+
expect((await t.mutation(internal.lib.retentionPhase, {})).purged).toBe(1);
|
|
689
712
|
expect(await t.run(ctx => ctx.db.get(job.requestId))).toBeNull();
|
|
690
713
|
} finally { vi.useRealTimers(); }
|
|
691
714
|
});
|
|
@@ -791,3 +814,80 @@ describe("v1 hardening", () => {
|
|
|
791
814
|
).rejects.toThrow(/dailySpendLimitNanos/);
|
|
792
815
|
});
|
|
793
816
|
});
|
|
817
|
+
|
|
818
|
+
describe("v1 hardening (round 2)", () => {
|
|
819
|
+
test("a non-finite estimatedCostNanos is rejected before it can poison a bucket", async () => {
|
|
820
|
+
const t = initTest();
|
|
821
|
+
for (const estimatedCostNanos of [Infinity, NaN, -1]) {
|
|
822
|
+
await expect(
|
|
823
|
+
start(t, { userId: "u", estimatedCostNanos })
|
|
824
|
+
).rejects.toThrow(/estimatedCostNanos/);
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("a settle with no cost signal falls back to the reserved estimate, not $0", async () => {
|
|
829
|
+
const t = initTest();
|
|
830
|
+
// $2 reserved up front (e.g. a video job).
|
|
831
|
+
const r = await start(t, { userId: "u", estimatedCostNanos: 2_000_000_000 });
|
|
832
|
+
// Settle with an unpriced server tool and no tokens/authoritative cost.
|
|
833
|
+
const out = await t.mutation(api.lib.finishRequest, {
|
|
834
|
+
requestId: r.requestId,
|
|
835
|
+
serverToolUses: { video_seconds: 8 }, // no configured price
|
|
836
|
+
});
|
|
837
|
+
expect(out.costNanos).toBe(2_000_000_000); // NOT 0
|
|
838
|
+
vi.useFakeTimers();
|
|
839
|
+
await t.finishAllScheduledFunctions(vi.runAllTimers);
|
|
840
|
+
vi.useRealTimers();
|
|
841
|
+
const u = await userOf(t, "u");
|
|
842
|
+
expect(u.totalSpendNanos).toBe(2_000_000_000);
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
describe("v1 hardening (round 3): reconcile phases", () => {
|
|
847
|
+
test("expired tombstones are deleted after the late-settle horizon", async () => {
|
|
848
|
+
vi.useFakeTimers();
|
|
849
|
+
try {
|
|
850
|
+
const t = initTest();
|
|
851
|
+
const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
852
|
+
vi.advanceTimersByTime(31 * 60_000);
|
|
853
|
+
// Expire it into a billing tombstone (reservationExpired + settled).
|
|
854
|
+
await t.mutation(internal.lib.expirePhase, {});
|
|
855
|
+
// Within the 7-day late-settle horizon: retention keeps the tombstone.
|
|
856
|
+
await t.mutation(internal.lib.retentionPhase, {});
|
|
857
|
+
expect(await t.run((ctx) => ctx.db.get(job.requestId))).not.toBeNull();
|
|
858
|
+
// Past the horizon: the tombstone is deleted so they can't accumulate.
|
|
859
|
+
vi.advanceTimersByTime(8 * 24 * 60 * 60_000);
|
|
860
|
+
await t.mutation(internal.lib.retentionPhase, {});
|
|
861
|
+
expect(await t.run((ctx) => ctx.db.get(job.requestId))).toBeNull();
|
|
862
|
+
} finally {
|
|
863
|
+
vi.useRealTimers();
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
describe("v1: deployment policy (unpriced models, content storage)", () => {
|
|
869
|
+
test("blocking unpriced models rejects a model with no configured price", async () => {
|
|
870
|
+
const t = initTest();
|
|
871
|
+
await t.mutation(api.lib.setDeploymentPolicy, { allowUnpricedModels: false });
|
|
872
|
+
const r = await start(t, { userId: "u", model: "made/up-model" });
|
|
873
|
+
expect(r.allowed).toBe(false);
|
|
874
|
+
expect(r.code).toBe("model_unpriced");
|
|
875
|
+
// A priced model still admits.
|
|
876
|
+
expect((await start(t, { userId: "u", model: MODEL })).allowed).toBe(true);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
test("storeContent:false keeps metadata but drops prompt/response content", async () => {
|
|
880
|
+
const t = initTest();
|
|
881
|
+
await t.mutation(api.lib.setDeploymentPolicy, { storeContent: false });
|
|
882
|
+
const r = await start(t, { userId: "u" });
|
|
883
|
+
await settleWith(t, r.requestId, {
|
|
884
|
+
promptTokens: 10,
|
|
885
|
+
completionTokens: 5,
|
|
886
|
+
responseText: "secret answer",
|
|
887
|
+
});
|
|
888
|
+
const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
|
|
889
|
+
expect(req.messages).toEqual([]); // prompt not stored
|
|
890
|
+
expect(req.responseText ?? undefined).toBe(undefined); // response not stored
|
|
891
|
+
expect(req.promptTokens).toBe(10); // metadata/cost still recorded
|
|
892
|
+
});
|
|
893
|
+
});
|