@convex-dev/ai-budget 0.0.2-alpha.14 → 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 +44 -8
- 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 +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 +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 +246 -29
- package/src/component/lib.ts +177 -135
- package/src/component/schema.ts +31 -1
package/src/component/lib.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { api, internal, components } from "./_generated/api";
|
|
9
9
|
import { vMessage, vTag } from "./schema";
|
|
10
10
|
import type { Doc } from "./_generated/dataModel";
|
|
11
|
+
import { RateLimiter, MINUTE } from "@convex-dev/rate-limiter";
|
|
11
12
|
import { ShardedCounter } from "@convex-dev/sharded-counter";
|
|
12
13
|
|
|
13
14
|
// All money is integer **nanodollars** (1 USD = 1e9 nano). Integers avoid the
|
|
@@ -26,6 +27,17 @@ const fmtUsd = (nanos: number) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
|
|
|
26
27
|
const USER_DIM = "user";
|
|
27
28
|
const ACTION_DIM = "action";
|
|
28
29
|
|
|
30
|
+
const requestRateLimiter = new RateLimiter(components.rateLimiter);
|
|
31
|
+
const requestRateOptions = (bucket: Doc<"buckets">) => ({
|
|
32
|
+
key: bucket._id,
|
|
33
|
+
config: {
|
|
34
|
+
kind: "token bucket" as const,
|
|
35
|
+
rate: bucket.requestsPerMinute!,
|
|
36
|
+
capacity: bucket.requestsPerMinute!,
|
|
37
|
+
period: MINUTE,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
29
41
|
// Deployment-wide spend totals (nanodollars), sharded for high write throughput.
|
|
30
42
|
// Keyed "total" (lifetime) and "day:<UTC date>" (natural daily reset).
|
|
31
43
|
const globalSpend = new ShardedCounter(components.shardedCounter);
|
|
@@ -56,12 +68,8 @@ const DEFAULT_SERVER_TOOL_PRICES: Record<string, number> = {
|
|
|
56
68
|
// concurrent admission atomic against the estimate; a response that exceeds the
|
|
57
69
|
// estimate can still settle above the cap by the estimation delta.
|
|
58
70
|
const ESTIMATED_OUTPUT_TOKENS = 800;
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// above any real call duration — a long reasoning/agent generation that is
|
|
62
|
-
// still billing must not be swept and mis-recorded as free. A late
|
|
63
|
-
// finishRequest is a no-op once swept (see finishRequest's terminal guard), so
|
|
64
|
-
// the only cost of a generous timeout is a briefly-held reservation.
|
|
71
|
+
// Expiry releases a hold without declaring final usage. A late provider
|
|
72
|
+
// completion can still record its authoritative charge exactly once.
|
|
65
73
|
const STALE_PENDING_MS = 30 * 60 * 1000;
|
|
66
74
|
// Default retention for request rows (full prompts + responses). Terminal,
|
|
67
75
|
// fully-accounted rows older than this are swept by the reconciler. Keeps the
|
|
@@ -379,6 +387,36 @@ async function getOrCreateBucket(
|
|
|
379
387
|
return (await ctx.db.get(id))!;
|
|
380
388
|
}
|
|
381
389
|
|
|
390
|
+
// Policy is read on every admission; reporting writes never touch it.
|
|
391
|
+
async function syncPolicy(ctx: MutationCtx, b: Doc<"buckets">) {
|
|
392
|
+
const existing = await ctx.db.query("bucketPolicies").withIndex("dim_value", q =>
|
|
393
|
+
q.eq("dimension", b.dimension).eq("value", b.value)).unique();
|
|
394
|
+
const policy = {
|
|
395
|
+
bucketId: b._id, dimension: b.dimension, value: b.value,
|
|
396
|
+
requestsPerMinute: b.requestsPerMinute, maxConcurrent: b.maxConcurrent,
|
|
397
|
+
dailySpendLimitNanos: b.dailySpendLimitNanos, monthlySpendLimitNanos: b.monthlySpendLimitNanos,
|
|
398
|
+
lifetimeSpendLimitNanos: b.lifetimeSpendLimitNanos, dailyTokenLimit: b.dailyTokenLimit,
|
|
399
|
+
monthlyTokenLimit: b.monthlyTokenLimit, lifetimeTokenLimit: b.lifetimeTokenLimit,
|
|
400
|
+
blocked: b.blocked, warnAtPct: b.warnAtPct, enforcement: b.enforcement,
|
|
401
|
+
};
|
|
402
|
+
if (existing) await ctx.db.replace(existing._id, policy);
|
|
403
|
+
else await ctx.db.insert("bucketPolicies", policy);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function admissionBucket(ctx: MutationCtx, dimension: string, value: string): Promise<Doc<"buckets">> {
|
|
407
|
+
const policy = await ctx.db.query("bucketPolicies").withIndex("dim_value", q =>
|
|
408
|
+
q.eq("dimension", dimension).eq("value", value)).unique();
|
|
409
|
+
if (!policy) {
|
|
410
|
+
const bucket = await getOrCreateBucket(ctx, dimension, value);
|
|
411
|
+
await syncPolicy(ctx, bucket);
|
|
412
|
+
return bucket;
|
|
413
|
+
}
|
|
414
|
+
// Only capped buckets need an atomic read of their accounting state.
|
|
415
|
+
if (needsReserve(policy)) return (await ctx.db.get(policy.bucketId))!;
|
|
416
|
+
return { ...policy, _id: policy.bucketId, totalSpendNanos: 0, totalRequests: 0,
|
|
417
|
+
totalTokens: 0, dayStamp: "", spendTodayNanos: 0 };
|
|
418
|
+
}
|
|
419
|
+
|
|
382
420
|
async function getSettings(ctx: MutationCtx) {
|
|
383
421
|
return await ctx.db
|
|
384
422
|
.query("settings")
|
|
@@ -432,12 +470,15 @@ export const startRequest = mutation({
|
|
|
432
470
|
},
|
|
433
471
|
returns: vStartResult,
|
|
434
472
|
handler: async (ctx, args) => {
|
|
473
|
+
if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
|
|
474
|
+
throw new Error("reserveTtlMs must be finite and nonnegative");
|
|
475
|
+
}
|
|
435
476
|
const extraTags = sanitizeExtraTags(args.tags);
|
|
436
477
|
// Record the blocked attempt and return a rejection (throwing would roll
|
|
437
478
|
// back the record). `persist` is false for the high-frequency-by-design
|
|
438
479
|
// rejections (rate limit, blocked user) that a client retries in a tight
|
|
439
480
|
// loop — persisting those would grow the requests table without bound and
|
|
440
|
-
//
|
|
481
|
+
// add audit-log writes to every transient rejection.
|
|
441
482
|
const reject = async (code: string, reason: string, persist = true) => {
|
|
442
483
|
if (persist) {
|
|
443
484
|
const requestId = await ctx.db.insert("requests", {
|
|
@@ -497,7 +538,7 @@ export const startRequest = mutation({
|
|
|
497
538
|
const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
|
|
498
539
|
const buckets: Doc<"buckets">[] = [];
|
|
499
540
|
for (const t of bucketTags) {
|
|
500
|
-
buckets.push(await
|
|
541
|
+
buckets.push(await admissionBucket(ctx, t.dimension, t.value));
|
|
501
542
|
}
|
|
502
543
|
|
|
503
544
|
// A hard block on ANY bucket rejects the request. The user dimension's block
|
|
@@ -527,49 +568,16 @@ export const startRequest = mutation({
|
|
|
527
568
|
}
|
|
528
569
|
}
|
|
529
570
|
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
// limit (plus a small allowance for persisted blocked attempts).
|
|
534
|
-
const rateCutoff = Date.now() - 60_000;
|
|
571
|
+
// Check all rates before consuming any. These transactional reads remain
|
|
572
|
+
// in admission's read set, so concurrent requests cannot spend the same
|
|
573
|
+
// capacity. Budget rejections below leave every rate balance untouched.
|
|
535
574
|
for (const b of buckets) {
|
|
536
575
|
const limit = b.requestsPerMinute;
|
|
537
576
|
if (limit === undefined) continue;
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
.query("requests")
|
|
543
|
-
.withIndex("userId", (q) =>
|
|
544
|
-
q.eq("userId", b.value).gt("_creationTime", rateCutoff)
|
|
545
|
-
)
|
|
546
|
-
.take(limit + 50);
|
|
547
|
-
recentCount = recent.filter((r) => r.status !== "blocked").length;
|
|
548
|
-
} else if (b.dimension === ACTION_DIM) {
|
|
549
|
-
const recent = await ctx.db
|
|
550
|
-
.query("requests")
|
|
551
|
-
.withIndex("actionName", (q) =>
|
|
552
|
-
q.eq("actionName", b.value).gt("_creationTime", rateCutoff)
|
|
553
|
-
)
|
|
554
|
-
.take(limit + 50);
|
|
555
|
-
recentCount = recent.filter((r) => r.status !== "blocked").length;
|
|
556
|
-
} else {
|
|
557
|
-
// Tag rows also cover persisted blocked attempts; fetch each request
|
|
558
|
-
// to exclude them, matching the user/action paths above.
|
|
559
|
-
const tagRows = await ctx.db
|
|
560
|
-
.query("requestTags")
|
|
561
|
-
.withIndex("dim_value", (q) =>
|
|
562
|
-
q
|
|
563
|
-
.eq("dimension", b.dimension)
|
|
564
|
-
.eq("value", b.value)
|
|
565
|
-
.gt("_creationTime", rateCutoff)
|
|
566
|
-
)
|
|
567
|
-
.take(limit + 50);
|
|
568
|
-
const recent = await Promise.all(tagRows.map((t) => ctx.db.get(t.requestId)));
|
|
569
|
-
recentCount = recent.filter((r) => r !== null && r.status !== "blocked").length;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
if (recentCount >= limit) {
|
|
577
|
+
const ok = limit > 0 && (await requestRateLimiter.check(
|
|
578
|
+
ctx, "requests", requestRateOptions(b)
|
|
579
|
+
)).ok;
|
|
580
|
+
if (!ok) {
|
|
573
581
|
const code = b.dimension === USER_DIM ? "rate_limit" : `${b.dimension}_rate_limit`;
|
|
574
582
|
return reject(
|
|
575
583
|
code,
|
|
@@ -633,9 +641,8 @@ export const startRequest = mutation({
|
|
|
633
641
|
// committed + reserved + estimate <= cap. The ONE difference is the holder:
|
|
634
642
|
// per-bucket caps reserve on a single row (an atomic check-and-reserve),
|
|
635
643
|
// while the global holder is a sharded counter for
|
|
636
|
-
// throughput
|
|
637
|
-
//
|
|
638
|
-
// bounded amount under burst. That's the deliberate consistency/throughput
|
|
644
|
+
// throughput. The sum is transactional, but excludes unsettled usage and
|
|
645
|
+
// has no cross-request reservation, so a hard global cap can overshoot. That's the deliberate consistency/throughput
|
|
639
646
|
// trade for a deployment-wide killswitch; it's the only approximate scope.
|
|
640
647
|
if (
|
|
641
648
|
settings &&
|
|
@@ -675,6 +682,18 @@ export const startRequest = mutation({
|
|
|
675
682
|
notices.push(...globalEval.notices);
|
|
676
683
|
}
|
|
677
684
|
|
|
685
|
+
// Consume only after every admission check succeeds, in the same
|
|
686
|
+
// transaction as the reservations and request insert. An unexpected
|
|
687
|
+
// rejection throws to roll back all previously consumed dimensions.
|
|
688
|
+
for (const b of buckets) {
|
|
689
|
+
if (b.requestsPerMinute !== undefined) {
|
|
690
|
+
await requestRateLimiter.limit(ctx, "requests", {
|
|
691
|
+
...requestRateOptions(b),
|
|
692
|
+
throws: true,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
678
697
|
// Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
|
|
679
698
|
// uncapped bucket's row here would serialize every request that shares it
|
|
680
699
|
// (e.g. all callers of one action, or every request in one env); with no cap
|
|
@@ -709,6 +728,10 @@ export const startRequest = mutation({
|
|
|
709
728
|
rerunOf: args.rerunOf,
|
|
710
729
|
...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
|
|
711
730
|
status: "pending",
|
|
731
|
+
expiresAt: Date.now() + Math.max(STALE_PENDING_MS, args.reserveTtlMs ?? 0),
|
|
732
|
+
heldBucketIds: buckets.filter(needsReserve).map(b => b._id),
|
|
733
|
+
reservationDay: today,
|
|
734
|
+
reservationMonth: month,
|
|
712
735
|
estimatedNanos: est.cost,
|
|
713
736
|
estimatedTokens: est.tokens,
|
|
714
737
|
...(priceInfo.known ? {} : { unpricedModel: true }),
|
|
@@ -750,14 +773,9 @@ export const finishRequest = mutation({
|
|
|
750
773
|
const request = await ctx.db.get(args.requestId);
|
|
751
774
|
if (!request) throw new Error("Unknown request");
|
|
752
775
|
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
// already folded would be re-opened and folded a SECOND time when it
|
|
757
|
-
// finally completes: totals double-count and the reservation is released
|
|
758
|
-
// twice, dropping the reserved pool below reality and letting the atomic
|
|
759
|
-
// check-and-reserve admit requests it should block.
|
|
760
|
-
if (request.status !== "pending") {
|
|
776
|
+
// Expiry releases capacity, but is not evidence that the provider charged
|
|
777
|
+
// nothing. Accept one final result even after expiry; duplicates stay no-ops.
|
|
778
|
+
if (request.status !== "pending" && !request.reservationExpired) {
|
|
761
779
|
return { costNanos: request.costNanos ?? 0 };
|
|
762
780
|
}
|
|
763
781
|
|
|
@@ -788,6 +806,9 @@ export const finishRequest = mutation({
|
|
|
788
806
|
// orphaned in "pending" even if the totals update below fails and retries.
|
|
789
807
|
await ctx.db.patch(args.requestId, {
|
|
790
808
|
status: args.error ? "error" : "success",
|
|
809
|
+
reservationExpired: false,
|
|
810
|
+
expiresAt: undefined,
|
|
811
|
+
finishedAt: Date.now(),
|
|
791
812
|
responseText: args.responseText,
|
|
792
813
|
error: args.error,
|
|
793
814
|
promptTokens,
|
|
@@ -808,62 +829,66 @@ export const finishRequest = mutation({
|
|
|
808
829
|
},
|
|
809
830
|
});
|
|
810
831
|
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
|
|
832
|
+
// Release only holds owned by this request, and only from their original
|
|
833
|
+
// calendar windows. A previous day's completion must not debit today's holds.
|
|
834
|
+
async function releaseReservation(ctx: MutationCtx, req: Doc<"requests">) {
|
|
835
|
+
if (req.reservationReleased) return;
|
|
836
|
+
const day = req.reservationDay ?? new Date(req._creationTime).toISOString().slice(0, 10);
|
|
837
|
+
const month = req.reservationMonth ?? day.slice(0, 7);
|
|
838
|
+
const cost = req.estimatedNanos ?? 0;
|
|
839
|
+
const tokens = req.estimatedTokens ?? 0;
|
|
840
|
+
for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
|
|
841
|
+
const b = await getBucketDoc(ctx, t.dimension, t.value);
|
|
842
|
+
if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b))) continue;
|
|
843
|
+
await ctx.db.patch(b._id, {
|
|
844
|
+
reservedTodayNanos: Math.max(0, (b.reservedTodayNanos ?? 0) - (b.dayStamp === day ? cost : 0)),
|
|
845
|
+
reservedMonthNanos: Math.max(0, (b.reservedMonthNanos ?? 0) - (b.monthStamp === month ? cost : 0)),
|
|
846
|
+
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - cost),
|
|
847
|
+
reservedTodayTokens: Math.max(0, (b.reservedTodayTokens ?? 0) - (b.dayStamp === day ? tokens : 0)),
|
|
848
|
+
reservedMonthTokens: Math.max(0, (b.reservedMonthTokens ?? 0) - (b.monthStamp === month ? tokens : 0)),
|
|
849
|
+
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - tokens),
|
|
850
|
+
pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
await ctx.db.patch(req._id, { reservationReleased: true });
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Final billing is folded once. Reservation release has its own guard because
|
|
857
|
+
// expiry can precede the final charge by hours or days.
|
|
814
858
|
async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
|
|
815
859
|
if (!req || req.settled !== false) return;
|
|
860
|
+
await releaseReservation(ctx, req);
|
|
816
861
|
const actual = req.costNanos ?? 0;
|
|
817
|
-
const estCost = req.estimatedNanos ?? 0;
|
|
818
862
|
const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
|
|
819
|
-
const
|
|
820
|
-
const
|
|
821
|
-
const month =
|
|
822
|
-
|
|
823
|
-
// Accrue into every attributed bucket (user, action, and each tag) — capped
|
|
824
|
-
// or not. Buckets that never held a reservation have their reserved fields
|
|
825
|
-
// clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
|
|
826
|
-
// Also write the durable per-(bucket, day/month) usage rows that survive
|
|
827
|
-
// request retention, so spend history outlives the raw request log.
|
|
863
|
+
const timestamp = new Date(req.finishedAt ?? Date.now()).toISOString();
|
|
864
|
+
const day = timestamp.slice(0, 10);
|
|
865
|
+
const month = timestamp.slice(0, 7);
|
|
828
866
|
for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
|
|
829
867
|
const b = await getOrCreateBucket(ctx, t.dimension, t.value);
|
|
830
|
-
|
|
831
|
-
|
|
868
|
+
// Keep the newest reporting window and clear obsolete window holds when
|
|
869
|
+
// advancing it. Lifetime holds persist until their owner releases them.
|
|
870
|
+
const targetDay = b.dayStamp > day ? b.dayStamp : day;
|
|
871
|
+
const targetMonth = (b.monthStamp ?? "") > month ? b.monthStamp! : month;
|
|
832
872
|
await ctx.db.patch(b._id, {
|
|
833
873
|
totalSpendNanos: b.totalSpendNanos + actual,
|
|
834
874
|
totalRequests: b.totalRequests + 1,
|
|
835
875
|
totalTokens: b.totalTokens + tokens,
|
|
836
|
-
dayStamp:
|
|
837
|
-
monthStamp:
|
|
838
|
-
spendTodayNanos: (
|
|
839
|
-
tokensToday: (
|
|
840
|
-
spendThisMonthNanos: (
|
|
841
|
-
tokensThisMonth: (
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
|
|
845
|
-
reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
|
|
846
|
-
reservedMonthTokens: Math.max(0, (sameMonth ? b.reservedMonthTokens ?? 0 : 0) - estTokens),
|
|
847
|
-
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
|
|
848
|
-
pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
|
|
876
|
+
dayStamp: targetDay,
|
|
877
|
+
monthStamp: targetMonth,
|
|
878
|
+
spendTodayNanos: (b.dayStamp === targetDay ? b.spendTodayNanos : 0) + (day === targetDay ? actual : 0),
|
|
879
|
+
tokensToday: (b.dayStamp === targetDay ? b.tokensToday ?? 0 : 0) + (day === targetDay ? tokens : 0),
|
|
880
|
+
spendThisMonthNanos: (b.monthStamp === targetMonth ? b.spendThisMonthNanos ?? 0 : 0) + (month === targetMonth ? actual : 0),
|
|
881
|
+
tokensThisMonth: (b.monthStamp === targetMonth ? b.tokensThisMonth ?? 0 : 0) + (month === targetMonth ? tokens : 0),
|
|
882
|
+
...(b.dayStamp !== targetDay ? { reservedTodayNanos: 0, reservedTodayTokens: 0 } : {}),
|
|
883
|
+
...(b.monthStamp !== targetMonth ? { reservedMonthNanos: 0, reservedMonthTokens: 0 } : {}),
|
|
849
884
|
});
|
|
850
|
-
await addUsage(ctx, t.dimension, t.value, "day",
|
|
885
|
+
await addUsage(ctx, t.dimension, t.value, "day", day, actual, tokens, 1);
|
|
851
886
|
await addUsage(ctx, t.dimension, t.value, "month", month, actual, tokens, 1);
|
|
852
887
|
}
|
|
853
|
-
|
|
854
|
-
// Deployment-wide totals via the sharded counter (only when a global cap is
|
|
855
|
-
// configured — otherwise skip the writes entirely). Distributed across shards,
|
|
856
|
-
// so this does not serialize on a single row.
|
|
888
|
+
// Reporting is independent of whether enforcement is configured.
|
|
857
889
|
if (actual > 0) {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
settings &&
|
|
861
|
-
(settings.globalDailySpendLimitNanos !== undefined ||
|
|
862
|
-
settings.globalLifetimeSpendLimitNanos !== undefined)
|
|
863
|
-
) {
|
|
864
|
-
await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
|
|
865
|
-
await globalSpend.add(ctx, globalDayKey(today), actual);
|
|
866
|
-
}
|
|
890
|
+
await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
|
|
891
|
+
await globalSpend.add(ctx, globalDayKey(day), actual);
|
|
867
892
|
}
|
|
868
893
|
await ctx.db.patch(req._id, { settled: true });
|
|
869
894
|
}
|
|
@@ -894,29 +919,23 @@ export const reconcile = internalMutation({
|
|
|
894
919
|
.take(200);
|
|
895
920
|
for (const req of toFold) await foldOne(ctx, req);
|
|
896
921
|
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
const
|
|
902
|
-
.
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
)
|
|
906
|
-
.take(200);
|
|
907
|
-
let expired = 0;
|
|
922
|
+
// Lazily migrate old pending rows in bounded batches. Once indexed, long
|
|
923
|
+
// TTL jobs cannot hide expired jobs behind them in creation-time order.
|
|
924
|
+
const legacy = await ctx.db.query("requests").withIndex("status_expires", q =>
|
|
925
|
+
q.eq("status", "pending").eq("expiresAt", undefined)).take(200);
|
|
926
|
+
for (const req of legacy) {
|
|
927
|
+
await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
|
|
928
|
+
}
|
|
929
|
+
const candidates = await ctx.db.query("requests").withIndex("status_expires", q =>
|
|
930
|
+
q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(200);
|
|
908
931
|
for (const req of candidates) {
|
|
909
|
-
|
|
910
|
-
if (Date.now() - req._creationTime <= ttl) continue; // still within its window
|
|
932
|
+
await releaseReservation(ctx, req);
|
|
911
933
|
await ctx.db.patch(req._id, {
|
|
912
|
-
status: "error",
|
|
913
|
-
|
|
914
|
-
costNanos: 0,
|
|
915
|
-
settled: false,
|
|
934
|
+
status: "error", error: "Reservation expired; awaiting final usage",
|
|
935
|
+
reservationExpired: true, expiresAt: undefined, settled: true,
|
|
916
936
|
});
|
|
917
|
-
await foldOne(ctx, await ctx.db.get(req._id));
|
|
918
|
-
expired++;
|
|
919
937
|
}
|
|
938
|
+
const expired = candidates.length;
|
|
920
939
|
|
|
921
940
|
// Retention: delete terminal, fully-accounted request rows past the window.
|
|
922
941
|
const settings = await getSettings(ctx);
|
|
@@ -924,20 +943,26 @@ export const reconcile = internalMutation({
|
|
|
924
943
|
let purged = 0;
|
|
925
944
|
if (retentionMs > 0) {
|
|
926
945
|
const retentionCutoff = Date.now() - retentionMs;
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
)
|
|
932
|
-
|
|
946
|
+
// Query eligible states directly. Long-lived pending jobs and expired
|
|
947
|
+
// billing tombstones must not repeatedly occupy the front of a scan.
|
|
948
|
+
const old = [];
|
|
949
|
+
for (const expiredFlag of [undefined, false]) {
|
|
950
|
+
old.push(...await ctx.db.query("requests").withIndex("retention", q =>
|
|
951
|
+
q.eq("reservationExpired", expiredFlag).eq("settled", true)
|
|
952
|
+
.lt("_creationTime", retentionCutoff)).take(200));
|
|
953
|
+
}
|
|
954
|
+
old.push(...await ctx.db.query("requests").withIndex("status", q =>
|
|
955
|
+
q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
|
|
933
956
|
for (const req of old) {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
957
|
+
await deleteRequestTags(ctx, req._id);
|
|
958
|
+
await ctx.db.delete(req._id);
|
|
959
|
+
purged++;
|
|
960
|
+
}
|
|
961
|
+
const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q =>
|
|
962
|
+
q.eq("reservationExpired", true).eq("contentPurged", undefined)
|
|
963
|
+
.lt("_creationTime", retentionCutoff)).take(200);
|
|
964
|
+
for (const req of expiredContent) {
|
|
965
|
+
await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
|
|
941
966
|
}
|
|
942
967
|
}
|
|
943
968
|
return { folded: toFold.length, expired, purged };
|
|
@@ -1090,9 +1115,14 @@ export const setBucketLimits = mutation({
|
|
|
1090
1115
|
},
|
|
1091
1116
|
returns: v.null(),
|
|
1092
1117
|
handler: async (ctx, args) => {
|
|
1118
|
+
if (args.requestsPerMinute !== undefined &&
|
|
1119
|
+
(!Number.isSafeInteger(args.requestsPerMinute) || args.requestsPerMinute < 0)) {
|
|
1120
|
+
throw new Error("requestsPerMinute must be a nonnegative safe integer");
|
|
1121
|
+
}
|
|
1093
1122
|
const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
|
|
1094
1123
|
const { dimension: _d, value: _v, ...limits } = args;
|
|
1095
1124
|
await ctx.db.patch(bucket._id, limits);
|
|
1125
|
+
await syncPolicy(ctx, (await ctx.db.get(bucket._id))!);
|
|
1096
1126
|
return null;
|
|
1097
1127
|
},
|
|
1098
1128
|
});
|
|
@@ -1234,11 +1264,23 @@ export const deleteBucket = mutation({
|
|
|
1234
1264
|
return { deletedThisBatch: rows.length, done: false };
|
|
1235
1265
|
}
|
|
1236
1266
|
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
1237
|
-
if (bucket)
|
|
1267
|
+
if (bucket) {
|
|
1268
|
+
const policy = await ctx.db.query("bucketPolicies").withIndex("dim_value", q =>
|
|
1269
|
+
q.eq("dimension", dimension).eq("value", value)).unique();
|
|
1270
|
+
if (policy) await ctx.db.delete(policy._id);
|
|
1271
|
+
await requestRateLimiter.reset(ctx, "requests", { key: bucket._id });
|
|
1272
|
+
await ctx.db.delete(bucket._id);
|
|
1273
|
+
}
|
|
1238
1274
|
return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
|
|
1239
1275
|
}
|
|
1240
1276
|
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
1241
|
-
if (bucket)
|
|
1277
|
+
if (bucket) {
|
|
1278
|
+
const policy = await ctx.db.query("bucketPolicies").withIndex("dim_value", q =>
|
|
1279
|
+
q.eq("dimension", dimension).eq("value", value)).unique();
|
|
1280
|
+
if (policy) await ctx.db.delete(policy._id);
|
|
1281
|
+
await requestRateLimiter.reset(ctx, "requests", { key: bucket._id });
|
|
1282
|
+
await ctx.db.delete(bucket._id);
|
|
1283
|
+
}
|
|
1242
1284
|
return { deletedThisBatch: bucket ? 1 : 0, done: true };
|
|
1243
1285
|
},
|
|
1244
1286
|
});
|
package/src/component/schema.ts
CHANGED
|
@@ -12,6 +12,25 @@ export const vMessage = v.object({
|
|
|
12
12
|
export const vTag = v.object({ dimension: v.string(), value: v.string() });
|
|
13
13
|
|
|
14
14
|
export default defineSchema({
|
|
15
|
+
bucketPolicies: defineTable({
|
|
16
|
+
bucketId: v.id("buckets"),
|
|
17
|
+
dimension: v.string(),
|
|
18
|
+
value: v.string(),
|
|
19
|
+
requestsPerMinute: v.optional(v.number()), // token-bucket refill per minute and burst capacity
|
|
20
|
+
maxConcurrent: v.optional(v.number()), // max in-flight (pending) requests
|
|
21
|
+
dailySpendLimitNanos: v.optional(v.number()),
|
|
22
|
+
monthlySpendLimitNanos: v.optional(v.number()),
|
|
23
|
+
lifetimeSpendLimitNanos: v.optional(v.number()),
|
|
24
|
+
dailyTokenLimit: v.optional(v.number()),
|
|
25
|
+
monthlyTokenLimit: v.optional(v.number()),
|
|
26
|
+
lifetimeTokenLimit: v.optional(v.number()),
|
|
27
|
+
blocked: v.optional(v.boolean()), // hard block (was `blocked`/`disabled`)
|
|
28
|
+
// Fire an approaching-limit alert once usage crosses this fraction of a cap
|
|
29
|
+
// (e.g. 0.8 = warn at 80%). Falls back to the deployment default.
|
|
30
|
+
warnAtPct: v.optional(v.number()),
|
|
31
|
+
// "hard" (default): exceeding a budget blocks. "soft": warn but allow.
|
|
32
|
+
enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
|
|
33
|
+
}).index("dim_value", ["dimension", "value"]),
|
|
15
34
|
// A budget holder, keyed by (dimension, value). Unifies what used to be the
|
|
16
35
|
// `users` and `actions` tables — those are just the "user" and "action"
|
|
17
36
|
// dimensions now. Any tag a request carries can have its own budget here.
|
|
@@ -19,7 +38,7 @@ export default defineSchema({
|
|
|
19
38
|
dimension: v.string(),
|
|
20
39
|
value: v.string(),
|
|
21
40
|
// limits (all optional — unlimited by default)
|
|
22
|
-
requestsPerMinute: v.optional(v.number()), //
|
|
41
|
+
requestsPerMinute: v.optional(v.number()), // token-bucket refill per minute and burst capacity
|
|
23
42
|
maxConcurrent: v.optional(v.number()), // max in-flight (pending) requests
|
|
24
43
|
dailySpendLimitNanos: v.optional(v.number()),
|
|
25
44
|
monthlySpendLimitNanos: v.optional(v.number()),
|
|
@@ -109,6 +128,14 @@ export default defineSchema({
|
|
|
109
128
|
tags: v.optional(v.array(vTag)),
|
|
110
129
|
model: v.string(),
|
|
111
130
|
// pessimistic holds placed at start; reconciled to actual on settle
|
|
131
|
+
heldBucketIds: v.optional(v.array(v.id("buckets"))),
|
|
132
|
+
reservationDay: v.optional(v.string()),
|
|
133
|
+
reservationMonth: v.optional(v.string()),
|
|
134
|
+
reservationReleased: v.optional(v.boolean()),
|
|
135
|
+
reservationExpired: v.optional(v.boolean()),
|
|
136
|
+
contentPurged: v.optional(v.boolean()),
|
|
137
|
+
expiresAt: v.optional(v.number()),
|
|
138
|
+
finishedAt: v.optional(v.number()),
|
|
112
139
|
estimatedNanos: v.optional(v.number()),
|
|
113
140
|
estimatedTokens: v.optional(v.number()),
|
|
114
141
|
// true when the model had no known/override price and was charged the
|
|
@@ -146,6 +173,9 @@ export default defineSchema({
|
|
|
146
173
|
})
|
|
147
174
|
.index("userId", ["userId"])
|
|
148
175
|
.index("status", ["status"])
|
|
176
|
+
.index("status_expires", ["status", "expiresAt"])
|
|
177
|
+
.index("retention", ["reservationExpired", "settled"])
|
|
178
|
+
.index("expired_content", ["reservationExpired", "contentPurged"])
|
|
149
179
|
.index("rerunOf", ["rerunOf"])
|
|
150
180
|
.index("actionName", ["actionName"])
|
|
151
181
|
.index("settled", ["settled"]),
|