@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/dist/component/lib.js
CHANGED
|
@@ -2,6 +2,7 @@ import { v } from "convex/values";
|
|
|
2
2
|
import { mutation, internalMutation, query, } from "./_generated/server";
|
|
3
3
|
import { api, internal, components } from "./_generated/api";
|
|
4
4
|
import { vMessage, vTag } from "./schema";
|
|
5
|
+
import { RateLimiter, MINUTE } from "@convex-dev/rate-limiter";
|
|
5
6
|
import { ShardedCounter } from "@convex-dev/sharded-counter";
|
|
6
7
|
// All money is integer **nanodollars** (1 USD = 1e9 nano). Integers avoid the
|
|
7
8
|
// rounding drift that floating-point cents accumulate over millions of
|
|
@@ -17,6 +18,16 @@ const fmtUsd = (nanos) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
|
|
|
17
18
|
// tags carrying them are ignored in favor of the first-class fields.
|
|
18
19
|
const USER_DIM = "user";
|
|
19
20
|
const ACTION_DIM = "action";
|
|
21
|
+
const requestRateLimiter = new RateLimiter(components.rateLimiter);
|
|
22
|
+
const requestRateOptions = (bucket) => ({
|
|
23
|
+
key: bucket._id,
|
|
24
|
+
config: {
|
|
25
|
+
kind: "token bucket",
|
|
26
|
+
rate: bucket.requestsPerMinute,
|
|
27
|
+
capacity: bucket.requestsPerMinute,
|
|
28
|
+
period: MINUTE,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
20
31
|
// Deployment-wide spend totals (nanodollars), sharded for high write throughput.
|
|
21
32
|
// Keyed "total" (lifetime) and "day:<UTC date>" (natural daily reset).
|
|
22
33
|
const globalSpend = new ShardedCounter(components.shardedCounter);
|
|
@@ -44,12 +55,8 @@ const DEFAULT_SERVER_TOOL_PRICES = {
|
|
|
44
55
|
// concurrent admission atomic against the estimate; a response that exceeds the
|
|
45
56
|
// estimate can still settle above the cap by the estimation delta.
|
|
46
57
|
const ESTIMATED_OUTPUT_TOKENS = 800;
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// above any real call duration — a long reasoning/agent generation that is
|
|
50
|
-
// still billing must not be swept and mis-recorded as free. A late
|
|
51
|
-
// finishRequest is a no-op once swept (see finishRequest's terminal guard), so
|
|
52
|
-
// the only cost of a generous timeout is a briefly-held reservation.
|
|
58
|
+
// Expiry releases a hold without declaring final usage. A late provider
|
|
59
|
+
// completion can still record its authoritative charge exactly once.
|
|
53
60
|
const STALE_PENDING_MS = 30 * 60 * 1000;
|
|
54
61
|
// Default retention for request rows (full prompts + responses). Terminal,
|
|
55
62
|
// fully-accounted rows older than this are swept by the reconciler. Keeps the
|
|
@@ -268,6 +275,35 @@ async function getOrCreateBucket(ctx, dimension, value) {
|
|
|
268
275
|
});
|
|
269
276
|
return (await ctx.db.get(id));
|
|
270
277
|
}
|
|
278
|
+
// Policy is read on every admission; reporting writes never touch it.
|
|
279
|
+
async function syncPolicy(ctx, b) {
|
|
280
|
+
const existing = await ctx.db.query("bucketPolicies").withIndex("dim_value", q => q.eq("dimension", b.dimension).eq("value", b.value)).unique();
|
|
281
|
+
const policy = {
|
|
282
|
+
bucketId: b._id, dimension: b.dimension, value: b.value,
|
|
283
|
+
requestsPerMinute: b.requestsPerMinute, maxConcurrent: b.maxConcurrent,
|
|
284
|
+
dailySpendLimitNanos: b.dailySpendLimitNanos, monthlySpendLimitNanos: b.monthlySpendLimitNanos,
|
|
285
|
+
lifetimeSpendLimitNanos: b.lifetimeSpendLimitNanos, dailyTokenLimit: b.dailyTokenLimit,
|
|
286
|
+
monthlyTokenLimit: b.monthlyTokenLimit, lifetimeTokenLimit: b.lifetimeTokenLimit,
|
|
287
|
+
blocked: b.blocked, warnAtPct: b.warnAtPct, enforcement: b.enforcement,
|
|
288
|
+
};
|
|
289
|
+
if (existing)
|
|
290
|
+
await ctx.db.replace(existing._id, policy);
|
|
291
|
+
else
|
|
292
|
+
await ctx.db.insert("bucketPolicies", policy);
|
|
293
|
+
}
|
|
294
|
+
async function admissionBucket(ctx, dimension, value) {
|
|
295
|
+
const policy = await ctx.db.query("bucketPolicies").withIndex("dim_value", q => q.eq("dimension", dimension).eq("value", value)).unique();
|
|
296
|
+
if (!policy) {
|
|
297
|
+
const bucket = await getOrCreateBucket(ctx, dimension, value);
|
|
298
|
+
await syncPolicy(ctx, bucket);
|
|
299
|
+
return bucket;
|
|
300
|
+
}
|
|
301
|
+
// Only capped buckets need an atomic read of their accounting state.
|
|
302
|
+
if (needsReserve(policy))
|
|
303
|
+
return (await ctx.db.get(policy.bucketId));
|
|
304
|
+
return { ...policy, _id: policy.bucketId, totalSpendNanos: 0, totalRequests: 0,
|
|
305
|
+
totalTokens: 0, dayStamp: "", spendTodayNanos: 0 };
|
|
306
|
+
}
|
|
271
307
|
async function getSettings(ctx) {
|
|
272
308
|
return await ctx.db
|
|
273
309
|
.query("settings")
|
|
@@ -316,15 +352,18 @@ export const startRequest = mutation({
|
|
|
316
352
|
},
|
|
317
353
|
returns: vStartResult,
|
|
318
354
|
handler: async (ctx, args) => {
|
|
355
|
+
if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
|
|
356
|
+
throw new Error("reserveTtlMs must be finite and nonnegative");
|
|
357
|
+
}
|
|
319
358
|
const extraTags = sanitizeExtraTags(args.tags);
|
|
320
359
|
// Record the blocked attempt and return a rejection (throwing would roll
|
|
321
360
|
// back the record). `persist` is false for the high-frequency-by-design
|
|
322
361
|
// rejections (rate limit, blocked user) that a client retries in a tight
|
|
323
362
|
// loop — persisting those would grow the requests table without bound and
|
|
324
|
-
//
|
|
363
|
+
// add audit-log writes to every transient rejection.
|
|
325
364
|
const reject = async (code, reason, persist = true) => {
|
|
326
365
|
if (persist) {
|
|
327
|
-
await ctx.db.insert("requests", {
|
|
366
|
+
const requestId = await ctx.db.insert("requests", {
|
|
328
367
|
userId: args.userId,
|
|
329
368
|
actionName: args.actionName,
|
|
330
369
|
...(extraTags.length ? { tags: extraTags } : {}),
|
|
@@ -334,6 +373,15 @@ export const startRequest = mutation({
|
|
|
334
373
|
status: "blocked",
|
|
335
374
|
error: reason,
|
|
336
375
|
});
|
|
376
|
+
// Reverse-index the blocked attempt too, so tag-filtered request logs
|
|
377
|
+
// show rejections alongside admitted traffic.
|
|
378
|
+
for (const t of extraTags) {
|
|
379
|
+
await ctx.db.insert("requestTags", {
|
|
380
|
+
dimension: t.dimension,
|
|
381
|
+
value: t.value,
|
|
382
|
+
requestId,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
337
385
|
}
|
|
338
386
|
return { allowed: false, code, reason };
|
|
339
387
|
};
|
|
@@ -366,7 +414,7 @@ export const startRequest = mutation({
|
|
|
366
414
|
const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
|
|
367
415
|
const buckets = [];
|
|
368
416
|
for (const t of bucketTags) {
|
|
369
|
-
buckets.push(await
|
|
417
|
+
buckets.push(await admissionBucket(ctx, t.dimension, t.value));
|
|
370
418
|
}
|
|
371
419
|
// A hard block on ANY bucket rejects the request. The user dimension's block
|
|
372
420
|
// isn't persisted (retried in a loop); config-level blocks on other
|
|
@@ -385,40 +433,15 @@ export const startRequest = mutation({
|
|
|
385
433
|
return reject(`${b.dimension}_max_concurrent`, `Too many concurrent requests for ${b.dimension} "${b.value}" (max ${b.maxConcurrent})`, false);
|
|
386
434
|
}
|
|
387
435
|
}
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
// limit (plus a small allowance for persisted blocked attempts).
|
|
392
|
-
const rateCutoff = Date.now() - 60_000;
|
|
436
|
+
// Check all rates before consuming any. These transactional reads remain
|
|
437
|
+
// in admission's read set, so concurrent requests cannot spend the same
|
|
438
|
+
// capacity. Budget rejections below leave every rate balance untouched.
|
|
393
439
|
for (const b of buckets) {
|
|
394
440
|
const limit = b.requestsPerMinute;
|
|
395
441
|
if (limit === undefined)
|
|
396
442
|
continue;
|
|
397
|
-
|
|
398
|
-
if (
|
|
399
|
-
const recent = await ctx.db
|
|
400
|
-
.query("requests")
|
|
401
|
-
.withIndex("userId", (q) => q.eq("userId", b.value).gt("_creationTime", rateCutoff))
|
|
402
|
-
.take(limit + 50);
|
|
403
|
-
recentCount = recent.filter((r) => r.status !== "blocked").length;
|
|
404
|
-
}
|
|
405
|
-
else if (b.dimension === ACTION_DIM) {
|
|
406
|
-
const recent = await ctx.db
|
|
407
|
-
.query("requests")
|
|
408
|
-
.withIndex("actionName", (q) => q.eq("actionName", b.value).gt("_creationTime", rateCutoff))
|
|
409
|
-
.take(limit + 50);
|
|
410
|
-
recentCount = recent.filter((r) => r.status !== "blocked").length;
|
|
411
|
-
}
|
|
412
|
-
else {
|
|
413
|
-
recentCount = (await ctx.db
|
|
414
|
-
.query("requestTags")
|
|
415
|
-
.withIndex("dim_value", (q) => q
|
|
416
|
-
.eq("dimension", b.dimension)
|
|
417
|
-
.eq("value", b.value)
|
|
418
|
-
.gt("_creationTime", rateCutoff))
|
|
419
|
-
.take(limit)).length;
|
|
420
|
-
}
|
|
421
|
-
if (recentCount >= limit) {
|
|
443
|
+
const ok = limit > 0 && (await requestRateLimiter.check(ctx, "requests", requestRateOptions(b))).ok;
|
|
444
|
+
if (!ok) {
|
|
422
445
|
const code = b.dimension === USER_DIM ? "rate_limit" : `${b.dimension}_rate_limit`;
|
|
423
446
|
return reject(code, `Rate limit exceeded for ${b.dimension} "${b.value}" (${limit}/min)`, false);
|
|
424
447
|
}
|
|
@@ -468,9 +491,8 @@ export const startRequest = mutation({
|
|
|
468
491
|
// committed + reserved + estimate <= cap. The ONE difference is the holder:
|
|
469
492
|
// per-bucket caps reserve on a single row (an atomic check-and-reserve),
|
|
470
493
|
// while the global holder is a sharded counter for
|
|
471
|
-
// throughput
|
|
472
|
-
//
|
|
473
|
-
// bounded amount under burst. That's the deliberate consistency/throughput
|
|
494
|
+
// throughput. The sum is transactional, but excludes unsettled usage and
|
|
495
|
+
// has no cross-request reservation, so a hard global cap can overshoot. That's the deliberate consistency/throughput
|
|
474
496
|
// trade for a deployment-wide killswitch; it's the only approximate scope.
|
|
475
497
|
if (settings &&
|
|
476
498
|
(settings.globalDailySpendLimitNanos !== undefined ||
|
|
@@ -502,6 +524,17 @@ export const startRequest = mutation({
|
|
|
502
524
|
warnings.push(...globalEval.warnings);
|
|
503
525
|
notices.push(...globalEval.notices);
|
|
504
526
|
}
|
|
527
|
+
// Consume only after every admission check succeeds, in the same
|
|
528
|
+
// transaction as the reservations and request insert. An unexpected
|
|
529
|
+
// rejection throws to roll back all previously consumed dimensions.
|
|
530
|
+
for (const b of buckets) {
|
|
531
|
+
if (b.requestsPerMinute !== undefined) {
|
|
532
|
+
await requestRateLimiter.limit(ctx, "requests", {
|
|
533
|
+
...requestRateOptions(b),
|
|
534
|
+
throws: true,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
505
538
|
// Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
|
|
506
539
|
// uncapped bucket's row here would serialize every request that shares it
|
|
507
540
|
// (e.g. all callers of one action, or every request in one env); with no cap
|
|
@@ -537,6 +570,10 @@ export const startRequest = mutation({
|
|
|
537
570
|
rerunOf: args.rerunOf,
|
|
538
571
|
...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
|
|
539
572
|
status: "pending",
|
|
573
|
+
expiresAt: Date.now() + Math.max(STALE_PENDING_MS, args.reserveTtlMs ?? 0),
|
|
574
|
+
heldBucketIds: buckets.filter(needsReserve).map(b => b._id),
|
|
575
|
+
reservationDay: today,
|
|
576
|
+
reservationMonth: month,
|
|
540
577
|
estimatedNanos: est.cost,
|
|
541
578
|
estimatedTokens: est.tokens,
|
|
542
579
|
...(priceInfo.known ? {} : { unpricedModel: true }),
|
|
@@ -577,14 +614,9 @@ export const finishRequest = mutation({
|
|
|
577
614
|
const request = await ctx.db.get(args.requestId);
|
|
578
615
|
if (!request)
|
|
579
616
|
throw new Error("Unknown request");
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
// already folded would be re-opened and folded a SECOND time when it
|
|
584
|
-
// finally completes: totals double-count and the reservation is released
|
|
585
|
-
// twice, dropping the reserved pool below reality and letting the atomic
|
|
586
|
-
// check-and-reserve admit requests it should block.
|
|
587
|
-
if (request.status !== "pending") {
|
|
617
|
+
// Expiry releases capacity, but is not evidence that the provider charged
|
|
618
|
+
// nothing. Accept one final result even after expiry; duplicates stay no-ops.
|
|
619
|
+
if (request.status !== "pending" && !request.reservationExpired) {
|
|
588
620
|
return { costNanos: request.costNanos ?? 0 };
|
|
589
621
|
}
|
|
590
622
|
// Clamp caller-supplied token counts: negatives would produce negative cost
|
|
@@ -609,6 +641,9 @@ export const finishRequest = mutation({
|
|
|
609
641
|
// orphaned in "pending" even if the totals update below fails and retries.
|
|
610
642
|
await ctx.db.patch(args.requestId, {
|
|
611
643
|
status: args.error ? "error" : "success",
|
|
644
|
+
reservationExpired: false,
|
|
645
|
+
expiresAt: undefined,
|
|
646
|
+
finishedAt: Date.now(),
|
|
612
647
|
responseText: args.responseText,
|
|
613
648
|
error: args.error,
|
|
614
649
|
promptTokens,
|
|
@@ -627,59 +662,68 @@ export const finishRequest = mutation({
|
|
|
627
662
|
return { costNanos };
|
|
628
663
|
},
|
|
629
664
|
});
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
|
|
665
|
+
// Release only holds owned by this request, and only from their original
|
|
666
|
+
// calendar windows. A previous day's completion must not debit today's holds.
|
|
667
|
+
async function releaseReservation(ctx, req) {
|
|
668
|
+
if (req.reservationReleased)
|
|
669
|
+
return;
|
|
670
|
+
const day = req.reservationDay ?? new Date(req._creationTime).toISOString().slice(0, 10);
|
|
671
|
+
const month = req.reservationMonth ?? day.slice(0, 7);
|
|
672
|
+
const cost = req.estimatedNanos ?? 0;
|
|
673
|
+
const tokens = req.estimatedTokens ?? 0;
|
|
674
|
+
for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
|
|
675
|
+
const b = await getBucketDoc(ctx, t.dimension, t.value);
|
|
676
|
+
if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b)))
|
|
677
|
+
continue;
|
|
678
|
+
await ctx.db.patch(b._id, {
|
|
679
|
+
reservedTodayNanos: Math.max(0, (b.reservedTodayNanos ?? 0) - (b.dayStamp === day ? cost : 0)),
|
|
680
|
+
reservedMonthNanos: Math.max(0, (b.reservedMonthNanos ?? 0) - (b.monthStamp === month ? cost : 0)),
|
|
681
|
+
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - cost),
|
|
682
|
+
reservedTodayTokens: Math.max(0, (b.reservedTodayTokens ?? 0) - (b.dayStamp === day ? tokens : 0)),
|
|
683
|
+
reservedMonthTokens: Math.max(0, (b.reservedMonthTokens ?? 0) - (b.monthStamp === month ? tokens : 0)),
|
|
684
|
+
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - tokens),
|
|
685
|
+
pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
await ctx.db.patch(req._id, { reservationReleased: true });
|
|
689
|
+
}
|
|
690
|
+
// Final billing is folded once. Reservation release has its own guard because
|
|
691
|
+
// expiry can precede the final charge by hours or days.
|
|
633
692
|
async function foldOne(ctx, req) {
|
|
634
693
|
if (!req || req.settled !== false)
|
|
635
694
|
return;
|
|
695
|
+
await releaseReservation(ctx, req);
|
|
636
696
|
const actual = req.costNanos ?? 0;
|
|
637
|
-
const estCost = req.estimatedNanos ?? 0;
|
|
638
697
|
const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
|
|
639
|
-
const
|
|
640
|
-
const
|
|
641
|
-
const month =
|
|
642
|
-
// Accrue into every attributed bucket (user, action, and each tag) — capped
|
|
643
|
-
// or not. Buckets that never held a reservation have their reserved fields
|
|
644
|
-
// clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
|
|
645
|
-
// Also write the durable per-(bucket, day/month) usage rows that survive
|
|
646
|
-
// request retention, so spend history outlives the raw request log.
|
|
698
|
+
const timestamp = new Date(req.finishedAt ?? Date.now()).toISOString();
|
|
699
|
+
const day = timestamp.slice(0, 10);
|
|
700
|
+
const month = timestamp.slice(0, 7);
|
|
647
701
|
for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
|
|
648
702
|
const b = await getOrCreateBucket(ctx, t.dimension, t.value);
|
|
649
|
-
|
|
650
|
-
|
|
703
|
+
// Keep the newest reporting window and clear obsolete window holds when
|
|
704
|
+
// advancing it. Lifetime holds persist until their owner releases them.
|
|
705
|
+
const targetDay = b.dayStamp > day ? b.dayStamp : day;
|
|
706
|
+
const targetMonth = (b.monthStamp ?? "") > month ? b.monthStamp : month;
|
|
651
707
|
await ctx.db.patch(b._id, {
|
|
652
708
|
totalSpendNanos: b.totalSpendNanos + actual,
|
|
653
709
|
totalRequests: b.totalRequests + 1,
|
|
654
710
|
totalTokens: b.totalTokens + tokens,
|
|
655
|
-
dayStamp:
|
|
656
|
-
monthStamp:
|
|
657
|
-
spendTodayNanos: (
|
|
658
|
-
tokensToday: (
|
|
659
|
-
spendThisMonthNanos: (
|
|
660
|
-
tokensThisMonth: (
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
|
|
664
|
-
reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
|
|
665
|
-
reservedMonthTokens: Math.max(0, (sameMonth ? b.reservedMonthTokens ?? 0 : 0) - estTokens),
|
|
666
|
-
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
|
|
667
|
-
pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
|
|
711
|
+
dayStamp: targetDay,
|
|
712
|
+
monthStamp: targetMonth,
|
|
713
|
+
spendTodayNanos: (b.dayStamp === targetDay ? b.spendTodayNanos : 0) + (day === targetDay ? actual : 0),
|
|
714
|
+
tokensToday: (b.dayStamp === targetDay ? b.tokensToday ?? 0 : 0) + (day === targetDay ? tokens : 0),
|
|
715
|
+
spendThisMonthNanos: (b.monthStamp === targetMonth ? b.spendThisMonthNanos ?? 0 : 0) + (month === targetMonth ? actual : 0),
|
|
716
|
+
tokensThisMonth: (b.monthStamp === targetMonth ? b.tokensThisMonth ?? 0 : 0) + (month === targetMonth ? tokens : 0),
|
|
717
|
+
...(b.dayStamp !== targetDay ? { reservedTodayNanos: 0, reservedTodayTokens: 0 } : {}),
|
|
718
|
+
...(b.monthStamp !== targetMonth ? { reservedMonthNanos: 0, reservedMonthTokens: 0 } : {}),
|
|
668
719
|
});
|
|
669
|
-
await addUsage(ctx, t.dimension, t.value, "day",
|
|
720
|
+
await addUsage(ctx, t.dimension, t.value, "day", day, actual, tokens, 1);
|
|
670
721
|
await addUsage(ctx, t.dimension, t.value, "month", month, actual, tokens, 1);
|
|
671
722
|
}
|
|
672
|
-
//
|
|
673
|
-
// configured — otherwise skip the writes entirely). Distributed across shards,
|
|
674
|
-
// so this does not serialize on a single row.
|
|
723
|
+
// Reporting is independent of whether enforcement is configured.
|
|
675
724
|
if (actual > 0) {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
(settings.globalDailySpendLimitNanos !== undefined ||
|
|
679
|
-
settings.globalLifetimeSpendLimitNanos !== undefined)) {
|
|
680
|
-
await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
|
|
681
|
-
await globalSpend.add(ctx, globalDayKey(today), actual);
|
|
682
|
-
}
|
|
725
|
+
await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
|
|
726
|
+
await globalSpend.add(ctx, globalDayKey(day), actual);
|
|
683
727
|
}
|
|
684
728
|
await ctx.db.patch(req._id, { settled: true });
|
|
685
729
|
}
|
|
@@ -708,46 +752,44 @@ export const reconcile = internalMutation({
|
|
|
708
752
|
.take(200);
|
|
709
753
|
for (const req of toFold)
|
|
710
754
|
await foldOne(ctx, req);
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
.take(200);
|
|
719
|
-
let expired = 0;
|
|
755
|
+
// Lazily migrate old pending rows in bounded batches. Once indexed, long
|
|
756
|
+
// TTL jobs cannot hide expired jobs behind them in creation-time order.
|
|
757
|
+
const legacy = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").eq("expiresAt", undefined)).take(200);
|
|
758
|
+
for (const req of legacy) {
|
|
759
|
+
await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
|
|
760
|
+
}
|
|
761
|
+
const candidates = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(200);
|
|
720
762
|
for (const req of candidates) {
|
|
721
|
-
|
|
722
|
-
if (Date.now() - req._creationTime <= ttl)
|
|
723
|
-
continue; // still within its window
|
|
763
|
+
await releaseReservation(ctx, req);
|
|
724
764
|
await ctx.db.patch(req._id, {
|
|
725
|
-
status: "error",
|
|
726
|
-
|
|
727
|
-
costNanos: 0,
|
|
728
|
-
settled: false,
|
|
765
|
+
status: "error", error: "Reservation expired; awaiting final usage",
|
|
766
|
+
reservationExpired: true, expiresAt: undefined, settled: true,
|
|
729
767
|
});
|
|
730
|
-
await foldOne(ctx, await ctx.db.get(req._id));
|
|
731
|
-
expired++;
|
|
732
768
|
}
|
|
769
|
+
const expired = candidates.length;
|
|
733
770
|
// Retention: delete terminal, fully-accounted request rows past the window.
|
|
734
771
|
const settings = await getSettings(ctx);
|
|
735
772
|
const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
736
773
|
let purged = 0;
|
|
737
774
|
if (retentionMs > 0) {
|
|
738
775
|
const retentionCutoff = Date.now() - retentionMs;
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
776
|
+
// Query eligible states directly. Long-lived pending jobs and expired
|
|
777
|
+
// billing tombstones must not repeatedly occupy the front of a scan.
|
|
778
|
+
const old = [];
|
|
779
|
+
for (const expiredFlag of [undefined, false]) {
|
|
780
|
+
old.push(...await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", expiredFlag).eq("settled", true)
|
|
781
|
+
.lt("_creationTime", retentionCutoff)).take(200));
|
|
782
|
+
}
|
|
783
|
+
old.push(...await ctx.db.query("requests").withIndex("status", q => q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
|
|
743
784
|
for (const req of old) {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
785
|
+
await deleteRequestTags(ctx, req._id);
|
|
786
|
+
await ctx.db.delete(req._id);
|
|
787
|
+
purged++;
|
|
788
|
+
}
|
|
789
|
+
const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q => q.eq("reservationExpired", true).eq("contentPurged", undefined)
|
|
790
|
+
.lt("_creationTime", retentionCutoff)).take(200);
|
|
791
|
+
for (const req of expiredContent) {
|
|
792
|
+
await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
|
|
751
793
|
}
|
|
752
794
|
}
|
|
753
795
|
return { folded: toFold.length, expired, purged };
|
|
@@ -895,9 +937,14 @@ export const setBucketLimits = mutation({
|
|
|
895
937
|
},
|
|
896
938
|
returns: v.null(),
|
|
897
939
|
handler: async (ctx, args) => {
|
|
940
|
+
if (args.requestsPerMinute !== undefined &&
|
|
941
|
+
(!Number.isSafeInteger(args.requestsPerMinute) || args.requestsPerMinute < 0)) {
|
|
942
|
+
throw new Error("requestsPerMinute must be a nonnegative safe integer");
|
|
943
|
+
}
|
|
898
944
|
const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
|
|
899
945
|
const { dimension: _d, value: _v, ...limits } = args;
|
|
900
946
|
await ctx.db.patch(bucket._id, limits);
|
|
947
|
+
await syncPolicy(ctx, (await ctx.db.get(bucket._id)));
|
|
901
948
|
return null;
|
|
902
949
|
},
|
|
903
950
|
});
|
|
@@ -1028,13 +1075,23 @@ export const deleteBucket = mutation({
|
|
|
1028
1075
|
return { deletedThisBatch: rows.length, done: false };
|
|
1029
1076
|
}
|
|
1030
1077
|
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
1031
|
-
if (bucket)
|
|
1078
|
+
if (bucket) {
|
|
1079
|
+
const policy = await ctx.db.query("bucketPolicies").withIndex("dim_value", q => q.eq("dimension", dimension).eq("value", value)).unique();
|
|
1080
|
+
if (policy)
|
|
1081
|
+
await ctx.db.delete(policy._id);
|
|
1082
|
+
await requestRateLimiter.reset(ctx, "requests", { key: bucket._id });
|
|
1032
1083
|
await ctx.db.delete(bucket._id);
|
|
1084
|
+
}
|
|
1033
1085
|
return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
|
|
1034
1086
|
}
|
|
1035
1087
|
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
1036
|
-
if (bucket)
|
|
1088
|
+
if (bucket) {
|
|
1089
|
+
const policy = await ctx.db.query("bucketPolicies").withIndex("dim_value", q => q.eq("dimension", dimension).eq("value", value)).unique();
|
|
1090
|
+
if (policy)
|
|
1091
|
+
await ctx.db.delete(policy._id);
|
|
1092
|
+
await requestRateLimiter.reset(ctx, "requests", { key: bucket._id });
|
|
1037
1093
|
await ctx.db.delete(bucket._id);
|
|
1094
|
+
}
|
|
1038
1095
|
return { deletedThisBatch: bucket ? 1 : 0, done: true };
|
|
1039
1096
|
},
|
|
1040
1097
|
});
|
|
@@ -13,6 +13,39 @@ export declare const vTag: import("convex/values").VObject<{
|
|
|
13
13
|
value: import("convex/values").VString<string, "required">;
|
|
14
14
|
}, "required", "dimension" | "value">;
|
|
15
15
|
declare const _default: import("convex/server").SchemaDefinition<{
|
|
16
|
+
bucketPolicies: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
17
|
+
requestsPerMinute?: number | undefined;
|
|
18
|
+
maxConcurrent?: number | undefined;
|
|
19
|
+
dailySpendLimitNanos?: number | undefined;
|
|
20
|
+
monthlySpendLimitNanos?: number | undefined;
|
|
21
|
+
lifetimeSpendLimitNanos?: number | undefined;
|
|
22
|
+
dailyTokenLimit?: number | undefined;
|
|
23
|
+
monthlyTokenLimit?: number | undefined;
|
|
24
|
+
lifetimeTokenLimit?: number | undefined;
|
|
25
|
+
blocked?: boolean | undefined;
|
|
26
|
+
warnAtPct?: number | undefined;
|
|
27
|
+
enforcement?: "hard" | "soft" | undefined;
|
|
28
|
+
bucketId: import("convex/values").GenericId<"buckets">;
|
|
29
|
+
dimension: string;
|
|
30
|
+
value: string;
|
|
31
|
+
}, {
|
|
32
|
+
bucketId: import("convex/values").VId<import("convex/values").GenericId<"buckets">, "required">;
|
|
33
|
+
dimension: import("convex/values").VString<string, "required">;
|
|
34
|
+
value: import("convex/values").VString<string, "required">;
|
|
35
|
+
requestsPerMinute: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
36
|
+
maxConcurrent: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
37
|
+
dailySpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
38
|
+
monthlySpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
39
|
+
lifetimeSpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
40
|
+
dailyTokenLimit: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
41
|
+
monthlyTokenLimit: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
42
|
+
lifetimeTokenLimit: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
43
|
+
blocked: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
44
|
+
warnAtPct: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
45
|
+
enforcement: import("convex/values").VUnion<"hard" | "soft" | undefined, [import("convex/values").VLiteral<"hard", "required">, import("convex/values").VLiteral<"soft", "required">], "optional", never>;
|
|
46
|
+
}, "required", "bucketId" | "dimension" | "value" | "requestsPerMinute" | "maxConcurrent" | "dailySpendLimitNanos" | "monthlySpendLimitNanos" | "lifetimeSpendLimitNanos" | "dailyTokenLimit" | "monthlyTokenLimit" | "lifetimeTokenLimit" | "blocked" | "warnAtPct" | "enforcement">, {
|
|
47
|
+
dim_value: ["dimension", "value", "_creationTime"];
|
|
48
|
+
}, {}, {}>;
|
|
16
49
|
buckets: import("convex/server").TableDefinition<import("convex/values").VObject<{
|
|
17
50
|
requestsPerMinute?: number | undefined;
|
|
18
51
|
maxConcurrent?: number | undefined;
|
|
@@ -140,6 +173,14 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
140
173
|
dimension: string;
|
|
141
174
|
value: string;
|
|
142
175
|
}[] | undefined;
|
|
176
|
+
heldBucketIds?: import("convex/values").GenericId<"buckets">[] | undefined;
|
|
177
|
+
reservationDay?: string | undefined;
|
|
178
|
+
reservationMonth?: string | undefined;
|
|
179
|
+
reservationReleased?: boolean | undefined;
|
|
180
|
+
reservationExpired?: boolean | undefined;
|
|
181
|
+
contentPurged?: boolean | undefined;
|
|
182
|
+
expiresAt?: number | undefined;
|
|
183
|
+
finishedAt?: number | undefined;
|
|
143
184
|
estimatedNanos?: number | undefined;
|
|
144
185
|
estimatedTokens?: number | undefined;
|
|
145
186
|
unpricedModel?: boolean | undefined;
|
|
@@ -176,6 +217,14 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
176
217
|
value: import("convex/values").VString<string, "required">;
|
|
177
218
|
}, "required", "dimension" | "value">, "optional">;
|
|
178
219
|
model: import("convex/values").VString<string, "required">;
|
|
220
|
+
heldBucketIds: import("convex/values").VArray<import("convex/values").GenericId<"buckets">[] | undefined, import("convex/values").VId<import("convex/values").GenericId<"buckets">, "required">, "optional">;
|
|
221
|
+
reservationDay: import("convex/values").VString<string | undefined, "optional">;
|
|
222
|
+
reservationMonth: import("convex/values").VString<string | undefined, "optional">;
|
|
223
|
+
reservationReleased: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
224
|
+
reservationExpired: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
225
|
+
contentPurged: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
226
|
+
expiresAt: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
227
|
+
finishedAt: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
179
228
|
estimatedNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
180
229
|
estimatedTokens: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
181
230
|
unpricedModel: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
@@ -202,9 +251,12 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
202
251
|
costNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
203
252
|
latencyMs: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
204
253
|
rerunOf: import("convex/values").VId<import("convex/values").GenericId<"requests"> | undefined, "optional">;
|
|
205
|
-
}, "required", "userId" | "actionName" | "tags" | "model" | "estimatedNanos" | "estimatedTokens" | "unpricedModel" | "overBudget" | "settled" | "messages" | "status" | "error" | "responseText" | "promptTokens" | "completionTokens" | "cachedTokens" | "serverToolUses" | "reserveTtlMs" | "costNanos" | "latencyMs" | "rerunOf" | `serverToolUses.${string}`>, {
|
|
254
|
+
}, "required", "userId" | "actionName" | "tags" | "model" | "heldBucketIds" | "reservationDay" | "reservationMonth" | "reservationReleased" | "reservationExpired" | "contentPurged" | "expiresAt" | "finishedAt" | "estimatedNanos" | "estimatedTokens" | "unpricedModel" | "overBudget" | "settled" | "messages" | "status" | "error" | "responseText" | "promptTokens" | "completionTokens" | "cachedTokens" | "serverToolUses" | "reserveTtlMs" | "costNanos" | "latencyMs" | "rerunOf" | `serverToolUses.${string}`>, {
|
|
206
255
|
userId: ["userId", "_creationTime"];
|
|
207
256
|
status: ["status", "_creationTime"];
|
|
257
|
+
status_expires: ["status", "expiresAt", "_creationTime"];
|
|
258
|
+
retention: ["reservationExpired", "settled", "_creationTime"];
|
|
259
|
+
expired_content: ["reservationExpired", "contentPurged", "_creationTime"];
|
|
208
260
|
rerunOf: ["rerunOf", "_creationTime"];
|
|
209
261
|
actionName: ["actionName", "_creationTime"];
|
|
210
262
|
settled: ["settled", "_creationTime"];
|
package/dist/component/schema.js
CHANGED
|
@@ -9,6 +9,25 @@ export const vMessage = v.object({
|
|
|
9
9
|
// are built-in dimensions; apps can add any others (team, project, env, …).
|
|
10
10
|
export const vTag = v.object({ dimension: v.string(), value: v.string() });
|
|
11
11
|
export default defineSchema({
|
|
12
|
+
bucketPolicies: defineTable({
|
|
13
|
+
bucketId: v.id("buckets"),
|
|
14
|
+
dimension: v.string(),
|
|
15
|
+
value: v.string(),
|
|
16
|
+
requestsPerMinute: v.optional(v.number()), // token-bucket refill per minute and burst capacity
|
|
17
|
+
maxConcurrent: v.optional(v.number()), // max in-flight (pending) requests
|
|
18
|
+
dailySpendLimitNanos: v.optional(v.number()),
|
|
19
|
+
monthlySpendLimitNanos: v.optional(v.number()),
|
|
20
|
+
lifetimeSpendLimitNanos: v.optional(v.number()),
|
|
21
|
+
dailyTokenLimit: v.optional(v.number()),
|
|
22
|
+
monthlyTokenLimit: v.optional(v.number()),
|
|
23
|
+
lifetimeTokenLimit: v.optional(v.number()),
|
|
24
|
+
blocked: v.optional(v.boolean()), // hard block (was `blocked`/`disabled`)
|
|
25
|
+
// Fire an approaching-limit alert once usage crosses this fraction of a cap
|
|
26
|
+
// (e.g. 0.8 = warn at 80%). Falls back to the deployment default.
|
|
27
|
+
warnAtPct: v.optional(v.number()),
|
|
28
|
+
// "hard" (default): exceeding a budget blocks. "soft": warn but allow.
|
|
29
|
+
enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
|
|
30
|
+
}).index("dim_value", ["dimension", "value"]),
|
|
12
31
|
// A budget holder, keyed by (dimension, value). Unifies what used to be the
|
|
13
32
|
// `users` and `actions` tables — those are just the "user" and "action"
|
|
14
33
|
// dimensions now. Any tag a request carries can have its own budget here.
|
|
@@ -16,7 +35,7 @@ export default defineSchema({
|
|
|
16
35
|
dimension: v.string(),
|
|
17
36
|
value: v.string(),
|
|
18
37
|
// limits (all optional — unlimited by default)
|
|
19
|
-
requestsPerMinute: v.optional(v.number()), //
|
|
38
|
+
requestsPerMinute: v.optional(v.number()), // token-bucket refill per minute and burst capacity
|
|
20
39
|
maxConcurrent: v.optional(v.number()), // max in-flight (pending) requests
|
|
21
40
|
dailySpendLimitNanos: v.optional(v.number()),
|
|
22
41
|
monthlySpendLimitNanos: v.optional(v.number()),
|
|
@@ -102,6 +121,14 @@ export default defineSchema({
|
|
|
102
121
|
tags: v.optional(v.array(vTag)),
|
|
103
122
|
model: v.string(),
|
|
104
123
|
// pessimistic holds placed at start; reconciled to actual on settle
|
|
124
|
+
heldBucketIds: v.optional(v.array(v.id("buckets"))),
|
|
125
|
+
reservationDay: v.optional(v.string()),
|
|
126
|
+
reservationMonth: v.optional(v.string()),
|
|
127
|
+
reservationReleased: v.optional(v.boolean()),
|
|
128
|
+
reservationExpired: v.optional(v.boolean()),
|
|
129
|
+
contentPurged: v.optional(v.boolean()),
|
|
130
|
+
expiresAt: v.optional(v.number()),
|
|
131
|
+
finishedAt: v.optional(v.number()),
|
|
105
132
|
estimatedNanos: v.optional(v.number()),
|
|
106
133
|
estimatedTokens: v.optional(v.number()),
|
|
107
134
|
// true when the model had no known/override price and was charged the
|
|
@@ -134,6 +161,9 @@ export default defineSchema({
|
|
|
134
161
|
})
|
|
135
162
|
.index("userId", ["userId"])
|
|
136
163
|
.index("status", ["status"])
|
|
164
|
+
.index("status_expires", ["status", "expiresAt"])
|
|
165
|
+
.index("retention", ["reservationExpired", "settled"])
|
|
166
|
+
.index("expired_content", ["reservationExpired", "contentPurged"])
|
|
137
167
|
.index("rerunOf", ["rerunOf"])
|
|
138
168
|
.index("actionName", ["actionName"])
|
|
139
169
|
.index("settled", ["settled"]),
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"email": "support@convex.dev",
|
|
8
8
|
"url": "https://github.com/get-convex/ai-budget/issues"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.0.2-alpha.
|
|
10
|
+
"version": "0.0.2-alpha.15",
|
|
11
11
|
"license": "Apache-2.0",
|
|
12
12
|
"type": "module",
|
|
13
13
|
"keywords": [
|
|
@@ -56,7 +56,8 @@
|
|
|
56
56
|
"types": "./dist/client/index.d.ts",
|
|
57
57
|
"module": "./dist/client/index.js",
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@convex-dev/sharded-counter": "^0.2.1"
|
|
59
|
+
"@convex-dev/sharded-counter": "^0.2.1",
|
|
60
|
+
"@convex-dev/rate-limiter": "0.4.0"
|
|
60
61
|
},
|
|
61
62
|
"peerDependencies": {
|
|
62
63
|
"@convex-dev/ai-sdk-provider": "*",
|