@convex-dev/ai-budget 0.0.2-alpha.14 → 0.0.2-alpha.16
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 +74 -9
- package/dist/client/index.d.ts +86 -0
- package/dist/client/index.js +69 -1
- package/dist/component/_generated/api.d.ts +1 -0
- package/dist/component/convex.config.js +2 -0
- package/dist/component/lib.d.ts +32 -0
- package/dist/component/lib.js +166 -122
- package/dist/component/schema.d.ts +53 -1
- package/dist/component/schema.js +31 -1
- package/package.json +3 -2
- package/src/client/index.ts +114 -1
- package/src/client/webhook.test.ts +32 -0
- package/src/component/_generated/api.ts +1 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/lib.test.ts +246 -29
- package/src/component/lib.ts +177 -135
- package/src/component/schema.ts +31 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { convexTest } from "convex-test";
|
|
2
2
|
import { describe, expect, test, vi } from "vitest";
|
|
3
|
+
import shardedCounterTest from "@convex-dev/sharded-counter/test";
|
|
4
|
+
import rateLimiterTest from "@convex-dev/rate-limiter/test";
|
|
3
5
|
import schema from "./schema";
|
|
4
|
-
import { api } from "./_generated/api";
|
|
6
|
+
import { api, internal } from "./_generated/api";
|
|
5
7
|
|
|
6
8
|
// convex-test loads the component's own modules; exclude convex.config (not a
|
|
7
9
|
// function module) and the test files themselves.
|
|
@@ -11,6 +13,13 @@ const modules = import.meta.glob([
|
|
|
11
13
|
"!./**/convex.config.ts",
|
|
12
14
|
]);
|
|
13
15
|
|
|
16
|
+
function initTest() {
|
|
17
|
+
const t = convexTest(schema, modules);
|
|
18
|
+
rateLimiterTest.register(t);
|
|
19
|
+
shardedCounterTest.register(t);
|
|
20
|
+
return t;
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
const MODEL = "openai/gpt-4o-mini";
|
|
15
24
|
const msg = (content: string) => [{ role: "user", content }];
|
|
16
25
|
|
|
@@ -46,7 +55,7 @@ const userOf = (t: any, userId: string) => bucketOf(t, "user", userId);
|
|
|
46
55
|
|
|
47
56
|
describe("reserve / settle spend caps", () => {
|
|
48
57
|
test("a daily cap below one request's reservation blocks up front", async () => {
|
|
49
|
-
const t =
|
|
58
|
+
const t = initTest();
|
|
50
59
|
// one gpt-4o-mini request reserves ~480_000 nanodollars ($0.00048); a
|
|
51
60
|
// 1_000-nano ($0.000001) cap can't fit it.
|
|
52
61
|
await setUserLimits(t, "u", { dailySpendLimitNanos: 1_000 });
|
|
@@ -56,7 +65,7 @@ describe("reserve / settle spend caps", () => {
|
|
|
56
65
|
});
|
|
57
66
|
|
|
58
67
|
test("reservation is released and settled to the real cost", async () => {
|
|
59
|
-
const t =
|
|
68
|
+
const t = initTest();
|
|
60
69
|
await setUserLimits(t, "u", { dailySpendLimitNanos: 1_000_000_000 }); // $1/day
|
|
61
70
|
const r = await start(t, { userId: "u" });
|
|
62
71
|
expect(r.allowed).toBe(true);
|
|
@@ -79,7 +88,7 @@ async function settleWith(t: any, requestId: any, fields: any) {
|
|
|
79
88
|
|
|
80
89
|
describe("monthly budgets", () => {
|
|
81
90
|
test("a tiny monthly cap blocks up front", async () => {
|
|
82
|
-
const t =
|
|
91
|
+
const t = initTest();
|
|
83
92
|
await setUserLimits(t, "u", { monthlySpendLimitNanos: 1_000 });
|
|
84
93
|
const r = await start(t, { userId: "u" });
|
|
85
94
|
expect(r.allowed).toBe(false);
|
|
@@ -89,7 +98,7 @@ describe("monthly budgets", () => {
|
|
|
89
98
|
|
|
90
99
|
describe("cache-aware pricing", () => {
|
|
91
100
|
test("cached prompt tokens are billed at the discount, not full input", async () => {
|
|
92
|
-
const t =
|
|
101
|
+
const t = initTest();
|
|
93
102
|
const r = await start(t, { userId: "u" });
|
|
94
103
|
// 1M prompt, ALL cached, 0 completion. gpt-4o-mini input $0.15/Mtok; the
|
|
95
104
|
// cache default is 10% of input → 0.1 * 150_000_000 = 15_000_000 nano.
|
|
@@ -104,7 +113,7 @@ describe("cache-aware pricing", () => {
|
|
|
104
113
|
});
|
|
105
114
|
|
|
106
115
|
test("an authoritative gateway cost overrides the token estimate", async () => {
|
|
107
|
-
const t =
|
|
116
|
+
const t = initTest();
|
|
108
117
|
const r = await start(t, { userId: "u" });
|
|
109
118
|
await settleWith(t, r.requestId, {
|
|
110
119
|
promptTokens: 1_000_000,
|
|
@@ -118,7 +127,7 @@ describe("cache-aware pricing", () => {
|
|
|
118
127
|
|
|
119
128
|
describe("server-tool pricing", () => {
|
|
120
129
|
test("server-tool uses add a per-call fee on top of tokens", async () => {
|
|
121
|
-
const t =
|
|
130
|
+
const t = initTest();
|
|
122
131
|
const r = await start(t, { userId: "u" });
|
|
123
132
|
// 0 tokens; 3 web searches at the $0.01 default = 30_000_000 nano.
|
|
124
133
|
await settleWith(t, r.requestId, {
|
|
@@ -132,7 +141,7 @@ describe("server-tool pricing", () => {
|
|
|
132
141
|
});
|
|
133
142
|
|
|
134
143
|
test("an override price is applied", async () => {
|
|
135
|
-
const t =
|
|
144
|
+
const t = initTest();
|
|
136
145
|
await t.mutation(api.lib.setServerToolPrice, {
|
|
137
146
|
tool: "web_search",
|
|
138
147
|
nanosPerCall: 12_000_000,
|
|
@@ -148,7 +157,7 @@ describe("server-tool pricing", () => {
|
|
|
148
157
|
});
|
|
149
158
|
|
|
150
159
|
test("an authoritative cost already includes tool fees (not double-charged)", async () => {
|
|
151
|
-
const t =
|
|
160
|
+
const t = initTest();
|
|
152
161
|
const r = await start(t, { userId: "u" });
|
|
153
162
|
await settleWith(t, r.requestId, {
|
|
154
163
|
promptTokens: 1_000_000,
|
|
@@ -163,7 +172,7 @@ describe("server-tool pricing", () => {
|
|
|
163
172
|
|
|
164
173
|
describe("cost known up front (image gen, per-call APIs)", () => {
|
|
165
174
|
test("estimatedCostNanos drives the reservation for a hard cap", async () => {
|
|
166
|
-
const t =
|
|
175
|
+
const t = initTest();
|
|
167
176
|
await setUserLimits(t, "u", { dailySpendLimitNanos: 100_000_000 }); // $0.10
|
|
168
177
|
// A $0.13 image is known before the call; reserving it exceeds the cap,
|
|
169
178
|
// even though the token estimate for the prompt alone would pass.
|
|
@@ -177,7 +186,7 @@ describe("cost known up front (image gen, per-call APIs)", () => {
|
|
|
177
186
|
});
|
|
178
187
|
|
|
179
188
|
test("admits when it fits, then settles to the real per-image cost", async () => {
|
|
180
|
-
const t =
|
|
189
|
+
const t = initTest();
|
|
181
190
|
await setUserLimits(t, "u", { dailySpendLimitNanos: 500_000_000 });
|
|
182
191
|
const r = await start(t, {
|
|
183
192
|
userId: "u",
|
|
@@ -194,7 +203,7 @@ describe("cost known up front (image gen, per-call APIs)", () => {
|
|
|
194
203
|
|
|
195
204
|
describe("async lifecycle (video jobs): begin now, settle later", () => {
|
|
196
205
|
test("reserveTtlMs is stored, and settle records the real cost", async () => {
|
|
197
|
-
const t =
|
|
206
|
+
const t = initTest();
|
|
198
207
|
await setUserLimits(t, "u", { dailySpendLimitNanos: 5_000_000_000 });
|
|
199
208
|
// Reserve $2 for a long job that will settle minutes later.
|
|
200
209
|
const r = await start(t, {
|
|
@@ -221,7 +230,7 @@ describe("async lifecycle (video jobs): begin now, settle later", () => {
|
|
|
221
230
|
|
|
222
231
|
describe("durable usage history", () => {
|
|
223
232
|
test("settled spend lands in a per-day usage row", async () => {
|
|
224
|
-
const t =
|
|
233
|
+
const t = initTest();
|
|
225
234
|
const r = await start(t, { userId: "u" });
|
|
226
235
|
await settleWith(t, r.requestId, { promptTokens: 1_000_000, completionTokens: 1_000_000 });
|
|
227
236
|
const hist = await t.query(api.lib.usageHistory, {
|
|
@@ -237,7 +246,7 @@ describe("durable usage history", () => {
|
|
|
237
246
|
|
|
238
247
|
describe("manual adjustments", () => {
|
|
239
248
|
test("a credit reduces spend and is logged", async () => {
|
|
240
|
-
const t =
|
|
249
|
+
const t = initTest();
|
|
241
250
|
const r = await start(t, { userId: "u" });
|
|
242
251
|
await settleWith(t, r.requestId, { promptTokens: 1_000_000, completionTokens: 1_000_000 });
|
|
243
252
|
await t.mutation(api.lib.adjustBucket, {
|
|
@@ -256,7 +265,7 @@ describe("manual adjustments", () => {
|
|
|
256
265
|
|
|
257
266
|
describe("threshold alerts", () => {
|
|
258
267
|
test("crossing warnAtPct returns a notice but still admits", async () => {
|
|
259
|
-
const t =
|
|
268
|
+
const t = initTest();
|
|
260
269
|
// One "hi" estimate is ~480_150 nano. Cap 800_000, warn at 50% (400_000).
|
|
261
270
|
await setUserLimits(t, "u", { dailySpendLimitNanos: 800_000, warnAtPct: 0.5 });
|
|
262
271
|
const r = await start(t, { userId: "u" });
|
|
@@ -267,7 +276,7 @@ describe("threshold alerts", () => {
|
|
|
267
276
|
|
|
268
277
|
describe("concurrency cap", () => {
|
|
269
278
|
test("maxConcurrent blocks a second in-flight request", async () => {
|
|
270
|
-
const t =
|
|
279
|
+
const t = initTest();
|
|
271
280
|
await setUserLimits(t, "u", { maxConcurrent: 1 });
|
|
272
281
|
const first = await start(t, { userId: "u" });
|
|
273
282
|
expect(first.allowed).toBe(true); // reserved, still pending
|
|
@@ -278,8 +287,67 @@ describe("concurrency cap", () => {
|
|
|
278
287
|
});
|
|
279
288
|
|
|
280
289
|
describe("per-bucket rate limits", () => {
|
|
290
|
+
test("refills continuously and does not consume other buckets on rejection", async () => {
|
|
291
|
+
vi.useFakeTimers();
|
|
292
|
+
try {
|
|
293
|
+
const t = initTest();
|
|
294
|
+
await setUserLimits(t, "u", { requestsPerMinute: 2 });
|
|
295
|
+
await t.mutation(api.lib.setBucketLimits, {
|
|
296
|
+
dimension: "action", value: "busy", requestsPerMinute: 1,
|
|
297
|
+
});
|
|
298
|
+
expect((await start(t, { userId: "other", actionName: "busy" })).allowed).toBe(true);
|
|
299
|
+
expect((await start(t, { userId: "u", actionName: "busy" })).code).toBe("action_rate_limit");
|
|
300
|
+
expect((await start(t, { userId: "u", actionName: "free" })).allowed).toBe(true);
|
|
301
|
+
expect((await start(t, { userId: "u", actionName: "free" })).allowed).toBe(true);
|
|
302
|
+
expect((await start(t, { userId: "u", actionName: "free" })).allowed).toBe(false);
|
|
303
|
+
vi.advanceTimersByTime(30_000);
|
|
304
|
+
expect((await start(t, { userId: "u", actionName: "free" })).allowed).toBe(true);
|
|
305
|
+
expect((await start(t, { userId: "u", actionName: "free" })).allowed).toBe(false);
|
|
306
|
+
} finally {
|
|
307
|
+
vi.useRealTimers();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("changing the rate preserves consumption and clamps to capacity", async () => {
|
|
312
|
+
vi.useFakeTimers();
|
|
313
|
+
try {
|
|
314
|
+
const t = initTest();
|
|
315
|
+
await setUserLimits(t, "u", { requestsPerMinute: 2 });
|
|
316
|
+
expect((await start(t, { userId: "u" })).allowed).toBe(true);
|
|
317
|
+
expect((await start(t, { userId: "u" })).allowed).toBe(true);
|
|
318
|
+
await setUserLimits(t, "u", { requestsPerMinute: 4 });
|
|
319
|
+
expect((await start(t, { userId: "u" })).allowed).toBe(false);
|
|
320
|
+
vi.advanceTimersByTime(15_000);
|
|
321
|
+
expect((await start(t, { userId: "u" })).allowed).toBe(true);
|
|
322
|
+
vi.advanceTimersByTime(60_000);
|
|
323
|
+
await setUserLimits(t, "u", { requestsPerMinute: 1 });
|
|
324
|
+
expect((await start(t, { userId: "u" })).allowed).toBe(true);
|
|
325
|
+
expect((await start(t, { userId: "u" })).allowed).toBe(false);
|
|
326
|
+
} finally {
|
|
327
|
+
vi.useRealTimers();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("zero blocks and invalid rates are rejected", async () => {
|
|
332
|
+
const t = initTest();
|
|
333
|
+
await setUserLimits(t, "u", { requestsPerMinute: 0 });
|
|
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");
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("deleting and recreating a bucket starts a fresh rate balance", async () => {
|
|
341
|
+
const t = initTest();
|
|
342
|
+
await setUserLimits(t, "u", { requestsPerMinute: 1 });
|
|
343
|
+
expect((await start(t, { userId: "u" })).allowed).toBe(true);
|
|
344
|
+
await t.mutation(api.lib.deleteBucket, { dimension: "user", value: "u" });
|
|
345
|
+
await setUserLimits(t, "u", { requestsPerMinute: 1 });
|
|
346
|
+
expect((await start(t, { userId: "u" })).allowed).toBe(true);
|
|
347
|
+
});
|
|
348
|
+
|
|
281
349
|
test("the existing user rate limit remains compatible", async () => {
|
|
282
|
-
const t =
|
|
350
|
+
const t = initTest();
|
|
283
351
|
await setUserLimits(t, "u", { requestsPerMinute: 1 });
|
|
284
352
|
const first = await start(t, { userId: "u" });
|
|
285
353
|
expect(first.allowed).toBe(true);
|
|
@@ -289,7 +357,7 @@ describe("per-bucket rate limits", () => {
|
|
|
289
357
|
});
|
|
290
358
|
|
|
291
359
|
test("an action rate limit blocks the next request for that action", async () => {
|
|
292
|
-
const t =
|
|
360
|
+
const t = initTest();
|
|
293
361
|
await t.mutation(api.lib.setBucketLimits, {
|
|
294
362
|
dimension: "action",
|
|
295
363
|
value: "ai:summarize",
|
|
@@ -303,7 +371,7 @@ describe("per-bucket rate limits", () => {
|
|
|
303
371
|
});
|
|
304
372
|
|
|
305
373
|
test("a custom-tag rate limit blocks the next request for that value", async () => {
|
|
306
|
-
const t =
|
|
374
|
+
const t = initTest();
|
|
307
375
|
await t.mutation(api.lib.setBucketLimits, {
|
|
308
376
|
dimension: "customer",
|
|
309
377
|
value: "acme",
|
|
@@ -320,7 +388,7 @@ describe("per-bucket rate limits", () => {
|
|
|
320
388
|
|
|
321
389
|
describe("tag-filtered request log", () => {
|
|
322
390
|
test("listRequests filters by a custom tag dimension", async () => {
|
|
323
|
-
const t =
|
|
391
|
+
const t = initTest();
|
|
324
392
|
await start(t, { userId: "u", tags: [{ dimension: "customer", value: "acme" }] });
|
|
325
393
|
await start(t, { userId: "u", tags: [{ dimension: "customer", value: "globex" }] });
|
|
326
394
|
const acme = await t.query(api.lib.listRequests, {
|
|
@@ -332,7 +400,7 @@ describe("tag-filtered request log", () => {
|
|
|
332
400
|
});
|
|
333
401
|
|
|
334
402
|
test("blocked attempts appear in the tag-filtered log", async () => {
|
|
335
|
-
const t =
|
|
403
|
+
const t = initTest();
|
|
336
404
|
const tags = [{ dimension: "burst", value: "run-1" }];
|
|
337
405
|
// Cap the tag bucket below one request's reservation so the attempt is
|
|
338
406
|
// budget-blocked (a persisted rejection).
|
|
@@ -352,7 +420,7 @@ describe("tag-filtered request log", () => {
|
|
|
352
420
|
});
|
|
353
421
|
|
|
354
422
|
test("persisted blocked attempts don't consume a custom-tag rate limit", async () => {
|
|
355
|
-
const t =
|
|
423
|
+
const t = initTest();
|
|
356
424
|
await t.mutation(api.lib.setBucketLimits, {
|
|
357
425
|
dimension: "customer",
|
|
358
426
|
value: "acme",
|
|
@@ -379,7 +447,7 @@ describe("tag-filtered request log", () => {
|
|
|
379
447
|
|
|
380
448
|
describe("tagged attribution buckets", () => {
|
|
381
449
|
test("a cap on a custom tag blocks, and settlement accrues to every bucket", async () => {
|
|
382
|
-
const t =
|
|
450
|
+
const t = initTest();
|
|
383
451
|
// A tiny cap on customer "acme" — the user is uncapped.
|
|
384
452
|
await t.mutation(api.lib.setBucketLimits, {
|
|
385
453
|
dimension: "customer",
|
|
@@ -411,7 +479,7 @@ describe("tagged attribution buckets", () => {
|
|
|
411
479
|
|
|
412
480
|
describe("D-00 exactly-once settlement", () => {
|
|
413
481
|
test("a duplicate finishRequest does not double-count", async () => {
|
|
414
|
-
const t =
|
|
482
|
+
const t = initTest();
|
|
415
483
|
const r = await start(t, { userId: "u" });
|
|
416
484
|
await settle(t, r.requestId); // first settle
|
|
417
485
|
const before = await userOf(t, "u");
|
|
@@ -426,7 +494,7 @@ describe("D-00 exactly-once settlement", () => {
|
|
|
426
494
|
|
|
427
495
|
describe("token quotas", () => {
|
|
428
496
|
test("a tiny daily token cap blocks (estimate exceeds it)", async () => {
|
|
429
|
-
const t =
|
|
497
|
+
const t = initTest();
|
|
430
498
|
await setUserLimits(t, "u", { dailyTokenLimit: 10 });
|
|
431
499
|
const r = await start(t, { userId: "u" });
|
|
432
500
|
expect(r.allowed).toBe(false);
|
|
@@ -436,7 +504,7 @@ describe("token quotas", () => {
|
|
|
436
504
|
|
|
437
505
|
describe("soft enforcement", () => {
|
|
438
506
|
test("over a soft budget: allowed, warned, flagged overBudget", async () => {
|
|
439
|
-
const t =
|
|
507
|
+
const t = initTest();
|
|
440
508
|
await setUserLimits(t, "u", {
|
|
441
509
|
dailySpendLimitNanos: 1, // 1 nanodollar — one estimate blows past it
|
|
442
510
|
enforcement: "soft",
|
|
@@ -451,7 +519,7 @@ describe("soft enforcement", () => {
|
|
|
451
519
|
|
|
452
520
|
describe("model policy", () => {
|
|
453
521
|
test("allowlist blocks an off-list model", async () => {
|
|
454
|
-
const t =
|
|
522
|
+
const t = initTest();
|
|
455
523
|
await t.mutation(api.lib.setModelPolicy, {
|
|
456
524
|
mode: "allowlist",
|
|
457
525
|
models: ["openai/gpt-4o-mini"],
|
|
@@ -466,7 +534,7 @@ describe("model policy", () => {
|
|
|
466
534
|
|
|
467
535
|
describe("D-02 pricing validation", () => {
|
|
468
536
|
test("setPrice rejects negative rates", async () => {
|
|
469
|
-
const t =
|
|
537
|
+
const t = initTest();
|
|
470
538
|
await expect(
|
|
471
539
|
t.mutation(api.lib.setPrice, {
|
|
472
540
|
model: "x/y",
|
|
@@ -479,7 +547,7 @@ describe("D-02 pricing validation", () => {
|
|
|
479
547
|
|
|
480
548
|
describe("F-04 fail-closed pricing", () => {
|
|
481
549
|
test("an unknown model is charged the conservative max, not zero", async () => {
|
|
482
|
-
const t =
|
|
550
|
+
const t = initTest();
|
|
483
551
|
const r = await start(t, { userId: "u", model: "made/up-model" });
|
|
484
552
|
expect(r.allowed).toBe(true);
|
|
485
553
|
await settle(t, r.requestId, 1_000_000, 1_000_000);
|
|
@@ -490,3 +558,152 @@ describe("F-04 fail-closed pricing", () => {
|
|
|
490
558
|
expect(req.unpricedModel).toBe(true);
|
|
491
559
|
});
|
|
492
560
|
});
|
|
561
|
+
|
|
562
|
+
describe("accounting lifecycle regressions", () => {
|
|
563
|
+
test("old-day and old-month settlements preserve new holds", async () => {
|
|
564
|
+
vi.useFakeTimers();
|
|
565
|
+
try {
|
|
566
|
+
vi.setSystemTime(new Date("2026-09-30T23:59:00Z"));
|
|
567
|
+
const t = initTest();
|
|
568
|
+
await setUserLimits(t, "u", { dailySpendLimitNanos: 1000, monthlySpendLimitNanos: 1000 });
|
|
569
|
+
const old = await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
570
|
+
vi.setSystemTime(new Date("2026-10-01T00:01:00Z"));
|
|
571
|
+
await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
572
|
+
await t.mutation(api.lib.finishRequest, { requestId: old.requestId, costNanos: 0 });
|
|
573
|
+
await t.mutation(internal.lib.foldTotals, { requestId: old.requestId });
|
|
574
|
+
const b = await userOf(t, "u");
|
|
575
|
+
expect(b.reservedTodayNanos).toBe(100);
|
|
576
|
+
expect(b.reservedMonthNanos).toBe(100);
|
|
577
|
+
expect(b.reservedTotalNanos).toBe(100);
|
|
578
|
+
expect(b.pendingCount).toBe(1);
|
|
579
|
+
} finally { vi.useRealTimers(); }
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("a request admitted before caps were enabled cannot release a later hold", async () => {
|
|
583
|
+
const t = initTest();
|
|
584
|
+
const old = await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
585
|
+
await setUserLimits(t, "u", { dailySpendLimitNanos: 1000 });
|
|
586
|
+
await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
587
|
+
await t.mutation(api.lib.finishRequest, { requestId: old.requestId, costNanos: 0 });
|
|
588
|
+
await t.mutation(internal.lib.foldTotals, { requestId: old.requestId });
|
|
589
|
+
expect((await userOf(t, "u")).reservedTotalNanos).toBe(100);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("expiry releases once, survives retention, and accepts one late charge", async () => {
|
|
593
|
+
vi.useFakeTimers();
|
|
594
|
+
try {
|
|
595
|
+
const t = initTest();
|
|
596
|
+
await setUserLimits(t, "u", { dailySpendLimitNanos: 1000 });
|
|
597
|
+
const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
598
|
+
vi.advanceTimersByTime(2 * 60 * 60_000);
|
|
599
|
+
await t.mutation(internal.lib.reconcile, {});
|
|
600
|
+
expect((await userOf(t, "u")).reservedTotalNanos).toBe(0);
|
|
601
|
+
await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
602
|
+
await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 75 });
|
|
603
|
+
await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
|
|
604
|
+
await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 999 });
|
|
605
|
+
await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
|
|
606
|
+
const b = await userOf(t, "u");
|
|
607
|
+
expect(b.totalSpendNanos).toBe(75);
|
|
608
|
+
expect(b.totalRequests).toBe(1);
|
|
609
|
+
expect(b.reservedTotalNanos).toBe(100);
|
|
610
|
+
expect((await t.query(api.lib.getGlobalStatus, {})).spentTotalNanos).toBe(75);
|
|
611
|
+
} finally { vi.useRealTimers(); }
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("long TTL jobs cannot hide expired jobs", async () => {
|
|
615
|
+
vi.useFakeTimers();
|
|
616
|
+
try {
|
|
617
|
+
const t = initTest();
|
|
618
|
+
await t.run(async ctx => {
|
|
619
|
+
for (let i = 0; i < 201; i++) await ctx.db.insert("requests", {
|
|
620
|
+
userId: "long", model: MODEL, messages: [], status: "pending",
|
|
621
|
+
expiresAt: Date.now() + 86400_000, heldBucketIds: [],
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
const short = await start(t, { userId: "short" });
|
|
625
|
+
vi.advanceTimersByTime(31 * 60_000);
|
|
626
|
+
const result = await t.mutation(internal.lib.reconcile, {});
|
|
627
|
+
expect(result.expired).toBe(1);
|
|
628
|
+
expect((await t.run(ctx => ctx.db.get(short.requestId))).reservationExpired).toBe(true);
|
|
629
|
+
} finally { vi.useRealTimers(); }
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("global accounting includes usage before limits are enabled", async () => {
|
|
633
|
+
const t = initTest();
|
|
634
|
+
const job = await start(t, { userId: "u" });
|
|
635
|
+
await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 100 });
|
|
636
|
+
await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
|
|
637
|
+
expect((await t.query(api.lib.getGlobalStatus, {})).spentTotalNanos).toBe(100);
|
|
638
|
+
await t.mutation(api.lib.setGlobalLimits, { lifetimeSpendLimitNanos: 100 });
|
|
639
|
+
expect((await start(t, { userId: "u", estimatedCostNanos: 1 })).allowed).toBe(false);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("settlement does not write admission policy, but changing limits does", async () => {
|
|
643
|
+
const t = initTest();
|
|
644
|
+
const job = await start(t, { userId: "u" });
|
|
645
|
+
const before = await t.run(ctx => ctx.db.query("bucketPolicies").collect());
|
|
646
|
+
await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 100 });
|
|
647
|
+
await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
|
|
648
|
+
expect(await t.run(ctx => ctx.db.query("bucketPolicies").collect())).toEqual(before);
|
|
649
|
+
await setUserLimits(t, "u", { blocked: true });
|
|
650
|
+
expect((await start(t, { userId: "u" })).allowed).toBe(false);
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("legacy pending rows acquire deadlines without starving newer expired work", async () => {
|
|
655
|
+
vi.useFakeTimers();
|
|
656
|
+
try {
|
|
657
|
+
const t = initTest();
|
|
658
|
+
await t.run(async ctx => {
|
|
659
|
+
for (let i = 0; i < 201; i++) await ctx.db.insert("requests", {
|
|
660
|
+
userId: "legacy", model: MODEL, messages: [], status: "pending", reserveTtlMs: 86400_000,
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
const job = await start(t, { userId: "new" });
|
|
664
|
+
vi.advanceTimersByTime(31 * 60_000);
|
|
665
|
+
expect((await t.mutation(internal.lib.reconcile, {})).expired).toBe(1);
|
|
666
|
+
expect((await t.run(ctx => ctx.db.get(job.requestId))).reservationExpired).toBe(true);
|
|
667
|
+
await t.mutation(internal.lib.reconcile, {});
|
|
668
|
+
expect(await t.run(ctx => ctx.db.query("requests").withIndex("status_expires", q =>
|
|
669
|
+
q.eq("status", "pending").eq("expiresAt", undefined)).take(1))).toHaveLength(0);
|
|
670
|
+
} finally { vi.useRealTimers(); }
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("retention progresses past unresolved jobs", async () => {
|
|
674
|
+
vi.useFakeTimers();
|
|
675
|
+
try {
|
|
676
|
+
const t = initTest();
|
|
677
|
+
await t.run(async ctx => {
|
|
678
|
+
for (let i = 0; i < 501; i++) await ctx.db.insert("requests", {
|
|
679
|
+
userId: "long", model: MODEL, messages: [], status: "pending", expiresAt: Date.now() + 86400_000,
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
const job = await start(t, { userId: "short" });
|
|
683
|
+
await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 0 });
|
|
684
|
+
await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
|
|
685
|
+
vi.advanceTimersByTime(2 * 60 * 60_000);
|
|
686
|
+
expect((await t.mutation(internal.lib.reconcile, {})).purged).toBe(1);
|
|
687
|
+
expect(await t.run(ctx => ctx.db.get(job.requestId))).toBeNull();
|
|
688
|
+
} finally { vi.useRealTimers(); }
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("delayed folding attributes spend to completion day and leaves newer holds intact", async () => {
|
|
692
|
+
vi.useFakeTimers();
|
|
693
|
+
try {
|
|
694
|
+
vi.setSystemTime(new Date("2026-09-30T23:59:00Z"));
|
|
695
|
+
const t = initTest();
|
|
696
|
+
await setUserLimits(t, "u", { dailySpendLimitNanos: 1000 });
|
|
697
|
+
const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
698
|
+
await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 50 });
|
|
699
|
+
vi.setSystemTime(new Date("2026-10-01T00:01:00Z"));
|
|
700
|
+
await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
701
|
+
await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
|
|
702
|
+
const b = await userOf(t, "u");
|
|
703
|
+
expect(b.spendTodayNanos).toBe(0);
|
|
704
|
+
expect(b.reservedTodayNanos).toBe(100);
|
|
705
|
+
const history = await t.query(api.lib.usageHistory, { dimension: "user", value: "u", period: "day" });
|
|
706
|
+
expect(history[0].stamp).toBe("2026-09-30");
|
|
707
|
+
expect(history[0].spendNanos).toBe(50);
|
|
708
|
+
} finally { vi.useRealTimers(); }
|
|
709
|
+
});
|