@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
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,12 +352,15 @@ 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
366
|
const requestId = await ctx.db.insert("requests", {
|
|
@@ -375,7 +414,7 @@ export const startRequest = mutation({
|
|
|
375
414
|
const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
|
|
376
415
|
const buckets = [];
|
|
377
416
|
for (const t of bucketTags) {
|
|
378
|
-
buckets.push(await
|
|
417
|
+
buckets.push(await admissionBucket(ctx, t.dimension, t.value));
|
|
379
418
|
}
|
|
380
419
|
// A hard block on ANY bucket rejects the request. The user dimension's block
|
|
381
420
|
// isn't persisted (retried in a loop); config-level blocks on other
|
|
@@ -394,44 +433,15 @@ export const startRequest = mutation({
|
|
|
394
433
|
return reject(`${b.dimension}_max_concurrent`, `Too many concurrent requests for ${b.dimension} "${b.value}" (max ${b.maxConcurrent})`, false);
|
|
395
434
|
}
|
|
396
435
|
}
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
// limit (plus a small allowance for persisted blocked attempts).
|
|
401
|
-
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.
|
|
402
439
|
for (const b of buckets) {
|
|
403
440
|
const limit = b.requestsPerMinute;
|
|
404
441
|
if (limit === undefined)
|
|
405
442
|
continue;
|
|
406
|
-
|
|
407
|
-
if (
|
|
408
|
-
const recent = await ctx.db
|
|
409
|
-
.query("requests")
|
|
410
|
-
.withIndex("userId", (q) => q.eq("userId", b.value).gt("_creationTime", rateCutoff))
|
|
411
|
-
.take(limit + 50);
|
|
412
|
-
recentCount = recent.filter((r) => r.status !== "blocked").length;
|
|
413
|
-
}
|
|
414
|
-
else if (b.dimension === ACTION_DIM) {
|
|
415
|
-
const recent = await ctx.db
|
|
416
|
-
.query("requests")
|
|
417
|
-
.withIndex("actionName", (q) => q.eq("actionName", b.value).gt("_creationTime", rateCutoff))
|
|
418
|
-
.take(limit + 50);
|
|
419
|
-
recentCount = recent.filter((r) => r.status !== "blocked").length;
|
|
420
|
-
}
|
|
421
|
-
else {
|
|
422
|
-
// Tag rows also cover persisted blocked attempts; fetch each request
|
|
423
|
-
// to exclude them, matching the user/action paths above.
|
|
424
|
-
const tagRows = await ctx.db
|
|
425
|
-
.query("requestTags")
|
|
426
|
-
.withIndex("dim_value", (q) => q
|
|
427
|
-
.eq("dimension", b.dimension)
|
|
428
|
-
.eq("value", b.value)
|
|
429
|
-
.gt("_creationTime", rateCutoff))
|
|
430
|
-
.take(limit + 50);
|
|
431
|
-
const recent = await Promise.all(tagRows.map((t) => ctx.db.get(t.requestId)));
|
|
432
|
-
recentCount = recent.filter((r) => r !== null && r.status !== "blocked").length;
|
|
433
|
-
}
|
|
434
|
-
if (recentCount >= limit) {
|
|
443
|
+
const ok = limit > 0 && (await requestRateLimiter.check(ctx, "requests", requestRateOptions(b))).ok;
|
|
444
|
+
if (!ok) {
|
|
435
445
|
const code = b.dimension === USER_DIM ? "rate_limit" : `${b.dimension}_rate_limit`;
|
|
436
446
|
return reject(code, `Rate limit exceeded for ${b.dimension} "${b.value}" (${limit}/min)`, false);
|
|
437
447
|
}
|
|
@@ -481,9 +491,8 @@ export const startRequest = mutation({
|
|
|
481
491
|
// committed + reserved + estimate <= cap. The ONE difference is the holder:
|
|
482
492
|
// per-bucket caps reserve on a single row (an atomic check-and-reserve),
|
|
483
493
|
// while the global holder is a sharded counter for
|
|
484
|
-
// throughput
|
|
485
|
-
//
|
|
486
|
-
// 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
|
|
487
496
|
// trade for a deployment-wide killswitch; it's the only approximate scope.
|
|
488
497
|
if (settings &&
|
|
489
498
|
(settings.globalDailySpendLimitNanos !== undefined ||
|
|
@@ -515,6 +524,17 @@ export const startRequest = mutation({
|
|
|
515
524
|
warnings.push(...globalEval.warnings);
|
|
516
525
|
notices.push(...globalEval.notices);
|
|
517
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
|
+
}
|
|
518
538
|
// Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
|
|
519
539
|
// uncapped bucket's row here would serialize every request that shares it
|
|
520
540
|
// (e.g. all callers of one action, or every request in one env); with no cap
|
|
@@ -550,6 +570,10 @@ export const startRequest = mutation({
|
|
|
550
570
|
rerunOf: args.rerunOf,
|
|
551
571
|
...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
|
|
552
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,
|
|
553
577
|
estimatedNanos: est.cost,
|
|
554
578
|
estimatedTokens: est.tokens,
|
|
555
579
|
...(priceInfo.known ? {} : { unpricedModel: true }),
|
|
@@ -590,14 +614,9 @@ export const finishRequest = mutation({
|
|
|
590
614
|
const request = await ctx.db.get(args.requestId);
|
|
591
615
|
if (!request)
|
|
592
616
|
throw new Error("Unknown request");
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
|
|
596
|
-
// already folded would be re-opened and folded a SECOND time when it
|
|
597
|
-
// finally completes: totals double-count and the reservation is released
|
|
598
|
-
// twice, dropping the reserved pool below reality and letting the atomic
|
|
599
|
-
// check-and-reserve admit requests it should block.
|
|
600
|
-
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) {
|
|
601
620
|
return { costNanos: request.costNanos ?? 0 };
|
|
602
621
|
}
|
|
603
622
|
// Clamp caller-supplied token counts: negatives would produce negative cost
|
|
@@ -622,6 +641,9 @@ export const finishRequest = mutation({
|
|
|
622
641
|
// orphaned in "pending" even if the totals update below fails and retries.
|
|
623
642
|
await ctx.db.patch(args.requestId, {
|
|
624
643
|
status: args.error ? "error" : "success",
|
|
644
|
+
reservationExpired: false,
|
|
645
|
+
expiresAt: undefined,
|
|
646
|
+
finishedAt: Date.now(),
|
|
625
647
|
responseText: args.responseText,
|
|
626
648
|
error: args.error,
|
|
627
649
|
promptTokens,
|
|
@@ -640,59 +662,68 @@ export const finishRequest = mutation({
|
|
|
640
662
|
return { costNanos };
|
|
641
663
|
},
|
|
642
664
|
});
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
|
|
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.
|
|
646
692
|
async function foldOne(ctx, req) {
|
|
647
693
|
if (!req || req.settled !== false)
|
|
648
694
|
return;
|
|
695
|
+
await releaseReservation(ctx, req);
|
|
649
696
|
const actual = req.costNanos ?? 0;
|
|
650
|
-
const estCost = req.estimatedNanos ?? 0;
|
|
651
697
|
const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
|
|
652
|
-
const
|
|
653
|
-
const
|
|
654
|
-
const month =
|
|
655
|
-
// Accrue into every attributed bucket (user, action, and each tag) — capped
|
|
656
|
-
// or not. Buckets that never held a reservation have their reserved fields
|
|
657
|
-
// clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
|
|
658
|
-
// Also write the durable per-(bucket, day/month) usage rows that survive
|
|
659
|
-
// 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);
|
|
660
701
|
for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
|
|
661
702
|
const b = await getOrCreateBucket(ctx, t.dimension, t.value);
|
|
662
|
-
|
|
663
|
-
|
|
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;
|
|
664
707
|
await ctx.db.patch(b._id, {
|
|
665
708
|
totalSpendNanos: b.totalSpendNanos + actual,
|
|
666
709
|
totalRequests: b.totalRequests + 1,
|
|
667
710
|
totalTokens: b.totalTokens + tokens,
|
|
668
|
-
dayStamp:
|
|
669
|
-
monthStamp:
|
|
670
|
-
spendTodayNanos: (
|
|
671
|
-
tokensToday: (
|
|
672
|
-
spendThisMonthNanos: (
|
|
673
|
-
tokensThisMonth: (
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
|
|
677
|
-
reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
|
|
678
|
-
reservedMonthTokens: Math.max(0, (sameMonth ? b.reservedMonthTokens ?? 0 : 0) - estTokens),
|
|
679
|
-
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
|
|
680
|
-
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 } : {}),
|
|
681
719
|
});
|
|
682
|
-
await addUsage(ctx, t.dimension, t.value, "day",
|
|
720
|
+
await addUsage(ctx, t.dimension, t.value, "day", day, actual, tokens, 1);
|
|
683
721
|
await addUsage(ctx, t.dimension, t.value, "month", month, actual, tokens, 1);
|
|
684
722
|
}
|
|
685
|
-
//
|
|
686
|
-
// configured — otherwise skip the writes entirely). Distributed across shards,
|
|
687
|
-
// so this does not serialize on a single row.
|
|
723
|
+
// Reporting is independent of whether enforcement is configured.
|
|
688
724
|
if (actual > 0) {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
(settings.globalDailySpendLimitNanos !== undefined ||
|
|
692
|
-
settings.globalLifetimeSpendLimitNanos !== undefined)) {
|
|
693
|
-
await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
|
|
694
|
-
await globalSpend.add(ctx, globalDayKey(today), actual);
|
|
695
|
-
}
|
|
725
|
+
await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
|
|
726
|
+
await globalSpend.add(ctx, globalDayKey(day), actual);
|
|
696
727
|
}
|
|
697
728
|
await ctx.db.patch(req._id, { settled: true });
|
|
698
729
|
}
|
|
@@ -721,46 +752,44 @@ export const reconcile = internalMutation({
|
|
|
721
752
|
.take(200);
|
|
722
753
|
for (const req of toFold)
|
|
723
754
|
await foldOne(ctx, req);
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
.take(200);
|
|
732
|
-
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);
|
|
733
762
|
for (const req of candidates) {
|
|
734
|
-
|
|
735
|
-
if (Date.now() - req._creationTime <= ttl)
|
|
736
|
-
continue; // still within its window
|
|
763
|
+
await releaseReservation(ctx, req);
|
|
737
764
|
await ctx.db.patch(req._id, {
|
|
738
|
-
status: "error",
|
|
739
|
-
|
|
740
|
-
costNanos: 0,
|
|
741
|
-
settled: false,
|
|
765
|
+
status: "error", error: "Reservation expired; awaiting final usage",
|
|
766
|
+
reservationExpired: true, expiresAt: undefined, settled: true,
|
|
742
767
|
});
|
|
743
|
-
await foldOne(ctx, await ctx.db.get(req._id));
|
|
744
|
-
expired++;
|
|
745
768
|
}
|
|
769
|
+
const expired = candidates.length;
|
|
746
770
|
// Retention: delete terminal, fully-accounted request rows past the window.
|
|
747
771
|
const settings = await getSettings(ctx);
|
|
748
772
|
const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
749
773
|
let purged = 0;
|
|
750
774
|
if (retentionMs > 0) {
|
|
751
775
|
const retentionCutoff = Date.now() - retentionMs;
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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));
|
|
756
784
|
for (const req of old) {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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 });
|
|
764
793
|
}
|
|
765
794
|
}
|
|
766
795
|
return { folded: toFold.length, expired, purged };
|
|
@@ -908,9 +937,14 @@ export const setBucketLimits = mutation({
|
|
|
908
937
|
},
|
|
909
938
|
returns: v.null(),
|
|
910
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
|
+
}
|
|
911
944
|
const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
|
|
912
945
|
const { dimension: _d, value: _v, ...limits } = args;
|
|
913
946
|
await ctx.db.patch(bucket._id, limits);
|
|
947
|
+
await syncPolicy(ctx, (await ctx.db.get(bucket._id)));
|
|
914
948
|
return null;
|
|
915
949
|
},
|
|
916
950
|
});
|
|
@@ -1041,13 +1075,23 @@ export const deleteBucket = mutation({
|
|
|
1041
1075
|
return { deletedThisBatch: rows.length, done: false };
|
|
1042
1076
|
}
|
|
1043
1077
|
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
1044
|
-
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 });
|
|
1045
1083
|
await ctx.db.delete(bucket._id);
|
|
1084
|
+
}
|
|
1046
1085
|
return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
|
|
1047
1086
|
}
|
|
1048
1087
|
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
1049
|
-
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 });
|
|
1050
1093
|
await ctx.db.delete(bucket._id);
|
|
1094
|
+
}
|
|
1051
1095
|
return { deletedThisBatch: bucket ? 1 : 0, done: true };
|
|
1052
1096
|
},
|
|
1053
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.16",
|
|
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": "*",
|