@convex-dev/ai-budget 0.0.2-alpha.17 → 0.0.2-alpha.19
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 +73 -13
- package/dist/client/index.d.ts +38 -5
- package/dist/client/index.js +56 -8
- package/dist/component/_generated/component.d.ts +8 -4
- package/dist/component/lib.d.ts +19 -3
- package/dist/component/lib.js +233 -80
- package/dist/component/schema.d.ts +20 -4
- package/dist/component/schema.js +28 -2
- package/package.json +1 -1
- package/src/client/index.ts +65 -15
- package/src/component/_generated/component.ts +11 -4
- package/src/component/lib.test.ts +109 -9
- package/src/component/lib.ts +249 -88
- package/src/component/schema.ts +28 -2
package/src/component/lib.ts
CHANGED
|
@@ -46,6 +46,10 @@ function assertFraction(n: number | undefined, name: string) {
|
|
|
46
46
|
// mapping NaN/Infinity/garbage to 0 rather than poisoning downstream totals.
|
|
47
47
|
const safeCount = (n: number | undefined) =>
|
|
48
48
|
Number.isFinite(n) ? Math.max(0, Math.floor(n as number)) : 0;
|
|
49
|
+
// Treat a non-finite stored accounting field as 0, so a bucket that was poisoned
|
|
50
|
+
// before validation existed self-heals on its next reserve/release instead of
|
|
51
|
+
// staying NaN forever.
|
|
52
|
+
const fin = (n: number | undefined) => (Number.isFinite(n) ? (n as number) : 0);
|
|
49
53
|
|
|
50
54
|
// Built-in attribution dimensions. `user` and `action` are always populated
|
|
51
55
|
// from a request's userId/actionName; apps can add any other dimensions
|
|
@@ -103,6 +107,16 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
|
|
|
103
107
|
// audit table — and the sensitive content in it — from growing without bound.
|
|
104
108
|
// Override per-deployment via setRetention.
|
|
105
109
|
const DEFAULT_RETENTION_MS = 60 * 60 * 1000; // 1 hour
|
|
110
|
+
// Reconciliation runs as small, independently-rescheduling phases. Keeping the
|
|
111
|
+
// batch small bounds the bytes read per transaction (each request row can carry
|
|
112
|
+
// prompts/responses) so a burst can't push one phase past Convex's 8 MiB / 16k-
|
|
113
|
+
// doc read limit and stall the whole reconciler. A phase that fills its batch
|
|
114
|
+
// reschedules itself immediately, so throughput still scales with backlog.
|
|
115
|
+
const RECONCILE_BATCH = 50;
|
|
116
|
+
// Keep an expired billing tombstone (content already purged) this long so a very
|
|
117
|
+
// late provider charge can still land against it, then delete it. A finish after
|
|
118
|
+
// deletion is a graceful no-op.
|
|
119
|
+
const LATE_SETTLE_HORIZON_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
106
120
|
|
|
107
121
|
const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
|
|
108
122
|
const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
|
|
@@ -118,12 +132,17 @@ const CACHE_DISCOUNT = 0.1;
|
|
|
118
132
|
// bills real money. Charging the conservative max instead keeps the caps honest
|
|
119
133
|
// (over-counting is the safe direction); admins can pin an exact price via
|
|
120
134
|
// setPrice, which also clears the `unpricedModel` flag on future requests.
|
|
135
|
+
// Seed with a true frontier ceiling ($20/$100 per Mtok), not just the max of the
|
|
136
|
+
// small built-in table — otherwise premium models (Opus-class $15/$75, etc.) not
|
|
137
|
+
// in the table would be under-counted several-fold whenever the gateway's
|
|
138
|
+
// authoritative cost isn't available. Over-counting an unpriced model is the safe
|
|
139
|
+
// direction; admins pin the exact rate with setPrice.
|
|
121
140
|
const CONSERVATIVE_PRICE = Object.values(DEFAULT_PRICES).reduce(
|
|
122
141
|
(m, p) => ({
|
|
123
142
|
input: Math.max(m.input, p.input),
|
|
124
143
|
output: Math.max(m.output, p.output),
|
|
125
144
|
}),
|
|
126
|
-
{ input:
|
|
145
|
+
{ input: 20_000_000_000, output: 100_000_000_000 }
|
|
127
146
|
);
|
|
128
147
|
|
|
129
148
|
async function getPrice(ctx: MutationCtx, model: string) {
|
|
@@ -297,7 +316,8 @@ function sanitizeExtraTags(
|
|
|
297
316
|
function evaluateCaps(o: {
|
|
298
317
|
label: string;
|
|
299
318
|
name: string;
|
|
300
|
-
|
|
319
|
+
// "approximate" is the global killswitch mode; it blocks like "hard" here.
|
|
320
|
+
enforcement: "hard" | "soft" | "approximate";
|
|
301
321
|
warnAtPct?: number;
|
|
302
322
|
estCost: number;
|
|
303
323
|
estTokens: number;
|
|
@@ -307,6 +327,10 @@ function evaluateCaps(o: {
|
|
|
307
327
|
reservedSpendMonth: number;
|
|
308
328
|
totalSpend: number;
|
|
309
329
|
reservedSpendTotal: number;
|
|
330
|
+
// Manual credits (subtracted from spend so a credit grants headroom).
|
|
331
|
+
creditsToday?: number;
|
|
332
|
+
creditsThisMonth?: number;
|
|
333
|
+
creditsTotal?: number;
|
|
310
334
|
tokensToday: number;
|
|
311
335
|
reservedTokensToday: number;
|
|
312
336
|
tokensThisMonth: number;
|
|
@@ -327,9 +351,9 @@ function evaluateCaps(o: {
|
|
|
327
351
|
// { code, projected usage (incl. this estimate), cap, human window label,
|
|
328
352
|
// whether it's a money cap (formatted as $), spend? for notices }
|
|
329
353
|
const checks = [
|
|
330
|
-
{ w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost, cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
|
|
331
|
-
{ w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost, cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
|
|
332
|
-
{ w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost, cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
|
|
354
|
+
{ w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost - (o.creditsToday ?? 0), cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
|
|
355
|
+
{ w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost - (o.creditsThisMonth ?? 0), cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
|
|
356
|
+
{ w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost - (o.creditsTotal ?? 0), cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
|
|
333
357
|
{ w: "daily_token_limit", used: o.tokensToday + o.reservedTokensToday + o.estTokens, cap: o.dailyTokenLimit, label: "daily token limit", money: false },
|
|
334
358
|
{ w: "monthly_token_limit", used: o.tokensThisMonth + o.reservedTokensMonth + o.estTokens, cap: o.monthlyTokenLimit, label: "monthly token limit", money: false },
|
|
335
359
|
{ w: "lifetime_token_limit", used: o.totalTokens + o.reservedTokensTotal + o.estTokens, cap: o.lifetimeTokenLimit, label: "lifetime token limit", money: false },
|
|
@@ -500,7 +524,15 @@ export const startRequest = mutation({
|
|
|
500
524
|
if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
|
|
501
525
|
throw new Error("reserveTtlMs must be finite and nonnegative");
|
|
502
526
|
}
|
|
527
|
+
// Infinity/NaN here is catastrophic: it's reserved onto the bucket, and a
|
|
528
|
+
// later release computes `Infinity - Infinity = NaN`, leaving the reserved
|
|
529
|
+
// fields NaN forever — after which every `used > cap` check is `NaN > cap`
|
|
530
|
+
// (false) and the bucket admits unlimited spend. Reject it up front.
|
|
531
|
+
assertAmount(args.estimatedCostNanos, "estimatedCostNanos");
|
|
503
532
|
const extraTags = sanitizeExtraTags(args.tags);
|
|
533
|
+
// Set from settings below; when false we persist metadata but no prompt
|
|
534
|
+
// content (a deployment that opts out of storing prompts entirely).
|
|
535
|
+
let storeContent = true;
|
|
504
536
|
// Record the blocked attempt and return a rejection (throwing would roll
|
|
505
537
|
// back the record). `persist` is false for the high-frequency-by-design
|
|
506
538
|
// rejections (rate limit, blocked user) that a client retries in a tight
|
|
@@ -513,7 +545,7 @@ export const startRequest = mutation({
|
|
|
513
545
|
actionName: args.actionName,
|
|
514
546
|
...(extraTags.length ? { tags: extraTags } : {}),
|
|
515
547
|
model: args.model,
|
|
516
|
-
messages: args.messages,
|
|
548
|
+
messages: storeContent ? args.messages : [],
|
|
517
549
|
rerunOf: args.rerunOf,
|
|
518
550
|
status: "blocked" as const,
|
|
519
551
|
error: reason,
|
|
@@ -545,6 +577,7 @@ export const startRequest = mutation({
|
|
|
545
577
|
|
|
546
578
|
// Model allow/deny policy (component-wide).
|
|
547
579
|
const settings = await getSettings(ctx);
|
|
580
|
+
storeContent = settings?.storeContent !== false;
|
|
548
581
|
const defaultWarnAtPct = settings?.defaultWarnAtPct;
|
|
549
582
|
if (settings) {
|
|
550
583
|
const mode = settings.modelMode ?? "open";
|
|
@@ -558,6 +591,14 @@ export const startRequest = mutation({
|
|
|
558
591
|
if (mode === "denylist" && list.includes(args.model)) {
|
|
559
592
|
return reject("model_denied", `Model "${args.model}" is denied`);
|
|
560
593
|
}
|
|
594
|
+
// Optionally refuse models with no known/override price rather than
|
|
595
|
+
// charging the conservative fallback (which under-counts premium models).
|
|
596
|
+
if (settings.allowUnpricedModels === false && !priceInfo.known) {
|
|
597
|
+
return reject(
|
|
598
|
+
"model_unpriced",
|
|
599
|
+
`Model "${args.model}" has no configured price; set one with setPrice or allow unpriced models`
|
|
600
|
+
);
|
|
601
|
+
}
|
|
561
602
|
}
|
|
562
603
|
|
|
563
604
|
// Fetch/create every bucket this request is attributed to (user, action,
|
|
@@ -636,6 +677,9 @@ export const startRequest = mutation({
|
|
|
636
677
|
reservedSpendMonth: sameMonth ? b.reservedMonthNanos ?? 0 : 0,
|
|
637
678
|
totalSpend: b.totalSpendNanos,
|
|
638
679
|
reservedSpendTotal: b.reservedTotalNanos ?? 0,
|
|
680
|
+
creditsToday: sameDay ? b.creditsTodayNanos ?? 0 : 0,
|
|
681
|
+
creditsThisMonth: sameMonth ? b.creditsThisMonthNanos ?? 0 : 0,
|
|
682
|
+
creditsTotal: b.creditsNanos ?? 0,
|
|
639
683
|
tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
|
|
640
684
|
reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
|
|
641
685
|
tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
|
|
@@ -671,42 +715,26 @@ export const startRequest = mutation({
|
|
|
671
715
|
// throughput. The sum is transactional, but excludes unsettled usage and
|
|
672
716
|
// has no cross-request reservation, so a hard global cap can overshoot. That's the deliberate consistency/throughput
|
|
673
717
|
// trade for a deployment-wide killswitch; it's the only approximate scope.
|
|
718
|
+
// H4: don't read the sharded counter here — that per-admission read
|
|
719
|
+
// contended with every fold that writes it. The reconciler compares the
|
|
720
|
+
// total to the cap out-of-band and records trip flags on `settings`;
|
|
721
|
+
// admission just reads those (already-loaded) flags. The killswitch lag is
|
|
722
|
+
// bounded by the reconcile interval, which is the point of "approximate".
|
|
674
723
|
if (
|
|
675
724
|
settings &&
|
|
676
|
-
(settings.
|
|
677
|
-
settings.globalLifetimeSpendLimitNanos !== undefined)
|
|
725
|
+
(settings.globalTrippedDaily || settings.globalTrippedLifetime)
|
|
678
726
|
) {
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
totalSpend: await globalSpend.count(ctx, GLOBAL_TOTAL),
|
|
691
|
-
reservedSpendTotal: 0,
|
|
692
|
-
tokensToday: 0,
|
|
693
|
-
reservedTokensToday: 0,
|
|
694
|
-
tokensThisMonth: 0,
|
|
695
|
-
reservedTokensMonth: 0,
|
|
696
|
-
totalTokens: 0,
|
|
697
|
-
reservedTokensTotal: 0,
|
|
698
|
-
dailySpendLimitNanos: withBump(
|
|
699
|
-
settings.globalDailySpendLimitNanos,
|
|
700
|
-
settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0
|
|
701
|
-
),
|
|
702
|
-
lifetimeSpendLimitNanos: withBump(
|
|
703
|
-
settings.globalLifetimeSpendLimitNanos,
|
|
704
|
-
settings.globalLifetimeBumpNanos
|
|
705
|
-
),
|
|
706
|
-
});
|
|
707
|
-
if (globalEval.hard) return reject(globalEval.hard.code, globalEval.hard.reason);
|
|
708
|
-
warnings.push(...globalEval.warnings);
|
|
709
|
-
notices.push(...globalEval.notices);
|
|
727
|
+
const enforcement = settings.globalEnforcement ?? "approximate";
|
|
728
|
+
if (enforcement === "soft") {
|
|
729
|
+
warnings.push(`Global spend limit reached for the deployment (allowed — soft)`);
|
|
730
|
+
} else {
|
|
731
|
+
return reject(
|
|
732
|
+
"global_spend_limit",
|
|
733
|
+
"Global spend limit reached for the deployment"
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
} else if (settings?.globalNearLimit) {
|
|
737
|
+
notices.push("Deployment approaching its global spend limit");
|
|
710
738
|
}
|
|
711
739
|
|
|
712
740
|
// Consume only after every admission check succeeds, in the same
|
|
@@ -757,7 +785,7 @@ export const startRequest = mutation({
|
|
|
757
785
|
actionName: args.actionName,
|
|
758
786
|
...(extraTags.length ? { tags: extraTags } : {}),
|
|
759
787
|
model: args.model,
|
|
760
|
-
messages: args.messages,
|
|
788
|
+
messages: storeContent ? args.messages : [],
|
|
761
789
|
rerunOf: args.rerunOf,
|
|
762
790
|
...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
|
|
763
791
|
status: "pending",
|
|
@@ -825,18 +853,27 @@ export const finishRequest = mutation({
|
|
|
825
853
|
// tool fees); otherwise price from tokens — discounting the cached
|
|
826
854
|
// (prompt-cache-read) slice — plus any server-tool per-call fees. Require it
|
|
827
855
|
// finite: +Infinity passes a bare `>= 0` and would corrupt the totals.
|
|
856
|
+
const settings = await getSettings(ctx);
|
|
857
|
+
const storeContent = settings?.storeContent !== false;
|
|
828
858
|
let costNanos: number;
|
|
829
859
|
if (args.costNanos !== undefined && Number.isFinite(args.costNanos) && args.costNanos >= 0) {
|
|
830
860
|
costNanos = Math.round(args.costNanos);
|
|
831
861
|
} else {
|
|
832
|
-
const
|
|
833
|
-
costNanos =
|
|
862
|
+
const priced =
|
|
834
863
|
settleCost(
|
|
835
864
|
promptTokens,
|
|
836
865
|
cachedTokens,
|
|
837
866
|
completionTokens,
|
|
838
867
|
await getPrice(ctx, request.model)
|
|
839
868
|
) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
|
|
869
|
+
// Fail closed: if the settle carried NO usable cost signal — no tokens and
|
|
870
|
+
// no priced server-tool fee (an unpriced tool like `video_seconds`, or a
|
|
871
|
+
// bare settle()) — fall back to the reserved estimate rather than recording
|
|
872
|
+
// $0. Known-cost calls (image/video/audio) set estimatedCostNanos at
|
|
873
|
+
// reserve time precisely so this floor is their real cost. A caller that
|
|
874
|
+
// truly wants $0 passes an explicit authoritative costNanos: 0 above.
|
|
875
|
+
const noSignal = promptTokens === 0 && completionTokens === 0 && priced === 0;
|
|
876
|
+
costNanos = noSignal ? (request.estimatedNanos ?? 0) : priced;
|
|
840
877
|
}
|
|
841
878
|
|
|
842
879
|
// Durable write to the request's OWN row only — uncontended, so it always
|
|
@@ -847,7 +884,7 @@ export const finishRequest = mutation({
|
|
|
847
884
|
reservationExpired: false,
|
|
848
885
|
expiresAt: undefined,
|
|
849
886
|
finishedAt: Date.now(),
|
|
850
|
-
responseText: args.responseText,
|
|
887
|
+
responseText: storeContent ? args.responseText : undefined,
|
|
851
888
|
error: args.error,
|
|
852
889
|
promptTokens,
|
|
853
890
|
completionTokens,
|
|
@@ -879,13 +916,13 @@ async function releaseReservation(ctx: MutationCtx, req: Doc<"requests">) {
|
|
|
879
916
|
const b = await getBucketDoc(ctx, t.dimension, t.value);
|
|
880
917
|
if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b))) continue;
|
|
881
918
|
await ctx.db.patch(b._id, {
|
|
882
|
-
reservedTodayNanos: Math.max(0, (b.reservedTodayNanos
|
|
883
|
-
reservedMonthNanos: Math.max(0, (b.reservedMonthNanos
|
|
884
|
-
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos
|
|
885
|
-
reservedTodayTokens: Math.max(0, (b.reservedTodayTokens
|
|
886
|
-
reservedMonthTokens: Math.max(0, (b.reservedMonthTokens
|
|
887
|
-
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens
|
|
888
|
-
pendingCount: Math.max(0, (b.pendingCount
|
|
919
|
+
reservedTodayNanos: Math.max(0, fin(b.reservedTodayNanos) - (b.dayStamp === day ? cost : 0)),
|
|
920
|
+
reservedMonthNanos: Math.max(0, fin(b.reservedMonthNanos) - (b.monthStamp === month ? cost : 0)),
|
|
921
|
+
reservedTotalNanos: Math.max(0, fin(b.reservedTotalNanos) - cost),
|
|
922
|
+
reservedTodayTokens: Math.max(0, fin(b.reservedTodayTokens) - (b.dayStamp === day ? tokens : 0)),
|
|
923
|
+
reservedMonthTokens: Math.max(0, fin(b.reservedMonthTokens) - (b.monthStamp === month ? tokens : 0)),
|
|
924
|
+
reservedTotalTokens: Math.max(0, fin(b.reservedTotalTokens) - tokens),
|
|
925
|
+
pendingCount: Math.max(0, fin(b.pendingCount) - 1),
|
|
889
926
|
});
|
|
890
927
|
}
|
|
891
928
|
await ctx.db.patch(req._id, { reservationReleased: true });
|
|
@@ -943,29 +980,98 @@ export const foldTotals = internalMutation({
|
|
|
943
980
|
// Backstop for both failure modes: folds finished requests whose scheduled fold
|
|
944
981
|
// lost the retry race, and releases reservations for requests that never
|
|
945
982
|
// settled (their action crashed). Runs on a cron.
|
|
983
|
+
// Cron entry: kick off each reconciliation phase as its OWN transaction so a
|
|
984
|
+
// failure in one (e.g. an oversized retention scan) can't stall the others, and
|
|
985
|
+
// so the hot fold path doesn't share a transaction with retention. Each phase
|
|
986
|
+
// self-reschedules while it has a full batch of backlog.
|
|
946
987
|
export const reconcile = internalMutation({
|
|
947
988
|
args: {},
|
|
948
|
-
returns: v.
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
989
|
+
returns: v.null(),
|
|
990
|
+
handler: async (ctx) => {
|
|
991
|
+
await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
|
|
992
|
+
await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
|
|
993
|
+
await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
|
|
994
|
+
await ctx.scheduler.runAfter(0, internal.lib.globalPhase, {});
|
|
995
|
+
return null;
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// H4: compute the deployment-wide global-cap trip flags out-of-band so admission
|
|
1000
|
+
// never reads the sharded counter on its hot path. One cheap counter read here
|
|
1001
|
+
// per interval; admission then consults the flags on `settings`.
|
|
1002
|
+
export const globalPhase = internalMutation({
|
|
1003
|
+
args: {},
|
|
1004
|
+
returns: v.null(),
|
|
1005
|
+
handler: async (ctx) => {
|
|
1006
|
+
const s = await getSettings(ctx);
|
|
1007
|
+
if (!s) return null;
|
|
1008
|
+
const hasCap =
|
|
1009
|
+
s.globalDailySpendLimitNanos !== undefined ||
|
|
1010
|
+
s.globalLifetimeSpendLimitNanos !== undefined;
|
|
1011
|
+
if (!hasCap) {
|
|
1012
|
+
// Clear stale flags when no global cap is configured.
|
|
1013
|
+
if (s.globalTrippedDaily || s.globalTrippedLifetime || s.globalNearLimit)
|
|
1014
|
+
await ctx.db.patch(s._id, {
|
|
1015
|
+
globalTrippedDaily: false,
|
|
1016
|
+
globalTrippedLifetime: false,
|
|
1017
|
+
globalNearLimit: false,
|
|
1018
|
+
});
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
const today = dayStamp();
|
|
1022
|
+
const dailyCap = withBump(
|
|
1023
|
+
s.globalDailySpendLimitNanos,
|
|
1024
|
+
s.globalBumpDayStamp === today ? s.globalDailyBumpNanos : 0
|
|
1025
|
+
);
|
|
1026
|
+
const lifetimeCap = withBump(
|
|
1027
|
+
s.globalLifetimeSpendLimitNanos,
|
|
1028
|
+
s.globalLifetimeBumpNanos
|
|
1029
|
+
);
|
|
1030
|
+
const spentToday = await globalSpend.count(ctx, globalDayKey(today));
|
|
1031
|
+
const spentTotal = await globalSpend.count(ctx, GLOBAL_TOTAL);
|
|
1032
|
+
const pct = s.defaultWarnAtPct;
|
|
1033
|
+
const near = (spent: number, cap?: number) =>
|
|
1034
|
+
cap !== undefined && pct !== undefined && pct > 0 && pct < 1 && spent >= pct * cap;
|
|
1035
|
+
await ctx.db.patch(s._id, {
|
|
1036
|
+
globalTrippedDaily: dailyCap !== undefined && spentToday >= dailyCap,
|
|
1037
|
+
globalTrippedLifetime: lifetimeCap !== undefined && spentTotal >= lifetimeCap,
|
|
1038
|
+
globalNearLimit: near(spentToday, dailyCap) || near(spentTotal, lifetimeCap),
|
|
1039
|
+
});
|
|
1040
|
+
return null;
|
|
1041
|
+
},
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
// Fold finished-but-unfolded requests whose scheduled fold lost the OCC race.
|
|
1045
|
+
export const foldPhase = internalMutation({
|
|
1046
|
+
args: {},
|
|
1047
|
+
returns: v.object({ folded: v.number() }),
|
|
953
1048
|
handler: async (ctx) => {
|
|
954
1049
|
const toFold = await ctx.db
|
|
955
1050
|
.query("requests")
|
|
956
1051
|
.withIndex("settled", (q) => q.eq("settled", false))
|
|
957
|
-
.take(
|
|
1052
|
+
.take(RECONCILE_BATCH);
|
|
958
1053
|
for (const req of toFold) await foldOne(ctx, req);
|
|
1054
|
+
if (toFold.length === RECONCILE_BATCH)
|
|
1055
|
+
await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
|
|
1056
|
+
return { folded: toFold.length };
|
|
1057
|
+
},
|
|
1058
|
+
});
|
|
959
1059
|
|
|
1060
|
+
// Release reservations for requests that never settled (their action crashed),
|
|
1061
|
+
// and lazily backfill deadlines on legacy pending rows.
|
|
1062
|
+
export const expirePhase = internalMutation({
|
|
1063
|
+
args: {},
|
|
1064
|
+
returns: v.object({ expired: v.number() }),
|
|
1065
|
+
handler: async (ctx) => {
|
|
960
1066
|
// Lazily migrate old pending rows in bounded batches. Once indexed, long
|
|
961
1067
|
// TTL jobs cannot hide expired jobs behind them in creation-time order.
|
|
962
1068
|
const legacy = await ctx.db.query("requests").withIndex("status_expires", q =>
|
|
963
|
-
q.eq("status", "pending").eq("expiresAt", undefined)).take(
|
|
1069
|
+
q.eq("status", "pending").eq("expiresAt", undefined)).take(RECONCILE_BATCH);
|
|
964
1070
|
for (const req of legacy) {
|
|
965
1071
|
await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
|
|
966
1072
|
}
|
|
967
1073
|
const candidates = await ctx.db.query("requests").withIndex("status_expires", q =>
|
|
968
|
-
q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(
|
|
1074
|
+
q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(RECONCILE_BATCH);
|
|
969
1075
|
for (const req of candidates) {
|
|
970
1076
|
await releaseReservation(ctx, req);
|
|
971
1077
|
await ctx.db.patch(req._id, {
|
|
@@ -973,37 +1079,58 @@ export const reconcile = internalMutation({
|
|
|
973
1079
|
reservationExpired: true, expiresAt: undefined, settled: true,
|
|
974
1080
|
});
|
|
975
1081
|
}
|
|
976
|
-
|
|
1082
|
+
if (legacy.length === RECONCILE_BATCH || candidates.length === RECONCILE_BATCH)
|
|
1083
|
+
await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
|
|
1084
|
+
return { expired: candidates.length };
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
977
1087
|
|
|
978
|
-
|
|
1088
|
+
// Delete terminal, fully-accounted request rows past the retention window; purge
|
|
1089
|
+
// content from expired tombstones; and finally delete tombstones past the
|
|
1090
|
+
// late-settle horizon so they can't accumulate forever.
|
|
1091
|
+
export const retentionPhase = internalMutation({
|
|
1092
|
+
args: {},
|
|
1093
|
+
returns: v.object({ purged: v.number() }),
|
|
1094
|
+
handler: async (ctx) => {
|
|
979
1095
|
const settings = await getSettings(ctx);
|
|
980
1096
|
const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
1097
|
+
if (retentionMs <= 0) return { purged: 0 };
|
|
1098
|
+
const retentionCutoff = Date.now() - retentionMs;
|
|
981
1099
|
let purged = 0;
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
// billing tombstones must not repeatedly occupy the front of a scan.
|
|
986
|
-
const old = [];
|
|
987
|
-
for (const expiredFlag of [undefined, false]) {
|
|
988
|
-
old.push(...await ctx.db.query("requests").withIndex("retention", q =>
|
|
989
|
-
q.eq("reservationExpired", expiredFlag).eq("settled", true)
|
|
990
|
-
.lt("_creationTime", retentionCutoff)).take(200));
|
|
991
|
-
}
|
|
992
|
-
old.push(...await ctx.db.query("requests").withIndex("status", q =>
|
|
993
|
-
q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
|
|
994
|
-
for (const req of old) {
|
|
1100
|
+
let more = false;
|
|
1101
|
+
const sweep = async (rows: Doc<"requests">[]) => {
|
|
1102
|
+
for (const req of rows) {
|
|
995
1103
|
await deleteRequestTags(ctx, req._id);
|
|
996
1104
|
await ctx.db.delete(req._id);
|
|
997
1105
|
purged++;
|
|
998
1106
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1107
|
+
if (rows.length === RECONCILE_BATCH) more = true;
|
|
1108
|
+
};
|
|
1109
|
+
// Settled, non-expired terminal rows past the window.
|
|
1110
|
+
for (const expiredFlag of [undefined, false] as const) {
|
|
1111
|
+
await sweep(await ctx.db.query("requests").withIndex("retention", q =>
|
|
1112
|
+
q.eq("reservationExpired", expiredFlag).eq("settled", true)
|
|
1113
|
+
.lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
|
|
1005
1114
|
}
|
|
1006
|
-
|
|
1115
|
+
// Blocked attempts past the window.
|
|
1116
|
+
await sweep(await ctx.db.query("requests").withIndex("status", q =>
|
|
1117
|
+
q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
|
|
1118
|
+
// Expired billing tombstones past the late-settle horizon (content already
|
|
1119
|
+
// gone). Without this they'd live forever (one per crashed request).
|
|
1120
|
+
const tombstoneCutoff = Date.now() - Math.max(retentionMs, LATE_SETTLE_HORIZON_MS);
|
|
1121
|
+
await sweep(await ctx.db.query("requests").withIndex("retention", q =>
|
|
1122
|
+
q.eq("reservationExpired", true).eq("settled", true)
|
|
1123
|
+
.lt("_creationTime", tombstoneCutoff)).take(RECONCILE_BATCH));
|
|
1124
|
+
// Strip PII from expired tombstones still inside the horizon.
|
|
1125
|
+
const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q =>
|
|
1126
|
+
q.eq("reservationExpired", true).eq("contentPurged", undefined)
|
|
1127
|
+
.lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH);
|
|
1128
|
+
for (const req of expiredContent) {
|
|
1129
|
+
await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
|
|
1130
|
+
}
|
|
1131
|
+
if (expiredContent.length === RECONCILE_BATCH) more = true;
|
|
1132
|
+
if (more) await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
|
|
1133
|
+
return { purged };
|
|
1007
1134
|
},
|
|
1008
1135
|
});
|
|
1009
1136
|
|
|
@@ -1226,15 +1353,25 @@ export const adjustBucket = mutation({
|
|
|
1226
1353
|
const dSame = b.dayStamp === today;
|
|
1227
1354
|
const mSame = b.monthStamp === month;
|
|
1228
1355
|
const dt = tokens ?? 0;
|
|
1356
|
+
// Gross-plus-credits ledger: a positive delta is a real extra charge (adds
|
|
1357
|
+
// to GROSS spend); a negative delta is a credit/refund that accrues to a
|
|
1358
|
+
// separate credits balance and NEVER reduces gross spend — so gross spend
|
|
1359
|
+
// and durable history stay consistent, and net = gross - credits. Credits
|
|
1360
|
+
// grant headroom because admission subtracts them from the cap check.
|
|
1361
|
+
const debit = deltaNanos > 0 ? deltaNanos : 0;
|
|
1362
|
+
const credit = deltaNanos < 0 ? -deltaNanos : 0;
|
|
1229
1363
|
await ctx.db.patch(b._id, {
|
|
1230
|
-
totalSpendNanos:
|
|
1364
|
+
totalSpendNanos: b.totalSpendNanos + debit,
|
|
1231
1365
|
totalTokens: Math.max(0, b.totalTokens + dt),
|
|
1232
1366
|
dayStamp: today,
|
|
1233
1367
|
monthStamp: month,
|
|
1234
|
-
spendTodayNanos:
|
|
1368
|
+
spendTodayNanos: (dSame ? b.spendTodayNanos : 0) + debit,
|
|
1235
1369
|
tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
|
|
1236
|
-
spendThisMonthNanos:
|
|
1370
|
+
spendThisMonthNanos: (mSame ? b.spendThisMonthNanos ?? 0 : 0) + debit,
|
|
1237
1371
|
tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
|
|
1372
|
+
creditsNanos: (b.creditsNanos ?? 0) + credit,
|
|
1373
|
+
creditsTodayNanos: (dSame ? b.creditsTodayNanos ?? 0 : 0) + credit,
|
|
1374
|
+
creditsThisMonthNanos: (mSame ? b.creditsThisMonthNanos ?? 0 : 0) + credit,
|
|
1238
1375
|
// Advancing the window here must also clear the OLD window's reserved
|
|
1239
1376
|
// holds, or an in-flight request from the previous day/month would be
|
|
1240
1377
|
// treated as reserving against the new window and its later release would
|
|
@@ -1243,8 +1380,10 @@ export const adjustBucket = mutation({
|
|
|
1243
1380
|
...(mSame ? {} : { reservedMonthNanos: 0, reservedMonthTokens: 0 }),
|
|
1244
1381
|
});
|
|
1245
1382
|
await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
|
|
1246
|
-
|
|
1247
|
-
|
|
1383
|
+
// Durable history tracks GROSS spend only (debits); credits live in the
|
|
1384
|
+
// adjustments log + bucket balance, so usage rollups never go negative.
|
|
1385
|
+
await addUsage(ctx, dimension, value, "day", today, debit, dt, 0);
|
|
1386
|
+
await addUsage(ctx, dimension, value, "month", month, debit, dt, 0);
|
|
1248
1387
|
return null;
|
|
1249
1388
|
},
|
|
1250
1389
|
});
|
|
@@ -1291,6 +1430,28 @@ export const setAlertDefaults = mutation({
|
|
|
1291
1430
|
},
|
|
1292
1431
|
});
|
|
1293
1432
|
|
|
1433
|
+
// Deployment-wide data/pricing policy. Only the fields you pass change.
|
|
1434
|
+
// - allowUnpricedModels: false rejects models with no configured price under
|
|
1435
|
+
// hard enforcement (default true — charge the conservative fallback).
|
|
1436
|
+
// - storeContent: false stops persisting prompt/response content on request
|
|
1437
|
+
// rows (default true).
|
|
1438
|
+
export const setDeploymentPolicy = mutation({
|
|
1439
|
+
args: {
|
|
1440
|
+
allowUnpricedModels: v.optional(v.boolean()),
|
|
1441
|
+
storeContent: v.optional(v.boolean()),
|
|
1442
|
+
},
|
|
1443
|
+
returns: v.null(),
|
|
1444
|
+
handler: async (ctx, args) => {
|
|
1445
|
+
const patch: Record<string, unknown> = {};
|
|
1446
|
+
if ("allowUnpricedModels" in args) patch.allowUnpricedModels = args.allowUnpricedModels;
|
|
1447
|
+
if ("storeContent" in args) patch.storeContent = args.storeContent;
|
|
1448
|
+
const existing = await getSettings(ctx);
|
|
1449
|
+
if (existing) await ctx.db.patch(existing._id, patch);
|
|
1450
|
+
else await ctx.db.insert("settings", { key: "singleton", ...patch });
|
|
1451
|
+
return null;
|
|
1452
|
+
},
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1294
1455
|
// Delete a bucket and (for the `user` dimension) all of that user's request
|
|
1295
1456
|
// rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
|
|
1296
1457
|
// self-reschedules so it never exceeds the per-transaction document limit.
|
|
@@ -1372,7 +1533,7 @@ export const getGlobalStatus = query({
|
|
|
1372
1533
|
returns: v.object({
|
|
1373
1534
|
dailySpendLimitNanos: v.union(v.number(), v.null()),
|
|
1374
1535
|
lifetimeSpendLimitNanos: v.union(v.number(), v.null()),
|
|
1375
|
-
enforcement: v.union(v.literal("
|
|
1536
|
+
enforcement: v.union(v.literal("approximate"), v.literal("soft")),
|
|
1376
1537
|
spentTodayNanos: v.number(),
|
|
1377
1538
|
spentTotalNanos: v.number(),
|
|
1378
1539
|
// deployment-wide config (surfaced for the admin dashboard)
|
|
@@ -1387,7 +1548,7 @@ export const getGlobalStatus = query({
|
|
|
1387
1548
|
return {
|
|
1388
1549
|
dailySpendLimitNanos: s?.globalDailySpendLimitNanos ?? null,
|
|
1389
1550
|
lifetimeSpendLimitNanos: s?.globalLifetimeSpendLimitNanos ?? null,
|
|
1390
|
-
enforcement: s?.globalEnforcement ?? "
|
|
1551
|
+
enforcement: s?.globalEnforcement ?? "approximate",
|
|
1391
1552
|
spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
|
|
1392
1553
|
spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
|
|
1393
1554
|
retentionMs: s?.retentionMs ?? null,
|
|
@@ -1405,7 +1566,7 @@ export const setGlobalLimits = mutation({
|
|
|
1405
1566
|
dailySpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1406
1567
|
lifetimeSpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1407
1568
|
enforcement: v.optional(
|
|
1408
|
-
v.union(v.literal("
|
|
1569
|
+
v.union(v.literal("approximate"), v.literal("soft"), v.null())
|
|
1409
1570
|
),
|
|
1410
1571
|
},
|
|
1411
1572
|
returns: v.null(),
|
package/src/component/schema.ts
CHANGED
|
@@ -59,10 +59,17 @@ export default defineSchema({
|
|
|
59
59
|
lifetimeBumpNanos: v.optional(v.number()),
|
|
60
60
|
bumpDayStamp: v.optional(v.string()),
|
|
61
61
|
bumpMonthStamp: v.optional(v.string()),
|
|
62
|
-
// settled
|
|
62
|
+
// settled GROSS spend (real charges + positive adjustments). Credits never
|
|
63
|
+
// reduce these — they accrue separately below — so gross spend and durable
|
|
64
|
+
// history always agree. Cap checks and "net" use (gross - credits).
|
|
63
65
|
totalSpendNanos: v.number(),
|
|
64
66
|
totalRequests: v.number(),
|
|
65
67
|
totalTokens: v.number(),
|
|
68
|
+
// manual credits (comps/refunds), tracked separately from gross spend and
|
|
69
|
+
// subtracted at admission so a credit grants real headroom under a cap.
|
|
70
|
+
creditsNanos: v.optional(v.number()),
|
|
71
|
+
creditsTodayNanos: v.optional(v.number()),
|
|
72
|
+
creditsThisMonthNanos: v.optional(v.number()),
|
|
66
73
|
// daily window
|
|
67
74
|
dayStamp: v.string(),
|
|
68
75
|
spendTodayNanos: v.number(),
|
|
@@ -211,12 +218,23 @@ export default defineSchema({
|
|
|
211
218
|
// killswitch; per-bucket concurrent admission is atomic via reserve/settle.
|
|
212
219
|
globalDailySpendLimitNanos: v.optional(v.number()),
|
|
213
220
|
globalLifetimeSpendLimitNanos: v.optional(v.number()),
|
|
221
|
+
// "approximate" (default): a best-effort killswitch — it blocks once the
|
|
222
|
+
// sharded total crosses the cap, but with bounded overshoot (no per-request
|
|
223
|
+
// reservation). "soft": warn only. There is deliberately no "hard": a true
|
|
224
|
+
// to-the-dollar ceiling is a per-bucket cap.
|
|
214
225
|
globalEnforcement: v.optional(
|
|
215
|
-
v.union(v.literal("
|
|
226
|
+
v.union(v.literal("approximate"), v.literal("soft"))
|
|
216
227
|
),
|
|
217
228
|
globalDailyBumpNanos: v.optional(v.number()),
|
|
218
229
|
globalLifetimeBumpNanos: v.optional(v.number()),
|
|
219
230
|
globalBumpDayStamp: v.optional(v.string()),
|
|
231
|
+
// H4: the reconciler compares the sharded global total to the cap and sets
|
|
232
|
+
// these, so admission reads ONE settings doc instead of the sharded counter
|
|
233
|
+
// (whose per-admission read contended with every fold). Killswitch lag is
|
|
234
|
+
// bounded by the reconcile interval — fine for a deployment-wide stop.
|
|
235
|
+
globalTrippedDaily: v.optional(v.boolean()),
|
|
236
|
+
globalTrippedLifetime: v.optional(v.boolean()),
|
|
237
|
+
globalNearLimit: v.optional(v.boolean()),
|
|
220
238
|
// request-row retention window in ms (default 1h); 0 disables sweeping.
|
|
221
239
|
retentionMs: v.optional(v.number()),
|
|
222
240
|
// default approaching-limit alert threshold (fraction of a cap) for buckets
|
|
@@ -225,5 +243,13 @@ export default defineSchema({
|
|
|
225
243
|
// per-call price (nanodollars) overrides for provider server tools, keyed by
|
|
226
244
|
// tool name (e.g. { web_search: 12_000_000 }). Merged over the defaults.
|
|
227
245
|
serverToolPrices: v.optional(v.record(v.string(), v.number())),
|
|
246
|
+
// When false, reject requests for a model with no known/override price under
|
|
247
|
+
// hard enforcement (instead of charging the conservative fallback). Default
|
|
248
|
+
// true (charge the fallback, keep the call working).
|
|
249
|
+
allowUnpricedModels: v.optional(v.boolean()),
|
|
250
|
+
// When false, don't persist prompt/response content on request rows (only
|
|
251
|
+
// metadata + cost). Default true. For teams that want zero prompt retention
|
|
252
|
+
// rather than short retention.
|
|
253
|
+
storeContent: v.optional(v.boolean()),
|
|
228
254
|
}).index("key", ["key"]),
|
|
229
255
|
});
|