@convex-dev/ai-budget 0.0.2-alpha.13 → 0.0.2-alpha.15
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 +51 -10
- package/dist/client/index.d.ts +33 -0
- package/dist/client/index.js +1 -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 +176 -119
- 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 +2 -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 +289 -27
- package/src/component/lib.ts +187 -134
- package/src/component/schema.ts +31 -1
package/src/client/index.ts
CHANGED
|
@@ -239,6 +239,7 @@ function timingSafeEqual(a: string, b: string): boolean {
|
|
|
239
239
|
|
|
240
240
|
/** Limits/controls settable on any budget bucket (user, action, or tag). */
|
|
241
241
|
export type BucketLimits = {
|
|
242
|
+
/** Token-bucket refill per minute and burst capacity; 0 blocks all requests. */
|
|
242
243
|
requestsPerMinute?: number;
|
|
243
244
|
maxConcurrent?: number;
|
|
244
245
|
dailySpendLimitNanos?: number;
|
|
@@ -1061,7 +1062,7 @@ export class AIBudget {
|
|
|
1061
1062
|
path,
|
|
1062
1063
|
method: "POST",
|
|
1063
1064
|
handler: httpActionGeneric(async (ctx: any, request: Request) => {
|
|
1064
|
-
const body = await request.json().catch(() => ({}));
|
|
1065
|
+
const body = await request.clone().json().catch(() => ({}));
|
|
1065
1066
|
const settle = await opts.resolve(ctx, request, body);
|
|
1066
1067
|
if (!settle) return new Response("ignored", { status: 202 });
|
|
1067
1068
|
await self.settle(ctx, settle);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { test, expect, vi } from "vitest";
|
|
2
|
+
import { httpRouter } from "convex/server";
|
|
3
|
+
import { AIBudget } from "./index";
|
|
4
|
+
|
|
5
|
+
test("webhook resolver can verify the exact raw body before settlement", async () => {
|
|
6
|
+
const budget = new AIBudget({} as any);
|
|
7
|
+
const settle = vi.spyOn(budget, "settle").mockResolvedValue({ costNanos: 123 });
|
|
8
|
+
const http = httpRouter();
|
|
9
|
+
const raw = '{ "id": "job", "cost": 123 }\n';
|
|
10
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode("test-secret"),
|
|
11
|
+
{ name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
12
|
+
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(raw));
|
|
13
|
+
budget.registerWebhook(http, {
|
|
14
|
+
resolve: async (_ctx, request, body) => {
|
|
15
|
+
const bytes = await request.arrayBuffer();
|
|
16
|
+
if (!await crypto.subtle.verify("HMAC", key, signature, bytes)) return null;
|
|
17
|
+
expect(body.id).toBe("job");
|
|
18
|
+
return { requestId: "request", costNanos: body.cost };
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
const [handler] = http.lookup("/aibudget/webhook", "POST")!;
|
|
22
|
+
const response = await (handler as any)._handler({}, new Request("https://example.test/aibudget/webhook", {
|
|
23
|
+
method: "POST", body: raw,
|
|
24
|
+
}));
|
|
25
|
+
expect(response.status).toBe(200);
|
|
26
|
+
expect(settle).toHaveBeenCalledOnce();
|
|
27
|
+
const rejected = await (handler as any)._handler({}, new Request("https://example.test/aibudget/webhook", {
|
|
28
|
+
method: "POST", body: raw.replace("123", "999"),
|
|
29
|
+
}));
|
|
30
|
+
expect(rejected.status).toBe(202);
|
|
31
|
+
expect(settle).toHaveBeenCalledOnce();
|
|
32
|
+
});
|
|
@@ -51,4 +51,5 @@ export const internal: FilterApi<
|
|
|
51
51
|
|
|
52
52
|
export const components = componentsGeneric() as unknown as {
|
|
53
53
|
shardedCounter: import("@convex-dev/sharded-counter/_generated/component.js").ComponentApi<"shardedCounter">;
|
|
54
|
+
rateLimiter: import("@convex-dev/rate-limiter/_generated/component.js").ComponentApi<"rateLimiter">;
|
|
54
55
|
};
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { defineComponent } from "convex/server";
|
|
2
2
|
import shardedCounter from "@convex-dev/sharded-counter/convex.config";
|
|
3
3
|
|
|
4
|
+
import rateLimiter from "@convex-dev/rate-limiter/convex.config";
|
|
5
|
+
|
|
4
6
|
const component = defineComponent("aiBudget");
|
|
5
7
|
// Global spend totals use a sharded counter for high write throughput.
|
|
6
8
|
component.use(shardedCounter);
|
|
9
|
+
component.use(rateLimiter);
|
|
7
10
|
export default component;
|
|
@@ -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, {
|
|
@@ -330,11 +398,56 @@ describe("tag-filtered request log", () => {
|
|
|
330
398
|
expect(acme.length).toBe(1);
|
|
331
399
|
expect(acme[0].userId).toBe("u");
|
|
332
400
|
});
|
|
401
|
+
|
|
402
|
+
test("blocked attempts appear in the tag-filtered log", async () => {
|
|
403
|
+
const t = initTest();
|
|
404
|
+
const tags = [{ dimension: "burst", value: "run-1" }];
|
|
405
|
+
// Cap the tag bucket below one request's reservation so the attempt is
|
|
406
|
+
// budget-blocked (a persisted rejection).
|
|
407
|
+
await t.mutation(api.lib.setBucketLimits, {
|
|
408
|
+
dimension: "burst",
|
|
409
|
+
value: "run-1",
|
|
410
|
+
lifetimeSpendLimitNanos: 1_000,
|
|
411
|
+
});
|
|
412
|
+
const r = await start(t, { userId: "u", tags });
|
|
413
|
+
expect(r.allowed).toBe(false);
|
|
414
|
+
const log = await t.query(api.lib.listRequests, {
|
|
415
|
+
dimension: "burst",
|
|
416
|
+
value: "run-1",
|
|
417
|
+
});
|
|
418
|
+
expect(log.length).toBe(1);
|
|
419
|
+
expect(log[0].status).toBe("blocked");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("persisted blocked attempts don't consume a custom-tag rate limit", async () => {
|
|
423
|
+
const t = initTest();
|
|
424
|
+
await t.mutation(api.lib.setBucketLimits, {
|
|
425
|
+
dimension: "customer",
|
|
426
|
+
value: "acme",
|
|
427
|
+
requestsPerMinute: 1,
|
|
428
|
+
// also cap spend so attempts get budget-blocked (persisted) first
|
|
429
|
+
lifetimeSpendLimitNanos: 1_000,
|
|
430
|
+
});
|
|
431
|
+
const tags = [{ dimension: "customer", value: "acme" }];
|
|
432
|
+
// A budget-blocked (persisted) attempt writes a requestTags row…
|
|
433
|
+
const blocked = await start(t, { userId: "u1", tags });
|
|
434
|
+
expect(blocked.allowed).toBe(false);
|
|
435
|
+
expect(blocked.code).toBe("customer_lifetime_spend_limit");
|
|
436
|
+
// …which must NOT count toward the 1/min rate limit. Lift the spend cap:
|
|
437
|
+
// with no admitted requests in the window, the next request goes through.
|
|
438
|
+
await t.mutation(api.lib.setBucketLimits, {
|
|
439
|
+
dimension: "customer",
|
|
440
|
+
value: "acme",
|
|
441
|
+
lifetimeSpendLimitNanos: 1_000_000_000,
|
|
442
|
+
});
|
|
443
|
+
const next = await start(t, { userId: "u2", tags });
|
|
444
|
+
expect(next.allowed).toBe(true);
|
|
445
|
+
});
|
|
333
446
|
});
|
|
334
447
|
|
|
335
448
|
describe("tagged attribution buckets", () => {
|
|
336
449
|
test("a cap on a custom tag blocks, and settlement accrues to every bucket", async () => {
|
|
337
|
-
const t =
|
|
450
|
+
const t = initTest();
|
|
338
451
|
// A tiny cap on customer "acme" — the user is uncapped.
|
|
339
452
|
await t.mutation(api.lib.setBucketLimits, {
|
|
340
453
|
dimension: "customer",
|
|
@@ -366,7 +479,7 @@ describe("tagged attribution buckets", () => {
|
|
|
366
479
|
|
|
367
480
|
describe("D-00 exactly-once settlement", () => {
|
|
368
481
|
test("a duplicate finishRequest does not double-count", async () => {
|
|
369
|
-
const t =
|
|
482
|
+
const t = initTest();
|
|
370
483
|
const r = await start(t, { userId: "u" });
|
|
371
484
|
await settle(t, r.requestId); // first settle
|
|
372
485
|
const before = await userOf(t, "u");
|
|
@@ -381,7 +494,7 @@ describe("D-00 exactly-once settlement", () => {
|
|
|
381
494
|
|
|
382
495
|
describe("token quotas", () => {
|
|
383
496
|
test("a tiny daily token cap blocks (estimate exceeds it)", async () => {
|
|
384
|
-
const t =
|
|
497
|
+
const t = initTest();
|
|
385
498
|
await setUserLimits(t, "u", { dailyTokenLimit: 10 });
|
|
386
499
|
const r = await start(t, { userId: "u" });
|
|
387
500
|
expect(r.allowed).toBe(false);
|
|
@@ -391,7 +504,7 @@ describe("token quotas", () => {
|
|
|
391
504
|
|
|
392
505
|
describe("soft enforcement", () => {
|
|
393
506
|
test("over a soft budget: allowed, warned, flagged overBudget", async () => {
|
|
394
|
-
const t =
|
|
507
|
+
const t = initTest();
|
|
395
508
|
await setUserLimits(t, "u", {
|
|
396
509
|
dailySpendLimitNanos: 1, // 1 nanodollar — one estimate blows past it
|
|
397
510
|
enforcement: "soft",
|
|
@@ -406,7 +519,7 @@ describe("soft enforcement", () => {
|
|
|
406
519
|
|
|
407
520
|
describe("model policy", () => {
|
|
408
521
|
test("allowlist blocks an off-list model", async () => {
|
|
409
|
-
const t =
|
|
522
|
+
const t = initTest();
|
|
410
523
|
await t.mutation(api.lib.setModelPolicy, {
|
|
411
524
|
mode: "allowlist",
|
|
412
525
|
models: ["openai/gpt-4o-mini"],
|
|
@@ -421,7 +534,7 @@ describe("model policy", () => {
|
|
|
421
534
|
|
|
422
535
|
describe("D-02 pricing validation", () => {
|
|
423
536
|
test("setPrice rejects negative rates", async () => {
|
|
424
|
-
const t =
|
|
537
|
+
const t = initTest();
|
|
425
538
|
await expect(
|
|
426
539
|
t.mutation(api.lib.setPrice, {
|
|
427
540
|
model: "x/y",
|
|
@@ -434,7 +547,7 @@ describe("D-02 pricing validation", () => {
|
|
|
434
547
|
|
|
435
548
|
describe("F-04 fail-closed pricing", () => {
|
|
436
549
|
test("an unknown model is charged the conservative max, not zero", async () => {
|
|
437
|
-
const t =
|
|
550
|
+
const t = initTest();
|
|
438
551
|
const r = await start(t, { userId: "u", model: "made/up-model" });
|
|
439
552
|
expect(r.allowed).toBe(true);
|
|
440
553
|
await settle(t, r.requestId, 1_000_000, 1_000_000);
|
|
@@ -445,3 +558,152 @@ describe("F-04 fail-closed pricing", () => {
|
|
|
445
558
|
expect(req.unpricedModel).toBe(true);
|
|
446
559
|
});
|
|
447
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
|
+
});
|