@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/dist/component/lib.js
CHANGED
|
@@ -36,6 +36,10 @@ function assertFraction(n, name) {
|
|
|
36
36
|
// Coerce a caller/provider-supplied count to a finite nonnegative integer,
|
|
37
37
|
// mapping NaN/Infinity/garbage to 0 rather than poisoning downstream totals.
|
|
38
38
|
const safeCount = (n) => Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
|
|
39
|
+
// Treat a non-finite stored accounting field as 0, so a bucket that was poisoned
|
|
40
|
+
// before validation existed self-heals on its next reserve/release instead of
|
|
41
|
+
// staying NaN forever.
|
|
42
|
+
const fin = (n) => (Number.isFinite(n) ? n : 0);
|
|
39
43
|
// Built-in attribution dimensions. `user` and `action` are always populated
|
|
40
44
|
// from a request's userId/actionName; apps can add any other dimensions
|
|
41
45
|
// (team, project, customer, env, …) as tags. These two names are reserved —
|
|
@@ -87,6 +91,16 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
|
|
|
87
91
|
// audit table — and the sensitive content in it — from growing without bound.
|
|
88
92
|
// Override per-deployment via setRetention.
|
|
89
93
|
const DEFAULT_RETENTION_MS = 60 * 60 * 1000; // 1 hour
|
|
94
|
+
// Reconciliation runs as small, independently-rescheduling phases. Keeping the
|
|
95
|
+
// batch small bounds the bytes read per transaction (each request row can carry
|
|
96
|
+
// prompts/responses) so a burst can't push one phase past Convex's 8 MiB / 16k-
|
|
97
|
+
// doc read limit and stall the whole reconciler. A phase that fills its batch
|
|
98
|
+
// reschedules itself immediately, so throughput still scales with backlog.
|
|
99
|
+
const RECONCILE_BATCH = 50;
|
|
100
|
+
// Keep an expired billing tombstone (content already purged) this long so a very
|
|
101
|
+
// late provider charge can still land against it, then delete it. A finish after
|
|
102
|
+
// deletion is a graceful no-op.
|
|
103
|
+
const LATE_SETTLE_HORIZON_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
90
104
|
const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
|
|
91
105
|
const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
|
|
92
106
|
// Cached (prompt-cache-read) input tokens are billed far below the normal input
|
|
@@ -99,10 +113,15 @@ const CACHE_DISCOUNT = 0.1;
|
|
|
99
113
|
// bills real money. Charging the conservative max instead keeps the caps honest
|
|
100
114
|
// (over-counting is the safe direction); admins can pin an exact price via
|
|
101
115
|
// setPrice, which also clears the `unpricedModel` flag on future requests.
|
|
116
|
+
// Seed with a true frontier ceiling ($20/$100 per Mtok), not just the max of the
|
|
117
|
+
// small built-in table — otherwise premium models (Opus-class $15/$75, etc.) not
|
|
118
|
+
// in the table would be under-counted several-fold whenever the gateway's
|
|
119
|
+
// authoritative cost isn't available. Over-counting an unpriced model is the safe
|
|
120
|
+
// direction; admins pin the exact rate with setPrice.
|
|
102
121
|
const CONSERVATIVE_PRICE = Object.values(DEFAULT_PRICES).reduce((m, p) => ({
|
|
103
122
|
input: Math.max(m.input, p.input),
|
|
104
123
|
output: Math.max(m.output, p.output),
|
|
105
|
-
}), { input:
|
|
124
|
+
}), { input: 20_000_000_000, output: 100_000_000_000 });
|
|
106
125
|
async function getPrice(ctx, model) {
|
|
107
126
|
const override = await ctx.db
|
|
108
127
|
.query("prices")
|
|
@@ -235,9 +254,9 @@ function evaluateCaps(o) {
|
|
|
235
254
|
// { code, projected usage (incl. this estimate), cap, human window label,
|
|
236
255
|
// whether it's a money cap (formatted as $), spend? for notices }
|
|
237
256
|
const checks = [
|
|
238
|
-
{ w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost, cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
|
|
239
|
-
{ w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost, cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
|
|
240
|
-
{ w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost, cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
|
|
257
|
+
{ w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost - (o.creditsToday ?? 0), cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
|
|
258
|
+
{ w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost - (o.creditsThisMonth ?? 0), cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
|
|
259
|
+
{ w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost - (o.creditsTotal ?? 0), cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
|
|
241
260
|
{ w: "daily_token_limit", used: o.tokensToday + o.reservedTokensToday + o.estTokens, cap: o.dailyTokenLimit, label: "daily token limit", money: false },
|
|
242
261
|
{ w: "monthly_token_limit", used: o.tokensThisMonth + o.reservedTokensMonth + o.estTokens, cap: o.monthlyTokenLimit, label: "monthly token limit", money: false },
|
|
243
262
|
{ w: "lifetime_token_limit", used: o.totalTokens + o.reservedTokensTotal + o.estTokens, cap: o.lifetimeTokenLimit, label: "lifetime token limit", money: false },
|
|
@@ -379,7 +398,15 @@ export const startRequest = mutation({
|
|
|
379
398
|
if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
|
|
380
399
|
throw new Error("reserveTtlMs must be finite and nonnegative");
|
|
381
400
|
}
|
|
401
|
+
// Infinity/NaN here is catastrophic: it's reserved onto the bucket, and a
|
|
402
|
+
// later release computes `Infinity - Infinity = NaN`, leaving the reserved
|
|
403
|
+
// fields NaN forever — after which every `used > cap` check is `NaN > cap`
|
|
404
|
+
// (false) and the bucket admits unlimited spend. Reject it up front.
|
|
405
|
+
assertAmount(args.estimatedCostNanos, "estimatedCostNanos");
|
|
382
406
|
const extraTags = sanitizeExtraTags(args.tags);
|
|
407
|
+
// Set from settings below; when false we persist metadata but no prompt
|
|
408
|
+
// content (a deployment that opts out of storing prompts entirely).
|
|
409
|
+
let storeContent = true;
|
|
383
410
|
// Record the blocked attempt and return a rejection (throwing would roll
|
|
384
411
|
// back the record). `persist` is false for the high-frequency-by-design
|
|
385
412
|
// rejections (rate limit, blocked user) that a client retries in a tight
|
|
@@ -392,7 +419,7 @@ export const startRequest = mutation({
|
|
|
392
419
|
actionName: args.actionName,
|
|
393
420
|
...(extraTags.length ? { tags: extraTags } : {}),
|
|
394
421
|
model: args.model,
|
|
395
|
-
messages: args.messages,
|
|
422
|
+
messages: storeContent ? args.messages : [],
|
|
396
423
|
rerunOf: args.rerunOf,
|
|
397
424
|
status: "blocked",
|
|
398
425
|
error: reason,
|
|
@@ -422,6 +449,7 @@ export const startRequest = mutation({
|
|
|
422
449
|
const notices = [];
|
|
423
450
|
// Model allow/deny policy (component-wide).
|
|
424
451
|
const settings = await getSettings(ctx);
|
|
452
|
+
storeContent = settings?.storeContent !== false;
|
|
425
453
|
const defaultWarnAtPct = settings?.defaultWarnAtPct;
|
|
426
454
|
if (settings) {
|
|
427
455
|
const mode = settings.modelMode ?? "open";
|
|
@@ -432,6 +460,11 @@ export const startRequest = mutation({
|
|
|
432
460
|
if (mode === "denylist" && list.includes(args.model)) {
|
|
433
461
|
return reject("model_denied", `Model "${args.model}" is denied`);
|
|
434
462
|
}
|
|
463
|
+
// Optionally refuse models with no known/override price rather than
|
|
464
|
+
// charging the conservative fallback (which under-counts premium models).
|
|
465
|
+
if (settings.allowUnpricedModels === false && !priceInfo.known) {
|
|
466
|
+
return reject("model_unpriced", `Model "${args.model}" has no configured price; set one with setPrice or allow unpriced models`);
|
|
467
|
+
}
|
|
435
468
|
}
|
|
436
469
|
// Fetch/create every bucket this request is attributed to (user, action,
|
|
437
470
|
// and any extra tags). Each may carry its own budget.
|
|
@@ -492,6 +525,9 @@ export const startRequest = mutation({
|
|
|
492
525
|
reservedSpendMonth: sameMonth ? b.reservedMonthNanos ?? 0 : 0,
|
|
493
526
|
totalSpend: b.totalSpendNanos,
|
|
494
527
|
reservedSpendTotal: b.reservedTotalNanos ?? 0,
|
|
528
|
+
creditsToday: sameDay ? b.creditsTodayNanos ?? 0 : 0,
|
|
529
|
+
creditsThisMonth: sameMonth ? b.creditsThisMonthNanos ?? 0 : 0,
|
|
530
|
+
creditsTotal: b.creditsNanos ?? 0,
|
|
495
531
|
tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
|
|
496
532
|
reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
|
|
497
533
|
tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
|
|
@@ -518,35 +554,23 @@ export const startRequest = mutation({
|
|
|
518
554
|
// throughput. The sum is transactional, but excludes unsettled usage and
|
|
519
555
|
// has no cross-request reservation, so a hard global cap can overshoot. That's the deliberate consistency/throughput
|
|
520
556
|
// trade for a deployment-wide killswitch; it's the only approximate scope.
|
|
557
|
+
// H4: don't read the sharded counter here — that per-admission read
|
|
558
|
+
// contended with every fold that writes it. The reconciler compares the
|
|
559
|
+
// total to the cap out-of-band and records trip flags on `settings`;
|
|
560
|
+
// admission just reads those (already-loaded) flags. The killswitch lag is
|
|
561
|
+
// bounded by the reconcile interval, which is the point of "approximate".
|
|
521
562
|
if (settings &&
|
|
522
|
-
(settings.
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
spendThisMonth: 0, // global tracks daily + lifetime only
|
|
534
|
-
reservedSpendMonth: 0,
|
|
535
|
-
totalSpend: await globalSpend.count(ctx, GLOBAL_TOTAL),
|
|
536
|
-
reservedSpendTotal: 0,
|
|
537
|
-
tokensToday: 0,
|
|
538
|
-
reservedTokensToday: 0,
|
|
539
|
-
tokensThisMonth: 0,
|
|
540
|
-
reservedTokensMonth: 0,
|
|
541
|
-
totalTokens: 0,
|
|
542
|
-
reservedTokensTotal: 0,
|
|
543
|
-
dailySpendLimitNanos: withBump(settings.globalDailySpendLimitNanos, settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0),
|
|
544
|
-
lifetimeSpendLimitNanos: withBump(settings.globalLifetimeSpendLimitNanos, settings.globalLifetimeBumpNanos),
|
|
545
|
-
});
|
|
546
|
-
if (globalEval.hard)
|
|
547
|
-
return reject(globalEval.hard.code, globalEval.hard.reason);
|
|
548
|
-
warnings.push(...globalEval.warnings);
|
|
549
|
-
notices.push(...globalEval.notices);
|
|
563
|
+
(settings.globalTrippedDaily || settings.globalTrippedLifetime)) {
|
|
564
|
+
const enforcement = settings.globalEnforcement ?? "approximate";
|
|
565
|
+
if (enforcement === "soft") {
|
|
566
|
+
warnings.push(`Global spend limit reached for the deployment (allowed — soft)`);
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
return reject("global_spend_limit", "Global spend limit reached for the deployment");
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
else if (settings?.globalNearLimit) {
|
|
573
|
+
notices.push("Deployment approaching its global spend limit");
|
|
550
574
|
}
|
|
551
575
|
// Consume only after every admission check succeeds, in the same
|
|
552
576
|
// transaction as the reservations and request insert. `throws: true` is
|
|
@@ -596,7 +620,7 @@ export const startRequest = mutation({
|
|
|
596
620
|
actionName: args.actionName,
|
|
597
621
|
...(extraTags.length ? { tags: extraTags } : {}),
|
|
598
622
|
model: args.model,
|
|
599
|
-
messages: args.messages,
|
|
623
|
+
messages: storeContent ? args.messages : [],
|
|
600
624
|
rerunOf: args.rerunOf,
|
|
601
625
|
...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
|
|
602
626
|
status: "pending",
|
|
@@ -662,14 +686,22 @@ export const finishRequest = mutation({
|
|
|
662
686
|
// tool fees); otherwise price from tokens — discounting the cached
|
|
663
687
|
// (prompt-cache-read) slice — plus any server-tool per-call fees. Require it
|
|
664
688
|
// finite: +Infinity passes a bare `>= 0` and would corrupt the totals.
|
|
689
|
+
const settings = await getSettings(ctx);
|
|
690
|
+
const storeContent = settings?.storeContent !== false;
|
|
665
691
|
let costNanos;
|
|
666
692
|
if (args.costNanos !== undefined && Number.isFinite(args.costNanos) && args.costNanos >= 0) {
|
|
667
693
|
costNanos = Math.round(args.costNanos);
|
|
668
694
|
}
|
|
669
695
|
else {
|
|
670
|
-
const
|
|
671
|
-
|
|
672
|
-
|
|
696
|
+
const priced = settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model)) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
|
|
697
|
+
// Fail closed: if the settle carried NO usable cost signal — no tokens and
|
|
698
|
+
// no priced server-tool fee (an unpriced tool like `video_seconds`, or a
|
|
699
|
+
// bare settle()) — fall back to the reserved estimate rather than recording
|
|
700
|
+
// $0. Known-cost calls (image/video/audio) set estimatedCostNanos at
|
|
701
|
+
// reserve time precisely so this floor is their real cost. A caller that
|
|
702
|
+
// truly wants $0 passes an explicit authoritative costNanos: 0 above.
|
|
703
|
+
const noSignal = promptTokens === 0 && completionTokens === 0 && priced === 0;
|
|
704
|
+
costNanos = noSignal ? (request.estimatedNanos ?? 0) : priced;
|
|
673
705
|
}
|
|
674
706
|
// Durable write to the request's OWN row only — uncontended, so it always
|
|
675
707
|
// lands. `settled: false` hands it to the fold step; the row is never left
|
|
@@ -679,7 +711,7 @@ export const finishRequest = mutation({
|
|
|
679
711
|
reservationExpired: false,
|
|
680
712
|
expiresAt: undefined,
|
|
681
713
|
finishedAt: Date.now(),
|
|
682
|
-
responseText: args.responseText,
|
|
714
|
+
responseText: storeContent ? args.responseText : undefined,
|
|
683
715
|
error: args.error,
|
|
684
716
|
promptTokens,
|
|
685
717
|
completionTokens,
|
|
@@ -711,13 +743,13 @@ async function releaseReservation(ctx, req) {
|
|
|
711
743
|
if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b)))
|
|
712
744
|
continue;
|
|
713
745
|
await ctx.db.patch(b._id, {
|
|
714
|
-
reservedTodayNanos: Math.max(0, (b.reservedTodayNanos
|
|
715
|
-
reservedMonthNanos: Math.max(0, (b.reservedMonthNanos
|
|
716
|
-
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos
|
|
717
|
-
reservedTodayTokens: Math.max(0, (b.reservedTodayTokens
|
|
718
|
-
reservedMonthTokens: Math.max(0, (b.reservedMonthTokens
|
|
719
|
-
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens
|
|
720
|
-
pendingCount: Math.max(0, (b.pendingCount
|
|
746
|
+
reservedTodayNanos: Math.max(0, fin(b.reservedTodayNanos) - (b.dayStamp === day ? cost : 0)),
|
|
747
|
+
reservedMonthNanos: Math.max(0, fin(b.reservedMonthNanos) - (b.monthStamp === month ? cost : 0)),
|
|
748
|
+
reservedTotalNanos: Math.max(0, fin(b.reservedTotalNanos) - cost),
|
|
749
|
+
reservedTodayTokens: Math.max(0, fin(b.reservedTodayTokens) - (b.dayStamp === day ? tokens : 0)),
|
|
750
|
+
reservedMonthTokens: Math.max(0, fin(b.reservedMonthTokens) - (b.monthStamp === month ? tokens : 0)),
|
|
751
|
+
reservedTotalTokens: Math.max(0, fin(b.reservedTotalTokens) - tokens),
|
|
752
|
+
pendingCount: Math.max(0, fin(b.pendingCount) - 1),
|
|
721
753
|
});
|
|
722
754
|
}
|
|
723
755
|
await ctx.db.patch(req._id, { reservationReleased: true });
|
|
@@ -773,27 +805,87 @@ export const foldTotals = internalMutation({
|
|
|
773
805
|
// Backstop for both failure modes: folds finished requests whose scheduled fold
|
|
774
806
|
// lost the retry race, and releases reservations for requests that never
|
|
775
807
|
// settled (their action crashed). Runs on a cron.
|
|
808
|
+
// Cron entry: kick off each reconciliation phase as its OWN transaction so a
|
|
809
|
+
// failure in one (e.g. an oversized retention scan) can't stall the others, and
|
|
810
|
+
// so the hot fold path doesn't share a transaction with retention. Each phase
|
|
811
|
+
// self-reschedules while it has a full batch of backlog.
|
|
776
812
|
export const reconcile = internalMutation({
|
|
777
813
|
args: {},
|
|
778
|
-
returns: v.
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
814
|
+
returns: v.null(),
|
|
815
|
+
handler: async (ctx) => {
|
|
816
|
+
await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
|
|
817
|
+
await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
|
|
818
|
+
await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
|
|
819
|
+
await ctx.scheduler.runAfter(0, internal.lib.globalPhase, {});
|
|
820
|
+
return null;
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
// H4: compute the deployment-wide global-cap trip flags out-of-band so admission
|
|
824
|
+
// never reads the sharded counter on its hot path. One cheap counter read here
|
|
825
|
+
// per interval; admission then consults the flags on `settings`.
|
|
826
|
+
export const globalPhase = internalMutation({
|
|
827
|
+
args: {},
|
|
828
|
+
returns: v.null(),
|
|
829
|
+
handler: async (ctx) => {
|
|
830
|
+
const s = await getSettings(ctx);
|
|
831
|
+
if (!s)
|
|
832
|
+
return null;
|
|
833
|
+
const hasCap = s.globalDailySpendLimitNanos !== undefined ||
|
|
834
|
+
s.globalLifetimeSpendLimitNanos !== undefined;
|
|
835
|
+
if (!hasCap) {
|
|
836
|
+
// Clear stale flags when no global cap is configured.
|
|
837
|
+
if (s.globalTrippedDaily || s.globalTrippedLifetime || s.globalNearLimit)
|
|
838
|
+
await ctx.db.patch(s._id, {
|
|
839
|
+
globalTrippedDaily: false,
|
|
840
|
+
globalTrippedLifetime: false,
|
|
841
|
+
globalNearLimit: false,
|
|
842
|
+
});
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
const today = dayStamp();
|
|
846
|
+
const dailyCap = withBump(s.globalDailySpendLimitNanos, s.globalBumpDayStamp === today ? s.globalDailyBumpNanos : 0);
|
|
847
|
+
const lifetimeCap = withBump(s.globalLifetimeSpendLimitNanos, s.globalLifetimeBumpNanos);
|
|
848
|
+
const spentToday = await globalSpend.count(ctx, globalDayKey(today));
|
|
849
|
+
const spentTotal = await globalSpend.count(ctx, GLOBAL_TOTAL);
|
|
850
|
+
const pct = s.defaultWarnAtPct;
|
|
851
|
+
const near = (spent, cap) => cap !== undefined && pct !== undefined && pct > 0 && pct < 1 && spent >= pct * cap;
|
|
852
|
+
await ctx.db.patch(s._id, {
|
|
853
|
+
globalTrippedDaily: dailyCap !== undefined && spentToday >= dailyCap,
|
|
854
|
+
globalTrippedLifetime: lifetimeCap !== undefined && spentTotal >= lifetimeCap,
|
|
855
|
+
globalNearLimit: near(spentToday, dailyCap) || near(spentTotal, lifetimeCap),
|
|
856
|
+
});
|
|
857
|
+
return null;
|
|
858
|
+
},
|
|
859
|
+
});
|
|
860
|
+
// Fold finished-but-unfolded requests whose scheduled fold lost the OCC race.
|
|
861
|
+
export const foldPhase = internalMutation({
|
|
862
|
+
args: {},
|
|
863
|
+
returns: v.object({ folded: v.number() }),
|
|
783
864
|
handler: async (ctx) => {
|
|
784
865
|
const toFold = await ctx.db
|
|
785
866
|
.query("requests")
|
|
786
867
|
.withIndex("settled", (q) => q.eq("settled", false))
|
|
787
|
-
.take(
|
|
868
|
+
.take(RECONCILE_BATCH);
|
|
788
869
|
for (const req of toFold)
|
|
789
870
|
await foldOne(ctx, req);
|
|
871
|
+
if (toFold.length === RECONCILE_BATCH)
|
|
872
|
+
await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
|
|
873
|
+
return { folded: toFold.length };
|
|
874
|
+
},
|
|
875
|
+
});
|
|
876
|
+
// Release reservations for requests that never settled (their action crashed),
|
|
877
|
+
// and lazily backfill deadlines on legacy pending rows.
|
|
878
|
+
export const expirePhase = internalMutation({
|
|
879
|
+
args: {},
|
|
880
|
+
returns: v.object({ expired: v.number() }),
|
|
881
|
+
handler: async (ctx) => {
|
|
790
882
|
// Lazily migrate old pending rows in bounded batches. Once indexed, long
|
|
791
883
|
// TTL jobs cannot hide expired jobs behind them in creation-time order.
|
|
792
|
-
const legacy = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").eq("expiresAt", undefined)).take(
|
|
884
|
+
const legacy = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").eq("expiresAt", undefined)).take(RECONCILE_BATCH);
|
|
793
885
|
for (const req of legacy) {
|
|
794
886
|
await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
|
|
795
887
|
}
|
|
796
|
-
const candidates = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(
|
|
888
|
+
const candidates = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(RECONCILE_BATCH);
|
|
797
889
|
for (const req of candidates) {
|
|
798
890
|
await releaseReservation(ctx, req);
|
|
799
891
|
await ctx.db.patch(req._id, {
|
|
@@ -801,33 +893,57 @@ export const reconcile = internalMutation({
|
|
|
801
893
|
reservationExpired: true, expiresAt: undefined, settled: true,
|
|
802
894
|
});
|
|
803
895
|
}
|
|
804
|
-
|
|
805
|
-
|
|
896
|
+
if (legacy.length === RECONCILE_BATCH || candidates.length === RECONCILE_BATCH)
|
|
897
|
+
await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
|
|
898
|
+
return { expired: candidates.length };
|
|
899
|
+
},
|
|
900
|
+
});
|
|
901
|
+
// Delete terminal, fully-accounted request rows past the retention window; purge
|
|
902
|
+
// content from expired tombstones; and finally delete tombstones past the
|
|
903
|
+
// late-settle horizon so they can't accumulate forever.
|
|
904
|
+
export const retentionPhase = internalMutation({
|
|
905
|
+
args: {},
|
|
906
|
+
returns: v.object({ purged: v.number() }),
|
|
907
|
+
handler: async (ctx) => {
|
|
806
908
|
const settings = await getSettings(ctx);
|
|
807
909
|
const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
910
|
+
if (retentionMs <= 0)
|
|
911
|
+
return { purged: 0 };
|
|
912
|
+
const retentionCutoff = Date.now() - retentionMs;
|
|
808
913
|
let purged = 0;
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
// billing tombstones must not repeatedly occupy the front of a scan.
|
|
813
|
-
const old = [];
|
|
814
|
-
for (const expiredFlag of [undefined, false]) {
|
|
815
|
-
old.push(...await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", expiredFlag).eq("settled", true)
|
|
816
|
-
.lt("_creationTime", retentionCutoff)).take(200));
|
|
817
|
-
}
|
|
818
|
-
old.push(...await ctx.db.query("requests").withIndex("status", q => q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
|
|
819
|
-
for (const req of old) {
|
|
914
|
+
let more = false;
|
|
915
|
+
const sweep = async (rows) => {
|
|
916
|
+
for (const req of rows) {
|
|
820
917
|
await deleteRequestTags(ctx, req._id);
|
|
821
918
|
await ctx.db.delete(req._id);
|
|
822
919
|
purged++;
|
|
823
920
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
921
|
+
if (rows.length === RECONCILE_BATCH)
|
|
922
|
+
more = true;
|
|
923
|
+
};
|
|
924
|
+
// Settled, non-expired terminal rows past the window.
|
|
925
|
+
for (const expiredFlag of [undefined, false]) {
|
|
926
|
+
await sweep(await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", expiredFlag).eq("settled", true)
|
|
927
|
+
.lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
|
|
928
|
+
}
|
|
929
|
+
// Blocked attempts past the window.
|
|
930
|
+
await sweep(await ctx.db.query("requests").withIndex("status", q => q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
|
|
931
|
+
// Expired billing tombstones past the late-settle horizon (content already
|
|
932
|
+
// gone). Without this they'd live forever (one per crashed request).
|
|
933
|
+
const tombstoneCutoff = Date.now() - Math.max(retentionMs, LATE_SETTLE_HORIZON_MS);
|
|
934
|
+
await sweep(await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", true).eq("settled", true)
|
|
935
|
+
.lt("_creationTime", tombstoneCutoff)).take(RECONCILE_BATCH));
|
|
936
|
+
// Strip PII from expired tombstones still inside the horizon.
|
|
937
|
+
const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q => q.eq("reservationExpired", true).eq("contentPurged", undefined)
|
|
938
|
+
.lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH);
|
|
939
|
+
for (const req of expiredContent) {
|
|
940
|
+
await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
|
|
829
941
|
}
|
|
830
|
-
|
|
942
|
+
if (expiredContent.length === RECONCILE_BATCH)
|
|
943
|
+
more = true;
|
|
944
|
+
if (more)
|
|
945
|
+
await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
|
|
946
|
+
return { purged };
|
|
831
947
|
},
|
|
832
948
|
});
|
|
833
949
|
export const setRetention = mutation({
|
|
@@ -1040,15 +1156,25 @@ export const adjustBucket = mutation({
|
|
|
1040
1156
|
const dSame = b.dayStamp === today;
|
|
1041
1157
|
const mSame = b.monthStamp === month;
|
|
1042
1158
|
const dt = tokens ?? 0;
|
|
1159
|
+
// Gross-plus-credits ledger: a positive delta is a real extra charge (adds
|
|
1160
|
+
// to GROSS spend); a negative delta is a credit/refund that accrues to a
|
|
1161
|
+
// separate credits balance and NEVER reduces gross spend — so gross spend
|
|
1162
|
+
// and durable history stay consistent, and net = gross - credits. Credits
|
|
1163
|
+
// grant headroom because admission subtracts them from the cap check.
|
|
1164
|
+
const debit = deltaNanos > 0 ? deltaNanos : 0;
|
|
1165
|
+
const credit = deltaNanos < 0 ? -deltaNanos : 0;
|
|
1043
1166
|
await ctx.db.patch(b._id, {
|
|
1044
|
-
totalSpendNanos:
|
|
1167
|
+
totalSpendNanos: b.totalSpendNanos + debit,
|
|
1045
1168
|
totalTokens: Math.max(0, b.totalTokens + dt),
|
|
1046
1169
|
dayStamp: today,
|
|
1047
1170
|
monthStamp: month,
|
|
1048
|
-
spendTodayNanos:
|
|
1171
|
+
spendTodayNanos: (dSame ? b.spendTodayNanos : 0) + debit,
|
|
1049
1172
|
tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
|
|
1050
|
-
spendThisMonthNanos:
|
|
1173
|
+
spendThisMonthNanos: (mSame ? b.spendThisMonthNanos ?? 0 : 0) + debit,
|
|
1051
1174
|
tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
|
|
1175
|
+
creditsNanos: (b.creditsNanos ?? 0) + credit,
|
|
1176
|
+
creditsTodayNanos: (dSame ? b.creditsTodayNanos ?? 0 : 0) + credit,
|
|
1177
|
+
creditsThisMonthNanos: (mSame ? b.creditsThisMonthNanos ?? 0 : 0) + credit,
|
|
1052
1178
|
// Advancing the window here must also clear the OLD window's reserved
|
|
1053
1179
|
// holds, or an in-flight request from the previous day/month would be
|
|
1054
1180
|
// treated as reserving against the new window and its later release would
|
|
@@ -1057,8 +1183,10 @@ export const adjustBucket = mutation({
|
|
|
1057
1183
|
...(mSame ? {} : { reservedMonthNanos: 0, reservedMonthTokens: 0 }),
|
|
1058
1184
|
});
|
|
1059
1185
|
await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
|
|
1060
|
-
|
|
1061
|
-
|
|
1186
|
+
// Durable history tracks GROSS spend only (debits); credits live in the
|
|
1187
|
+
// adjustments log + bucket balance, so usage rollups never go negative.
|
|
1188
|
+
await addUsage(ctx, dimension, value, "day", today, debit, dt, 0);
|
|
1189
|
+
await addUsage(ctx, dimension, value, "month", month, debit, dt, 0);
|
|
1062
1190
|
return null;
|
|
1063
1191
|
},
|
|
1064
1192
|
});
|
|
@@ -1099,6 +1227,31 @@ export const setAlertDefaults = mutation({
|
|
|
1099
1227
|
return null;
|
|
1100
1228
|
},
|
|
1101
1229
|
});
|
|
1230
|
+
// Deployment-wide data/pricing policy. Only the fields you pass change.
|
|
1231
|
+
// - allowUnpricedModels: false rejects models with no configured price under
|
|
1232
|
+
// hard enforcement (default true — charge the conservative fallback).
|
|
1233
|
+
// - storeContent: false stops persisting prompt/response content on request
|
|
1234
|
+
// rows (default true).
|
|
1235
|
+
export const setDeploymentPolicy = mutation({
|
|
1236
|
+
args: {
|
|
1237
|
+
allowUnpricedModels: v.optional(v.boolean()),
|
|
1238
|
+
storeContent: v.optional(v.boolean()),
|
|
1239
|
+
},
|
|
1240
|
+
returns: v.null(),
|
|
1241
|
+
handler: async (ctx, args) => {
|
|
1242
|
+
const patch = {};
|
|
1243
|
+
if ("allowUnpricedModels" in args)
|
|
1244
|
+
patch.allowUnpricedModels = args.allowUnpricedModels;
|
|
1245
|
+
if ("storeContent" in args)
|
|
1246
|
+
patch.storeContent = args.storeContent;
|
|
1247
|
+
const existing = await getSettings(ctx);
|
|
1248
|
+
if (existing)
|
|
1249
|
+
await ctx.db.patch(existing._id, patch);
|
|
1250
|
+
else
|
|
1251
|
+
await ctx.db.insert("settings", { key: "singleton", ...patch });
|
|
1252
|
+
return null;
|
|
1253
|
+
},
|
|
1254
|
+
});
|
|
1102
1255
|
// Delete a bucket and (for the `user` dimension) all of that user's request
|
|
1103
1256
|
// rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
|
|
1104
1257
|
// self-reschedules so it never exceeds the per-transaction document limit.
|
|
@@ -1176,7 +1329,7 @@ export const getGlobalStatus = query({
|
|
|
1176
1329
|
returns: v.object({
|
|
1177
1330
|
dailySpendLimitNanos: v.union(v.number(), v.null()),
|
|
1178
1331
|
lifetimeSpendLimitNanos: v.union(v.number(), v.null()),
|
|
1179
|
-
enforcement: v.union(v.literal("
|
|
1332
|
+
enforcement: v.union(v.literal("approximate"), v.literal("soft")),
|
|
1180
1333
|
spentTodayNanos: v.number(),
|
|
1181
1334
|
spentTotalNanos: v.number(),
|
|
1182
1335
|
// deployment-wide config (surfaced for the admin dashboard)
|
|
@@ -1191,7 +1344,7 @@ export const getGlobalStatus = query({
|
|
|
1191
1344
|
return {
|
|
1192
1345
|
dailySpendLimitNanos: s?.globalDailySpendLimitNanos ?? null,
|
|
1193
1346
|
lifetimeSpendLimitNanos: s?.globalLifetimeSpendLimitNanos ?? null,
|
|
1194
|
-
enforcement: s?.globalEnforcement ?? "
|
|
1347
|
+
enforcement: s?.globalEnforcement ?? "approximate",
|
|
1195
1348
|
spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
|
|
1196
1349
|
spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
|
|
1197
1350
|
retentionMs: s?.retentionMs ?? null,
|
|
@@ -1207,7 +1360,7 @@ export const setGlobalLimits = mutation({
|
|
|
1207
1360
|
// other global controls by patching them to undefined.)
|
|
1208
1361
|
dailySpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1209
1362
|
lifetimeSpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1210
|
-
enforcement: v.optional(v.union(v.literal("
|
|
1363
|
+
enforcement: v.optional(v.union(v.literal("approximate"), v.literal("soft"), v.null())),
|
|
1211
1364
|
},
|
|
1212
1365
|
returns: v.null(),
|
|
1213
1366
|
handler: async (ctx, args) => {
|
|
@@ -63,6 +63,9 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
63
63
|
lifetimeBumpNanos?: number | undefined;
|
|
64
64
|
bumpDayStamp?: string | undefined;
|
|
65
65
|
bumpMonthStamp?: string | undefined;
|
|
66
|
+
creditsNanos?: number | undefined;
|
|
67
|
+
creditsTodayNanos?: number | undefined;
|
|
68
|
+
creditsThisMonthNanos?: number | undefined;
|
|
66
69
|
tokensToday?: number | undefined;
|
|
67
70
|
monthStamp?: string | undefined;
|
|
68
71
|
spendThisMonthNanos?: number | undefined;
|
|
@@ -103,6 +106,9 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
103
106
|
totalSpendNanos: import("convex/values").VFloat64<number, "required">;
|
|
104
107
|
totalRequests: import("convex/values").VFloat64<number, "required">;
|
|
105
108
|
totalTokens: import("convex/values").VFloat64<number, "required">;
|
|
109
|
+
creditsNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
110
|
+
creditsTodayNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
111
|
+
creditsThisMonthNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
106
112
|
dayStamp: import("convex/values").VString<string, "required">;
|
|
107
113
|
spendTodayNanos: import("convex/values").VFloat64<number, "required">;
|
|
108
114
|
tokensToday: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
@@ -116,7 +122,7 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
116
122
|
reservedMonthTokens: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
117
123
|
reservedTotalTokens: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
118
124
|
pendingCount: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
119
|
-
}, "required", "dimension" | "value" | "requestsPerMinute" | "maxConcurrent" | "dailySpendLimitNanos" | "monthlySpendLimitNanos" | "lifetimeSpendLimitNanos" | "dailyTokenLimit" | "monthlyTokenLimit" | "lifetimeTokenLimit" | "blocked" | "warnAtPct" | "enforcement" | "dailyBumpNanos" | "monthlyBumpNanos" | "lifetimeBumpNanos" | "bumpDayStamp" | "bumpMonthStamp" | "totalSpendNanos" | "totalRequests" | "totalTokens" | "dayStamp" | "spendTodayNanos" | "tokensToday" | "monthStamp" | "spendThisMonthNanos" | "tokensThisMonth" | "reservedTodayNanos" | "reservedMonthNanos" | "reservedTotalNanos" | "reservedTodayTokens" | "reservedMonthTokens" | "reservedTotalTokens" | "pendingCount">, {
|
|
125
|
+
}, "required", "dimension" | "value" | "requestsPerMinute" | "maxConcurrent" | "dailySpendLimitNanos" | "monthlySpendLimitNanos" | "lifetimeSpendLimitNanos" | "dailyTokenLimit" | "monthlyTokenLimit" | "lifetimeTokenLimit" | "blocked" | "warnAtPct" | "enforcement" | "dailyBumpNanos" | "monthlyBumpNanos" | "lifetimeBumpNanos" | "bumpDayStamp" | "bumpMonthStamp" | "totalSpendNanos" | "totalRequests" | "totalTokens" | "creditsNanos" | "creditsTodayNanos" | "creditsThisMonthNanos" | "dayStamp" | "spendTodayNanos" | "tokensToday" | "monthStamp" | "spendThisMonthNanos" | "tokensThisMonth" | "reservedTodayNanos" | "reservedMonthNanos" | "reservedTotalNanos" | "reservedTodayTokens" | "reservedMonthTokens" | "reservedTotalTokens" | "pendingCount">, {
|
|
120
126
|
dim_value: ["dimension", "value", "_creationTime"];
|
|
121
127
|
dimension: ["dimension", "_creationTime"];
|
|
122
128
|
}, {}, {}>;
|
|
@@ -279,13 +285,18 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
279
285
|
models?: string[] | undefined;
|
|
280
286
|
globalDailySpendLimitNanos?: number | undefined;
|
|
281
287
|
globalLifetimeSpendLimitNanos?: number | undefined;
|
|
282
|
-
globalEnforcement?: "
|
|
288
|
+
globalEnforcement?: "soft" | "approximate" | undefined;
|
|
283
289
|
globalDailyBumpNanos?: number | undefined;
|
|
284
290
|
globalLifetimeBumpNanos?: number | undefined;
|
|
285
291
|
globalBumpDayStamp?: string | undefined;
|
|
292
|
+
globalTrippedDaily?: boolean | undefined;
|
|
293
|
+
globalTrippedLifetime?: boolean | undefined;
|
|
294
|
+
globalNearLimit?: boolean | undefined;
|
|
286
295
|
retentionMs?: number | undefined;
|
|
287
296
|
defaultWarnAtPct?: number | undefined;
|
|
288
297
|
serverToolPrices?: Record<string, number> | undefined;
|
|
298
|
+
allowUnpricedModels?: boolean | undefined;
|
|
299
|
+
storeContent?: boolean | undefined;
|
|
289
300
|
key: string;
|
|
290
301
|
}, {
|
|
291
302
|
key: import("convex/values").VString<string, "required">;
|
|
@@ -293,14 +304,19 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
293
304
|
models: import("convex/values").VArray<string[] | undefined, import("convex/values").VString<string, "required">, "optional">;
|
|
294
305
|
globalDailySpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
295
306
|
globalLifetimeSpendLimitNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
296
|
-
globalEnforcement: import("convex/values").VUnion<"
|
|
307
|
+
globalEnforcement: import("convex/values").VUnion<"soft" | "approximate" | undefined, [import("convex/values").VLiteral<"approximate", "required">, import("convex/values").VLiteral<"soft", "required">], "optional", never>;
|
|
297
308
|
globalDailyBumpNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
298
309
|
globalLifetimeBumpNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
299
310
|
globalBumpDayStamp: import("convex/values").VString<string | undefined, "optional">;
|
|
311
|
+
globalTrippedDaily: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
312
|
+
globalTrippedLifetime: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
313
|
+
globalNearLimit: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
300
314
|
retentionMs: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
301
315
|
defaultWarnAtPct: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
302
316
|
serverToolPrices: import("convex/values").VRecord<Record<string, number> | undefined, import("convex/values").VString<string, "required">, import("convex/values").VFloat64<number, "required">, "optional", string>;
|
|
303
|
-
|
|
317
|
+
allowUnpricedModels: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
318
|
+
storeContent: import("convex/values").VBoolean<boolean | undefined, "optional">;
|
|
319
|
+
}, "required", "key" | "modelMode" | "models" | "globalDailySpendLimitNanos" | "globalLifetimeSpendLimitNanos" | "globalEnforcement" | "globalDailyBumpNanos" | "globalLifetimeBumpNanos" | "globalBumpDayStamp" | "globalTrippedDaily" | "globalTrippedLifetime" | "globalNearLimit" | "retentionMs" | "defaultWarnAtPct" | "serverToolPrices" | "allowUnpricedModels" | "storeContent" | `serverToolPrices.${string}`>, {
|
|
304
320
|
key: ["key", "_creationTime"];
|
|
305
321
|
}, {}, {}>;
|
|
306
322
|
}, true>;
|
package/dist/component/schema.js
CHANGED
|
@@ -56,10 +56,17 @@ export default defineSchema({
|
|
|
56
56
|
lifetimeBumpNanos: v.optional(v.number()),
|
|
57
57
|
bumpDayStamp: v.optional(v.string()),
|
|
58
58
|
bumpMonthStamp: v.optional(v.string()),
|
|
59
|
-
// settled
|
|
59
|
+
// settled GROSS spend (real charges + positive adjustments). Credits never
|
|
60
|
+
// reduce these — they accrue separately below — so gross spend and durable
|
|
61
|
+
// history always agree. Cap checks and "net" use (gross - credits).
|
|
60
62
|
totalSpendNanos: v.number(),
|
|
61
63
|
totalRequests: v.number(),
|
|
62
64
|
totalTokens: v.number(),
|
|
65
|
+
// manual credits (comps/refunds), tracked separately from gross spend and
|
|
66
|
+
// subtracted at admission so a credit grants real headroom under a cap.
|
|
67
|
+
creditsNanos: v.optional(v.number()),
|
|
68
|
+
creditsTodayNanos: v.optional(v.number()),
|
|
69
|
+
creditsThisMonthNanos: v.optional(v.number()),
|
|
63
70
|
// daily window
|
|
64
71
|
dayStamp: v.string(),
|
|
65
72
|
spendTodayNanos: v.number(),
|
|
@@ -191,10 +198,21 @@ export default defineSchema({
|
|
|
191
198
|
// killswitch; per-bucket concurrent admission is atomic via reserve/settle.
|
|
192
199
|
globalDailySpendLimitNanos: v.optional(v.number()),
|
|
193
200
|
globalLifetimeSpendLimitNanos: v.optional(v.number()),
|
|
194
|
-
|
|
201
|
+
// "approximate" (default): a best-effort killswitch — it blocks once the
|
|
202
|
+
// sharded total crosses the cap, but with bounded overshoot (no per-request
|
|
203
|
+
// reservation). "soft": warn only. There is deliberately no "hard": a true
|
|
204
|
+
// to-the-dollar ceiling is a per-bucket cap.
|
|
205
|
+
globalEnforcement: v.optional(v.union(v.literal("approximate"), v.literal("soft"))),
|
|
195
206
|
globalDailyBumpNanos: v.optional(v.number()),
|
|
196
207
|
globalLifetimeBumpNanos: v.optional(v.number()),
|
|
197
208
|
globalBumpDayStamp: v.optional(v.string()),
|
|
209
|
+
// H4: the reconciler compares the sharded global total to the cap and sets
|
|
210
|
+
// these, so admission reads ONE settings doc instead of the sharded counter
|
|
211
|
+
// (whose per-admission read contended with every fold). Killswitch lag is
|
|
212
|
+
// bounded by the reconcile interval — fine for a deployment-wide stop.
|
|
213
|
+
globalTrippedDaily: v.optional(v.boolean()),
|
|
214
|
+
globalTrippedLifetime: v.optional(v.boolean()),
|
|
215
|
+
globalNearLimit: v.optional(v.boolean()),
|
|
198
216
|
// request-row retention window in ms (default 1h); 0 disables sweeping.
|
|
199
217
|
retentionMs: v.optional(v.number()),
|
|
200
218
|
// default approaching-limit alert threshold (fraction of a cap) for buckets
|
|
@@ -203,5 +221,13 @@ export default defineSchema({
|
|
|
203
221
|
// per-call price (nanodollars) overrides for provider server tools, keyed by
|
|
204
222
|
// tool name (e.g. { web_search: 12_000_000 }). Merged over the defaults.
|
|
205
223
|
serverToolPrices: v.optional(v.record(v.string(), v.number())),
|
|
224
|
+
// When false, reject requests for a model with no known/override price under
|
|
225
|
+
// hard enforcement (instead of charging the conservative fallback). Default
|
|
226
|
+
// true (charge the fallback, keep the call working).
|
|
227
|
+
allowUnpricedModels: v.optional(v.boolean()),
|
|
228
|
+
// When false, don't persist prompt/response content on request rows (only
|
|
229
|
+
// metadata + cost). Default true. For teams that want zero prompt retention
|
|
230
|
+
// rather than short retention.
|
|
231
|
+
storeContent: v.optional(v.boolean()),
|
|
206
232
|
}).index("key", ["key"]),
|
|
207
233
|
});
|