@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.
- package/README.md +50 -11
- package/dist/client/dashboard.js +12 -2
- package/dist/client/index.js +128 -62
- package/dist/component/_generated/component.d.ts +3 -3
- package/dist/component/lib.d.ts +9 -4
- package/dist/component/lib.js +215 -65
- package/package.json +1 -1
- package/src/client/dashboard.ts +12 -2
- package/src/client/index.ts +133 -60
- package/src/component/_generated/component.ts +3 -3
- package/src/component/lib.test.ts +155 -18
- package/src/component/lib.ts +219 -69
package/src/client/index.ts
CHANGED
|
@@ -172,8 +172,21 @@ function extractUsage(usage: any): {
|
|
|
172
172
|
cachedTokens: number;
|
|
173
173
|
} {
|
|
174
174
|
return {
|
|
175
|
-
|
|
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.
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
|
1041
|
-
//
|
|
1042
|
-
// JSON API
|
|
1043
|
-
|
|
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
|
-
|
|
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
|
|
1115
|
-
// treated as a replacement pattern
|
|
1116
|
-
//
|
|
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
|
-
() =>
|
|
1120
|
-
).replace(/__TOKEN__/g, () =>
|
|
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: {
|
|
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(
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
//
|
|
556
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
+
});
|