@convex-dev/ai-budget 0.0.2-alpha.16 → 0.0.2-alpha.18
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 +50 -11
- package/dist/client/dashboard.js +12 -2
- package/dist/client/index.js +128 -62
- package/dist/component/_generated/component.d.ts +3 -3
- package/dist/component/lib.d.ts +9 -4
- package/dist/component/lib.js +215 -65
- package/package.json +1 -1
- package/src/client/dashboard.ts +12 -2
- package/src/client/index.ts +133 -60
- package/src/component/_generated/component.ts +3 -3
- package/src/component/lib.test.ts +155 -18
- package/src/component/lib.ts +219 -69
package/src/component/lib.ts
CHANGED
|
@@ -20,6 +20,37 @@ import { ShardedCounter } from "@convex-dev/sharded-counter";
|
|
|
20
20
|
const NANOS_PER_DOLLAR = 1e9;
|
|
21
21
|
const fmtUsd = (nanos: number) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
|
|
22
22
|
|
|
23
|
+
// Convex's `v.number()` accepts NaN and ±Infinity. Those are poison here: a NaN
|
|
24
|
+
// cap or count silently defeats every `used > cap` comparison (NaN > x is
|
|
25
|
+
// false), so an unvalidated NaN would make admission fail OPEN and admit
|
|
26
|
+
// unlimited spend; +Infinity in totals is just as corrupting. Validate every
|
|
27
|
+
// externally-supplied accounting amount at the mutation boundary.
|
|
28
|
+
function assertAmount(
|
|
29
|
+
n: number | undefined,
|
|
30
|
+
name: string,
|
|
31
|
+
{ signed = false }: { signed?: boolean } = {}
|
|
32
|
+
) {
|
|
33
|
+
if (n === undefined) return;
|
|
34
|
+
if (!Number.isFinite(n) || !Number.isSafeInteger(n)) {
|
|
35
|
+
throw new Error(`${name} must be a finite safe integer (got ${n})`);
|
|
36
|
+
}
|
|
37
|
+
if (!signed && n < 0) throw new Error(`${name} must be nonnegative (got ${n})`);
|
|
38
|
+
}
|
|
39
|
+
function assertFraction(n: number | undefined, name: string) {
|
|
40
|
+
if (n === undefined) return;
|
|
41
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
|
42
|
+
throw new Error(`${name} must be a number in [0, 1] (got ${n})`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Coerce a caller/provider-supplied count to a finite nonnegative integer,
|
|
46
|
+
// mapping NaN/Infinity/garbage to 0 rather than poisoning downstream totals.
|
|
47
|
+
const safeCount = (n: number | undefined) =>
|
|
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);
|
|
53
|
+
|
|
23
54
|
// Built-in attribution dimensions. `user` and `action` are always populated
|
|
24
55
|
// from a request's userId/actionName; apps can add any other dimensions
|
|
25
56
|
// (team, project, customer, env, …) as tags. These two names are reserved —
|
|
@@ -76,6 +107,16 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
|
|
|
76
107
|
// audit table — and the sensitive content in it — from growing without bound.
|
|
77
108
|
// Override per-deployment via setRetention.
|
|
78
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
|
|
79
120
|
|
|
80
121
|
const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
|
|
81
122
|
const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
|
|
@@ -91,12 +132,17 @@ const CACHE_DISCOUNT = 0.1;
|
|
|
91
132
|
// bills real money. Charging the conservative max instead keeps the caps honest
|
|
92
133
|
// (over-counting is the safe direction); admins can pin an exact price via
|
|
93
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.
|
|
94
140
|
const CONSERVATIVE_PRICE = Object.values(DEFAULT_PRICES).reduce(
|
|
95
141
|
(m, p) => ({
|
|
96
142
|
input: Math.max(m.input, p.input),
|
|
97
143
|
output: Math.max(m.output, p.output),
|
|
98
144
|
}),
|
|
99
|
-
{ input:
|
|
145
|
+
{ input: 20_000_000_000, output: 100_000_000_000 }
|
|
100
146
|
);
|
|
101
147
|
|
|
102
148
|
async function getPrice(ctx: MutationCtx, model: string) {
|
|
@@ -473,6 +519,11 @@ export const startRequest = mutation({
|
|
|
473
519
|
if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
|
|
474
520
|
throw new Error("reserveTtlMs must be finite and nonnegative");
|
|
475
521
|
}
|
|
522
|
+
// Infinity/NaN here is catastrophic: it's reserved onto the bucket, and a
|
|
523
|
+
// later release computes `Infinity - Infinity = NaN`, leaving the reserved
|
|
524
|
+
// fields NaN forever — after which every `used > cap` check is `NaN > cap`
|
|
525
|
+
// (false) and the bucket admits unlimited spend. Reject it up front.
|
|
526
|
+
assertAmount(args.estimatedCostNanos, "estimatedCostNanos");
|
|
476
527
|
const extraTags = sanitizeExtraTags(args.tags);
|
|
477
528
|
// Record the blocked attempt and return a rejection (throwing would roll
|
|
478
529
|
// back the record). `persist` is false for the high-frequency-by-design
|
|
@@ -683,8 +734,14 @@ export const startRequest = mutation({
|
|
|
683
734
|
}
|
|
684
735
|
|
|
685
736
|
// Consume only after every admission check succeeds, in the same
|
|
686
|
-
// transaction as the reservations and request insert.
|
|
687
|
-
//
|
|
737
|
+
// transaction as the reservations and request insert. `throws: true` is
|
|
738
|
+
// deliberate, not a rough edge: we already `.check`ed every bucket above in
|
|
739
|
+
// this same serializable transaction, so a `.limit` here cannot fail on a
|
|
740
|
+
// bucket that passed check — and if it somehow did (or a later bucket did),
|
|
741
|
+
// throwing rolls back the WHOLE transaction, including the rate we already
|
|
742
|
+
// consumed on earlier buckets. Converting this to a graceful `{allowed:false}`
|
|
743
|
+
// return would COMMIT the partial consumption and leak rate capacity, so keep
|
|
744
|
+
// the throw.
|
|
688
745
|
for (const b of buckets) {
|
|
689
746
|
if (b.requestsPerMinute !== undefined) {
|
|
690
747
|
await requestRateLimiter.limit(ctx, "requests", {
|
|
@@ -771,7 +828,10 @@ export const finishRequest = mutation({
|
|
|
771
828
|
returns: v.object({ costNanos: v.number() }),
|
|
772
829
|
handler: async (ctx, args) => {
|
|
773
830
|
const request = await ctx.db.get(args.requestId);
|
|
774
|
-
|
|
831
|
+
// The request may be gone — retention purged it, or the owning bucket was
|
|
832
|
+
// deleted. A late/duplicate webhook must be an idempotent no-op, not a 500
|
|
833
|
+
// (the caller can't do anything useful with the error, and it triggers retries).
|
|
834
|
+
if (!request) return { costNanos: 0 };
|
|
775
835
|
|
|
776
836
|
// Expiry releases capacity, but is not evidence that the provider charged
|
|
777
837
|
// nothing. Accept one final result even after expiry; duplicates stay no-ops.
|
|
@@ -779,26 +839,36 @@ export const finishRequest = mutation({
|
|
|
779
839
|
return { costNanos: request.costNanos ?? 0 };
|
|
780
840
|
}
|
|
781
841
|
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
|
|
785
|
-
const
|
|
786
|
-
const
|
|
842
|
+
// Coerce caller/provider-supplied token counts to finite nonnegative
|
|
843
|
+
// integers: negatives would refund below a cap, and NaN/Infinity (which
|
|
844
|
+
// v.number() allows) would poison every downstream total and cap check.
|
|
845
|
+
const promptTokens = safeCount(args.promptTokens);
|
|
846
|
+
const completionTokens = safeCount(args.completionTokens);
|
|
847
|
+
const cachedTokens = Math.min(promptTokens, safeCount(args.cachedTokens));
|
|
787
848
|
// Prefer an authoritative gateway cost when supplied (it already includes
|
|
788
849
|
// tool fees); otherwise price from tokens — discounting the cached
|
|
789
|
-
// (prompt-cache-read) slice — plus any server-tool per-call fees.
|
|
850
|
+
// (prompt-cache-read) slice — plus any server-tool per-call fees. Require it
|
|
851
|
+
// finite: +Infinity passes a bare `>= 0` and would corrupt the totals.
|
|
790
852
|
let costNanos: number;
|
|
791
|
-
if (args.costNanos !== undefined && args.costNanos >= 0) {
|
|
853
|
+
if (args.costNanos !== undefined && Number.isFinite(args.costNanos) && args.costNanos >= 0) {
|
|
792
854
|
costNanos = Math.round(args.costNanos);
|
|
793
855
|
} else {
|
|
794
856
|
const settings = await getSettings(ctx);
|
|
795
|
-
|
|
857
|
+
const priced =
|
|
796
858
|
settleCost(
|
|
797
859
|
promptTokens,
|
|
798
860
|
cachedTokens,
|
|
799
861
|
completionTokens,
|
|
800
862
|
await getPrice(ctx, request.model)
|
|
801
863
|
) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
|
|
864
|
+
// Fail closed: if the settle carried NO usable cost signal — no tokens and
|
|
865
|
+
// no priced server-tool fee (an unpriced tool like `video_seconds`, or a
|
|
866
|
+
// bare settle()) — fall back to the reserved estimate rather than recording
|
|
867
|
+
// $0. Known-cost calls (image/video/audio) set estimatedCostNanos at
|
|
868
|
+
// reserve time precisely so this floor is their real cost. A caller that
|
|
869
|
+
// truly wants $0 passes an explicit authoritative costNanos: 0 above.
|
|
870
|
+
const noSignal = promptTokens === 0 && completionTokens === 0 && priced === 0;
|
|
871
|
+
costNanos = noSignal ? (request.estimatedNanos ?? 0) : priced;
|
|
802
872
|
}
|
|
803
873
|
|
|
804
874
|
// Durable write to the request's OWN row only — uncontended, so it always
|
|
@@ -841,13 +911,13 @@ async function releaseReservation(ctx: MutationCtx, req: Doc<"requests">) {
|
|
|
841
911
|
const b = await getBucketDoc(ctx, t.dimension, t.value);
|
|
842
912
|
if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b))) continue;
|
|
843
913
|
await ctx.db.patch(b._id, {
|
|
844
|
-
reservedTodayNanos: Math.max(0, (b.reservedTodayNanos
|
|
845
|
-
reservedMonthNanos: Math.max(0, (b.reservedMonthNanos
|
|
846
|
-
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos
|
|
847
|
-
reservedTodayTokens: Math.max(0, (b.reservedTodayTokens
|
|
848
|
-
reservedMonthTokens: Math.max(0, (b.reservedMonthTokens
|
|
849
|
-
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens
|
|
850
|
-
pendingCount: Math.max(0, (b.pendingCount
|
|
914
|
+
reservedTodayNanos: Math.max(0, fin(b.reservedTodayNanos) - (b.dayStamp === day ? cost : 0)),
|
|
915
|
+
reservedMonthNanos: Math.max(0, fin(b.reservedMonthNanos) - (b.monthStamp === month ? cost : 0)),
|
|
916
|
+
reservedTotalNanos: Math.max(0, fin(b.reservedTotalNanos) - cost),
|
|
917
|
+
reservedTodayTokens: Math.max(0, fin(b.reservedTodayTokens) - (b.dayStamp === day ? tokens : 0)),
|
|
918
|
+
reservedMonthTokens: Math.max(0, fin(b.reservedMonthTokens) - (b.monthStamp === month ? tokens : 0)),
|
|
919
|
+
reservedTotalTokens: Math.max(0, fin(b.reservedTotalTokens) - tokens),
|
|
920
|
+
pendingCount: Math.max(0, fin(b.pendingCount) - 1),
|
|
851
921
|
});
|
|
852
922
|
}
|
|
853
923
|
await ctx.db.patch(req._id, { reservationReleased: true });
|
|
@@ -905,29 +975,52 @@ export const foldTotals = internalMutation({
|
|
|
905
975
|
// Backstop for both failure modes: folds finished requests whose scheduled fold
|
|
906
976
|
// lost the retry race, and releases reservations for requests that never
|
|
907
977
|
// settled (their action crashed). Runs on a cron.
|
|
978
|
+
// Cron entry: kick off each reconciliation phase as its OWN transaction so a
|
|
979
|
+
// failure in one (e.g. an oversized retention scan) can't stall the others, and
|
|
980
|
+
// so the hot fold path doesn't share a transaction with retention. Each phase
|
|
981
|
+
// self-reschedules while it has a full batch of backlog.
|
|
908
982
|
export const reconcile = internalMutation({
|
|
909
983
|
args: {},
|
|
910
|
-
returns: v.
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
984
|
+
returns: v.null(),
|
|
985
|
+
handler: async (ctx) => {
|
|
986
|
+
await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
|
|
987
|
+
await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
|
|
988
|
+
await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
|
|
989
|
+
return null;
|
|
990
|
+
},
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
// Fold finished-but-unfolded requests whose scheduled fold lost the OCC race.
|
|
994
|
+
export const foldPhase = internalMutation({
|
|
995
|
+
args: {},
|
|
996
|
+
returns: v.object({ folded: v.number() }),
|
|
915
997
|
handler: async (ctx) => {
|
|
916
998
|
const toFold = await ctx.db
|
|
917
999
|
.query("requests")
|
|
918
1000
|
.withIndex("settled", (q) => q.eq("settled", false))
|
|
919
|
-
.take(
|
|
1001
|
+
.take(RECONCILE_BATCH);
|
|
920
1002
|
for (const req of toFold) await foldOne(ctx, req);
|
|
1003
|
+
if (toFold.length === RECONCILE_BATCH)
|
|
1004
|
+
await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
|
|
1005
|
+
return { folded: toFold.length };
|
|
1006
|
+
},
|
|
1007
|
+
});
|
|
921
1008
|
|
|
1009
|
+
// Release reservations for requests that never settled (their action crashed),
|
|
1010
|
+
// and lazily backfill deadlines on legacy pending rows.
|
|
1011
|
+
export const expirePhase = internalMutation({
|
|
1012
|
+
args: {},
|
|
1013
|
+
returns: v.object({ expired: v.number() }),
|
|
1014
|
+
handler: async (ctx) => {
|
|
922
1015
|
// Lazily migrate old pending rows in bounded batches. Once indexed, long
|
|
923
1016
|
// TTL jobs cannot hide expired jobs behind them in creation-time order.
|
|
924
1017
|
const legacy = await ctx.db.query("requests").withIndex("status_expires", q =>
|
|
925
|
-
q.eq("status", "pending").eq("expiresAt", undefined)).take(
|
|
1018
|
+
q.eq("status", "pending").eq("expiresAt", undefined)).take(RECONCILE_BATCH);
|
|
926
1019
|
for (const req of legacy) {
|
|
927
1020
|
await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
|
|
928
1021
|
}
|
|
929
1022
|
const candidates = await ctx.db.query("requests").withIndex("status_expires", q =>
|
|
930
|
-
q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(
|
|
1023
|
+
q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(RECONCILE_BATCH);
|
|
931
1024
|
for (const req of candidates) {
|
|
932
1025
|
await releaseReservation(ctx, req);
|
|
933
1026
|
await ctx.db.patch(req._id, {
|
|
@@ -935,37 +1028,58 @@ export const reconcile = internalMutation({
|
|
|
935
1028
|
reservationExpired: true, expiresAt: undefined, settled: true,
|
|
936
1029
|
});
|
|
937
1030
|
}
|
|
938
|
-
|
|
1031
|
+
if (legacy.length === RECONCILE_BATCH || candidates.length === RECONCILE_BATCH)
|
|
1032
|
+
await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
|
|
1033
|
+
return { expired: candidates.length };
|
|
1034
|
+
},
|
|
1035
|
+
});
|
|
939
1036
|
|
|
940
|
-
|
|
1037
|
+
// Delete terminal, fully-accounted request rows past the retention window; purge
|
|
1038
|
+
// content from expired tombstones; and finally delete tombstones past the
|
|
1039
|
+
// late-settle horizon so they can't accumulate forever.
|
|
1040
|
+
export const retentionPhase = internalMutation({
|
|
1041
|
+
args: {},
|
|
1042
|
+
returns: v.object({ purged: v.number() }),
|
|
1043
|
+
handler: async (ctx) => {
|
|
941
1044
|
const settings = await getSettings(ctx);
|
|
942
1045
|
const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
1046
|
+
if (retentionMs <= 0) return { purged: 0 };
|
|
1047
|
+
const retentionCutoff = Date.now() - retentionMs;
|
|
943
1048
|
let purged = 0;
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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));
|
|
956
|
-
for (const req of old) {
|
|
1049
|
+
let more = false;
|
|
1050
|
+
const sweep = async (rows: Doc<"requests">[]) => {
|
|
1051
|
+
for (const req of rows) {
|
|
957
1052
|
await deleteRequestTags(ctx, req._id);
|
|
958
1053
|
await ctx.db.delete(req._id);
|
|
959
1054
|
purged++;
|
|
960
1055
|
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1056
|
+
if (rows.length === RECONCILE_BATCH) more = true;
|
|
1057
|
+
};
|
|
1058
|
+
// Settled, non-expired terminal rows past the window.
|
|
1059
|
+
for (const expiredFlag of [undefined, false] as const) {
|
|
1060
|
+
await sweep(await ctx.db.query("requests").withIndex("retention", q =>
|
|
1061
|
+
q.eq("reservationExpired", expiredFlag).eq("settled", true)
|
|
1062
|
+
.lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
|
|
967
1063
|
}
|
|
968
|
-
|
|
1064
|
+
// Blocked attempts past the window.
|
|
1065
|
+
await sweep(await ctx.db.query("requests").withIndex("status", q =>
|
|
1066
|
+
q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
|
|
1067
|
+
// Expired billing tombstones past the late-settle horizon (content already
|
|
1068
|
+
// gone). Without this they'd live forever (one per crashed request).
|
|
1069
|
+
const tombstoneCutoff = Date.now() - Math.max(retentionMs, LATE_SETTLE_HORIZON_MS);
|
|
1070
|
+
await sweep(await ctx.db.query("requests").withIndex("retention", q =>
|
|
1071
|
+
q.eq("reservationExpired", true).eq("settled", true)
|
|
1072
|
+
.lt("_creationTime", tombstoneCutoff)).take(RECONCILE_BATCH));
|
|
1073
|
+
// Strip PII from expired tombstones still inside the horizon.
|
|
1074
|
+
const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q =>
|
|
1075
|
+
q.eq("reservationExpired", true).eq("contentPurged", undefined)
|
|
1076
|
+
.lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH);
|
|
1077
|
+
for (const req of expiredContent) {
|
|
1078
|
+
await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
|
|
1079
|
+
}
|
|
1080
|
+
if (expiredContent.length === RECONCILE_BATCH) more = true;
|
|
1081
|
+
if (more) await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
|
|
1082
|
+
return { purged };
|
|
969
1083
|
},
|
|
970
1084
|
});
|
|
971
1085
|
|
|
@@ -1115,10 +1229,15 @@ export const setBucketLimits = mutation({
|
|
|
1115
1229
|
},
|
|
1116
1230
|
returns: v.null(),
|
|
1117
1231
|
handler: async (ctx, args) => {
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1232
|
+
assertAmount(args.requestsPerMinute, "requestsPerMinute");
|
|
1233
|
+
assertAmount(args.maxConcurrent, "maxConcurrent");
|
|
1234
|
+
assertAmount(args.dailySpendLimitNanos, "dailySpendLimitNanos");
|
|
1235
|
+
assertAmount(args.monthlySpendLimitNanos, "monthlySpendLimitNanos");
|
|
1236
|
+
assertAmount(args.lifetimeSpendLimitNanos, "lifetimeSpendLimitNanos");
|
|
1237
|
+
assertAmount(args.dailyTokenLimit, "dailyTokenLimit");
|
|
1238
|
+
assertAmount(args.monthlyTokenLimit, "monthlyTokenLimit");
|
|
1239
|
+
assertAmount(args.lifetimeTokenLimit, "lifetimeTokenLimit");
|
|
1240
|
+
assertFraction(args.warnAtPct, "warnAtPct");
|
|
1122
1241
|
const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
|
|
1123
1242
|
const { dimension: _d, value: _v, ...limits } = args;
|
|
1124
1243
|
await ctx.db.patch(bucket._id, limits);
|
|
@@ -1140,6 +1259,9 @@ export const bumpBucket = mutation({
|
|
|
1140
1259
|
args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
|
|
1141
1260
|
returns: v.null(),
|
|
1142
1261
|
handler: async (ctx, { dimension, value, dailyNanos, monthlyNanos, lifetimeNanos }) => {
|
|
1262
|
+
assertAmount(dailyNanos, "dailyNanos");
|
|
1263
|
+
assertAmount(monthlyNanos, "monthlyNanos");
|
|
1264
|
+
assertAmount(lifetimeNanos, "lifetimeNanos");
|
|
1143
1265
|
const bucket = await getOrCreateBucket(ctx, dimension, value);
|
|
1144
1266
|
const today = dayStamp();
|
|
1145
1267
|
const month = monthStamp();
|
|
@@ -1172,6 +1294,8 @@ export const adjustBucket = mutation({
|
|
|
1172
1294
|
},
|
|
1173
1295
|
returns: v.null(),
|
|
1174
1296
|
handler: async (ctx, { dimension, value, deltaNanos, tokens, reason }) => {
|
|
1297
|
+
assertAmount(deltaNanos, "deltaNanos", { signed: true });
|
|
1298
|
+
assertAmount(tokens, "tokens", { signed: true });
|
|
1175
1299
|
const b = await getOrCreateBucket(ctx, dimension, value);
|
|
1176
1300
|
const today = dayStamp();
|
|
1177
1301
|
const month = monthStamp();
|
|
@@ -1187,6 +1311,12 @@ export const adjustBucket = mutation({
|
|
|
1187
1311
|
tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
|
|
1188
1312
|
spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
|
|
1189
1313
|
tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
|
|
1314
|
+
// Advancing the window here must also clear the OLD window's reserved
|
|
1315
|
+
// holds, or an in-flight request from the previous day/month would be
|
|
1316
|
+
// treated as reserving against the new window and its later release would
|
|
1317
|
+
// no longer match — stranding those reserved nanos/tokens.
|
|
1318
|
+
...(dSame ? {} : { reservedTodayNanos: 0, reservedTodayTokens: 0 }),
|
|
1319
|
+
...(mSame ? {} : { reservedMonthNanos: 0, reservedMonthTokens: 0 }),
|
|
1190
1320
|
});
|
|
1191
1321
|
await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
|
|
1192
1322
|
await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
|
|
@@ -1253,6 +1383,15 @@ export const deleteBucket = mutation({
|
|
|
1253
1383
|
.withIndex("userId", (q) => q.eq("userId", value))
|
|
1254
1384
|
.take(DELETE_BATCH);
|
|
1255
1385
|
for (const r of rows) {
|
|
1386
|
+
// Before dropping the row, free or settle any hold it placed on OTHER
|
|
1387
|
+
// (shared) buckets — an action/customer bucket this user's request
|
|
1388
|
+
// reserved against. Otherwise deleting the only row that could release
|
|
1389
|
+
// that hold strands the shared bucket's reservation + pendingCount
|
|
1390
|
+
// forever, and a late finish would throw. A finished-but-unfolded row
|
|
1391
|
+
// is folded (the charge lands on the shared buckets); a still-pending
|
|
1392
|
+
// one just has its reservation released.
|
|
1393
|
+
if (r.settled === false) await foldOne(ctx, r);
|
|
1394
|
+
else if (r.status === "pending") await releaseReservation(ctx, r);
|
|
1256
1395
|
await deleteRequestTags(ctx, r._id);
|
|
1257
1396
|
await ctx.db.delete(r._id);
|
|
1258
1397
|
}
|
|
@@ -1335,18 +1474,31 @@ export const getGlobalStatus = query({
|
|
|
1335
1474
|
|
|
1336
1475
|
export const setGlobalLimits = mutation({
|
|
1337
1476
|
args: {
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1477
|
+
// Absent = leave unchanged; explicit null = clear that limit. (A bare
|
|
1478
|
+
// v.optional(number) that always rebuilt the full patch would let a
|
|
1479
|
+
// one-field edit — exactly what the dashboard sends — silently wipe the
|
|
1480
|
+
// other global controls by patching them to undefined.)
|
|
1481
|
+
dailySpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1482
|
+
lifetimeSpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1483
|
+
enforcement: v.optional(
|
|
1484
|
+
v.union(v.literal("hard"), v.literal("soft"), v.null())
|
|
1485
|
+
),
|
|
1341
1486
|
},
|
|
1342
1487
|
returns: v.null(),
|
|
1343
1488
|
handler: async (ctx, args) => {
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1489
|
+
if (typeof args.dailySpendLimitNanos === "number")
|
|
1490
|
+
assertAmount(args.dailySpendLimitNanos, "dailySpendLimitNanos");
|
|
1491
|
+
if (typeof args.lifetimeSpendLimitNanos === "number")
|
|
1492
|
+
assertAmount(args.lifetimeSpendLimitNanos, "lifetimeSpendLimitNanos");
|
|
1493
|
+
// The settings fields are "global"-prefixed; map the friendly arg names,
|
|
1494
|
+
// touching ONLY the keys the caller actually passed. null -> clear.
|
|
1495
|
+
const patch: Record<string, unknown> = {};
|
|
1496
|
+
if ("dailySpendLimitNanos" in args)
|
|
1497
|
+
patch.globalDailySpendLimitNanos = args.dailySpendLimitNanos ?? undefined;
|
|
1498
|
+
if ("lifetimeSpendLimitNanos" in args)
|
|
1499
|
+
patch.globalLifetimeSpendLimitNanos = args.lifetimeSpendLimitNanos ?? undefined;
|
|
1500
|
+
if ("enforcement" in args)
|
|
1501
|
+
patch.globalEnforcement = args.enforcement ?? undefined;
|
|
1350
1502
|
const existing = await getSettings(ctx);
|
|
1351
1503
|
if (existing) {
|
|
1352
1504
|
await ctx.db.patch(existing._id, patch);
|
|
@@ -1415,13 +1567,11 @@ export const setPrice = mutation({
|
|
|
1415
1567
|
handler: async (ctx, args) => {
|
|
1416
1568
|
// Negative prices would make costOf return a negative cost, which folds
|
|
1417
1569
|
// into totals as a spend *refund* — pushing a user back under their cap.
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
)
|
|
1423
|
-
throw new Error("Prices must be non-negative");
|
|
1424
|
-
}
|
|
1570
|
+
// NaN/Infinity (allowed by v.number()) are just as corrupting — a NaN rate
|
|
1571
|
+
// poisons every settled cost for the model — so require finite integers.
|
|
1572
|
+
assertAmount(args.inputNanosPerMTok, "inputNanosPerMTok");
|
|
1573
|
+
assertAmount(args.outputNanosPerMTok, "outputNanosPerMTok");
|
|
1574
|
+
assertAmount(args.cachedNanosPerMTok, "cachedNanosPerMTok");
|
|
1425
1575
|
const existing = await ctx.db
|
|
1426
1576
|
.query("prices")
|
|
1427
1577
|
.withIndex("model", (q) => q.eq("model", args.model))
|