@convex-dev/ai-budget 0.0.2-alpha.13 → 0.0.2-alpha.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -10
- package/dist/client/index.d.ts +33 -0
- package/dist/client/index.js +1 -1
- package/dist/component/_generated/api.d.ts +1 -0
- package/dist/component/convex.config.js +2 -0
- package/dist/component/lib.d.ts +32 -0
- package/dist/component/lib.js +176 -119
- package/dist/component/schema.d.ts +53 -1
- package/dist/component/schema.js +31 -1
- package/package.json +3 -2
- package/src/client/index.ts +2 -1
- package/src/client/webhook.test.ts +32 -0
- package/src/component/_generated/api.ts +1 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/lib.test.ts +289 -27
- package/src/component/lib.ts +187 -134
- package/src/component/schema.ts +31 -1
package/src/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,15 +470,18 @@ 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
|
-
await ctx.db.insert("requests", {
|
|
484
|
+
const requestId = await ctx.db.insert("requests", {
|
|
444
485
|
userId: args.userId,
|
|
445
486
|
actionName: args.actionName,
|
|
446
487
|
...(extraTags.length ? { tags: extraTags } : {}),
|
|
@@ -450,6 +491,15 @@ export const startRequest = mutation({
|
|
|
450
491
|
status: "blocked" as const,
|
|
451
492
|
error: reason,
|
|
452
493
|
});
|
|
494
|
+
// Reverse-index the blocked attempt too, so tag-filtered request logs
|
|
495
|
+
// show rejections alongside admitted traffic.
|
|
496
|
+
for (const t of extraTags) {
|
|
497
|
+
await ctx.db.insert("requestTags", {
|
|
498
|
+
dimension: t.dimension,
|
|
499
|
+
value: t.value,
|
|
500
|
+
requestId,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
453
503
|
}
|
|
454
504
|
return { allowed: false as const, code, reason };
|
|
455
505
|
};
|
|
@@ -488,7 +538,7 @@ export const startRequest = mutation({
|
|
|
488
538
|
const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
|
|
489
539
|
const buckets: Doc<"buckets">[] = [];
|
|
490
540
|
for (const t of bucketTags) {
|
|
491
|
-
buckets.push(await
|
|
541
|
+
buckets.push(await admissionBucket(ctx, t.dimension, t.value));
|
|
492
542
|
}
|
|
493
543
|
|
|
494
544
|
// A hard block on ANY bucket rejects the request. The user dimension's block
|
|
@@ -518,47 +568,16 @@ export const startRequest = mutation({
|
|
|
518
568
|
}
|
|
519
569
|
}
|
|
520
570
|
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
// limit (plus a small allowance for persisted blocked attempts).
|
|
525
|
-
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.
|
|
526
574
|
for (const b of buckets) {
|
|
527
575
|
const limit = b.requestsPerMinute;
|
|
528
576
|
if (limit === undefined) continue;
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
.query("requests")
|
|
534
|
-
.withIndex("userId", (q) =>
|
|
535
|
-
q.eq("userId", b.value).gt("_creationTime", rateCutoff)
|
|
536
|
-
)
|
|
537
|
-
.take(limit + 50);
|
|
538
|
-
recentCount = recent.filter((r) => r.status !== "blocked").length;
|
|
539
|
-
} else if (b.dimension === ACTION_DIM) {
|
|
540
|
-
const recent = await ctx.db
|
|
541
|
-
.query("requests")
|
|
542
|
-
.withIndex("actionName", (q) =>
|
|
543
|
-
q.eq("actionName", b.value).gt("_creationTime", rateCutoff)
|
|
544
|
-
)
|
|
545
|
-
.take(limit + 50);
|
|
546
|
-
recentCount = recent.filter((r) => r.status !== "blocked").length;
|
|
547
|
-
} else {
|
|
548
|
-
recentCount = (
|
|
549
|
-
await ctx.db
|
|
550
|
-
.query("requestTags")
|
|
551
|
-
.withIndex("dim_value", (q) =>
|
|
552
|
-
q
|
|
553
|
-
.eq("dimension", b.dimension)
|
|
554
|
-
.eq("value", b.value)
|
|
555
|
-
.gt("_creationTime", rateCutoff)
|
|
556
|
-
)
|
|
557
|
-
.take(limit)
|
|
558
|
-
).length;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
if (recentCount >= limit) {
|
|
577
|
+
const ok = limit > 0 && (await requestRateLimiter.check(
|
|
578
|
+
ctx, "requests", requestRateOptions(b)
|
|
579
|
+
)).ok;
|
|
580
|
+
if (!ok) {
|
|
562
581
|
const code = b.dimension === USER_DIM ? "rate_limit" : `${b.dimension}_rate_limit`;
|
|
563
582
|
return reject(
|
|
564
583
|
code,
|
|
@@ -622,9 +641,8 @@ export const startRequest = mutation({
|
|
|
622
641
|
// committed + reserved + estimate <= cap. The ONE difference is the holder:
|
|
623
642
|
// per-bucket caps reserve on a single row (an atomic check-and-reserve),
|
|
624
643
|
// while the global holder is a sharded counter for
|
|
625
|
-
// throughput
|
|
626
|
-
//
|
|
627
|
-
// 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
|
|
628
646
|
// trade for a deployment-wide killswitch; it's the only approximate scope.
|
|
629
647
|
if (
|
|
630
648
|
settings &&
|
|
@@ -664,6 +682,18 @@ export const startRequest = mutation({
|
|
|
664
682
|
notices.push(...globalEval.notices);
|
|
665
683
|
}
|
|
666
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
|
+
|
|
667
697
|
// Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
|
|
668
698
|
// uncapped bucket's row here would serialize every request that shares it
|
|
669
699
|
// (e.g. all callers of one action, or every request in one env); with no cap
|
|
@@ -698,6 +728,10 @@ export const startRequest = mutation({
|
|
|
698
728
|
rerunOf: args.rerunOf,
|
|
699
729
|
...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
|
|
700
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,
|
|
701
735
|
estimatedNanos: est.cost,
|
|
702
736
|
estimatedTokens: est.tokens,
|
|
703
737
|
...(priceInfo.known ? {} : { unpricedModel: true }),
|
|
@@ -739,14 +773,9 @@ export const finishRequest = mutation({
|
|
|
739
773
|
const request = await ctx.db.get(args.requestId);
|
|
740
774
|
if (!request) throw new Error("Unknown request");
|
|
741
775
|
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
|
|
745
|
-
// already folded would be re-opened and folded a SECOND time when it
|
|
746
|
-
// finally completes: totals double-count and the reservation is released
|
|
747
|
-
// twice, dropping the reserved pool below reality and letting the atomic
|
|
748
|
-
// check-and-reserve admit requests it should block.
|
|
749
|
-
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) {
|
|
750
779
|
return { costNanos: request.costNanos ?? 0 };
|
|
751
780
|
}
|
|
752
781
|
|
|
@@ -777,6 +806,9 @@ export const finishRequest = mutation({
|
|
|
777
806
|
// orphaned in "pending" even if the totals update below fails and retries.
|
|
778
807
|
await ctx.db.patch(args.requestId, {
|
|
779
808
|
status: args.error ? "error" : "success",
|
|
809
|
+
reservationExpired: false,
|
|
810
|
+
expiresAt: undefined,
|
|
811
|
+
finishedAt: Date.now(),
|
|
780
812
|
responseText: args.responseText,
|
|
781
813
|
error: args.error,
|
|
782
814
|
promptTokens,
|
|
@@ -797,62 +829,66 @@ export const finishRequest = mutation({
|
|
|
797
829
|
},
|
|
798
830
|
});
|
|
799
831
|
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
|
|
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.
|
|
803
858
|
async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
|
|
804
859
|
if (!req || req.settled !== false) return;
|
|
860
|
+
await releaseReservation(ctx, req);
|
|
805
861
|
const actual = req.costNanos ?? 0;
|
|
806
|
-
const estCost = req.estimatedNanos ?? 0;
|
|
807
862
|
const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
|
|
808
|
-
const
|
|
809
|
-
const
|
|
810
|
-
const month =
|
|
811
|
-
|
|
812
|
-
// Accrue into every attributed bucket (user, action, and each tag) — capped
|
|
813
|
-
// or not. Buckets that never held a reservation have their reserved fields
|
|
814
|
-
// clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
|
|
815
|
-
// Also write the durable per-(bucket, day/month) usage rows that survive
|
|
816
|
-
// 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);
|
|
817
866
|
for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
|
|
818
867
|
const b = await getOrCreateBucket(ctx, t.dimension, t.value);
|
|
819
|
-
|
|
820
|
-
|
|
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;
|
|
821
872
|
await ctx.db.patch(b._id, {
|
|
822
873
|
totalSpendNanos: b.totalSpendNanos + actual,
|
|
823
874
|
totalRequests: b.totalRequests + 1,
|
|
824
875
|
totalTokens: b.totalTokens + tokens,
|
|
825
|
-
dayStamp:
|
|
826
|
-
monthStamp:
|
|
827
|
-
spendTodayNanos: (
|
|
828
|
-
tokensToday: (
|
|
829
|
-
spendThisMonthNanos: (
|
|
830
|
-
tokensThisMonth: (
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
|
|
834
|
-
reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
|
|
835
|
-
reservedMonthTokens: Math.max(0, (sameMonth ? b.reservedMonthTokens ?? 0 : 0) - estTokens),
|
|
836
|
-
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
|
|
837
|
-
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 } : {}),
|
|
838
884
|
});
|
|
839
|
-
await addUsage(ctx, t.dimension, t.value, "day",
|
|
885
|
+
await addUsage(ctx, t.dimension, t.value, "day", day, actual, tokens, 1);
|
|
840
886
|
await addUsage(ctx, t.dimension, t.value, "month", month, actual, tokens, 1);
|
|
841
887
|
}
|
|
842
|
-
|
|
843
|
-
// Deployment-wide totals via the sharded counter (only when a global cap is
|
|
844
|
-
// configured — otherwise skip the writes entirely). Distributed across shards,
|
|
845
|
-
// so this does not serialize on a single row.
|
|
888
|
+
// Reporting is independent of whether enforcement is configured.
|
|
846
889
|
if (actual > 0) {
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
settings &&
|
|
850
|
-
(settings.globalDailySpendLimitNanos !== undefined ||
|
|
851
|
-
settings.globalLifetimeSpendLimitNanos !== undefined)
|
|
852
|
-
) {
|
|
853
|
-
await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
|
|
854
|
-
await globalSpend.add(ctx, globalDayKey(today), actual);
|
|
855
|
-
}
|
|
890
|
+
await globalSpend.add(ctx, GLOBAL_TOTAL, actual);
|
|
891
|
+
await globalSpend.add(ctx, globalDayKey(day), actual);
|
|
856
892
|
}
|
|
857
893
|
await ctx.db.patch(req._id, { settled: true });
|
|
858
894
|
}
|
|
@@ -883,29 +919,23 @@ export const reconcile = internalMutation({
|
|
|
883
919
|
.take(200);
|
|
884
920
|
for (const req of toFold) await foldOne(ctx, req);
|
|
885
921
|
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
const
|
|
891
|
-
.
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
)
|
|
895
|
-
.take(200);
|
|
896
|
-
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);
|
|
897
931
|
for (const req of candidates) {
|
|
898
|
-
|
|
899
|
-
if (Date.now() - req._creationTime <= ttl) continue; // still within its window
|
|
932
|
+
await releaseReservation(ctx, req);
|
|
900
933
|
await ctx.db.patch(req._id, {
|
|
901
|
-
status: "error",
|
|
902
|
-
|
|
903
|
-
costNanos: 0,
|
|
904
|
-
settled: false,
|
|
934
|
+
status: "error", error: "Reservation expired; awaiting final usage",
|
|
935
|
+
reservationExpired: true, expiresAt: undefined, settled: true,
|
|
905
936
|
});
|
|
906
|
-
await foldOne(ctx, await ctx.db.get(req._id));
|
|
907
|
-
expired++;
|
|
908
937
|
}
|
|
938
|
+
const expired = candidates.length;
|
|
909
939
|
|
|
910
940
|
// Retention: delete terminal, fully-accounted request rows past the window.
|
|
911
941
|
const settings = await getSettings(ctx);
|
|
@@ -913,20 +943,26 @@ export const reconcile = internalMutation({
|
|
|
913
943
|
let purged = 0;
|
|
914
944
|
if (retentionMs > 0) {
|
|
915
945
|
const retentionCutoff = Date.now() - retentionMs;
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
)
|
|
921
|
-
|
|
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));
|
|
922
956
|
for (const req of old) {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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 });
|
|
930
966
|
}
|
|
931
967
|
}
|
|
932
968
|
return { folded: toFold.length, expired, purged };
|
|
@@ -1079,9 +1115,14 @@ export const setBucketLimits = mutation({
|
|
|
1079
1115
|
},
|
|
1080
1116
|
returns: v.null(),
|
|
1081
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
|
+
}
|
|
1082
1122
|
const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
|
|
1083
1123
|
const { dimension: _d, value: _v, ...limits } = args;
|
|
1084
1124
|
await ctx.db.patch(bucket._id, limits);
|
|
1125
|
+
await syncPolicy(ctx, (await ctx.db.get(bucket._id))!);
|
|
1085
1126
|
return null;
|
|
1086
1127
|
},
|
|
1087
1128
|
});
|
|
@@ -1223,11 +1264,23 @@ export const deleteBucket = mutation({
|
|
|
1223
1264
|
return { deletedThisBatch: rows.length, done: false };
|
|
1224
1265
|
}
|
|
1225
1266
|
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
1226
|
-
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
|
+
}
|
|
1227
1274
|
return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
|
|
1228
1275
|
}
|
|
1229
1276
|
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
1230
|
-
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
|
+
}
|
|
1231
1284
|
return { deletedThisBatch: bucket ? 1 : 0, done: true };
|
|
1232
1285
|
},
|
|
1233
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"]),
|