@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/dist/component/lib.js
CHANGED
|
@@ -12,6 +12,34 @@ import { ShardedCounter } from "@convex-dev/sharded-counter";
|
|
|
12
12
|
// currency code alongside these amounts and convert here, at the one boundary.
|
|
13
13
|
const NANOS_PER_DOLLAR = 1e9;
|
|
14
14
|
const fmtUsd = (nanos) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
|
|
15
|
+
// Convex's `v.number()` accepts NaN and ±Infinity. Those are poison here: a NaN
|
|
16
|
+
// cap or count silently defeats every `used > cap` comparison (NaN > x is
|
|
17
|
+
// false), so an unvalidated NaN would make admission fail OPEN and admit
|
|
18
|
+
// unlimited spend; +Infinity in totals is just as corrupting. Validate every
|
|
19
|
+
// externally-supplied accounting amount at the mutation boundary.
|
|
20
|
+
function assertAmount(n, name, { signed = false } = {}) {
|
|
21
|
+
if (n === undefined)
|
|
22
|
+
return;
|
|
23
|
+
if (!Number.isFinite(n) || !Number.isSafeInteger(n)) {
|
|
24
|
+
throw new Error(`${name} must be a finite safe integer (got ${n})`);
|
|
25
|
+
}
|
|
26
|
+
if (!signed && n < 0)
|
|
27
|
+
throw new Error(`${name} must be nonnegative (got ${n})`);
|
|
28
|
+
}
|
|
29
|
+
function assertFraction(n, name) {
|
|
30
|
+
if (n === undefined)
|
|
31
|
+
return;
|
|
32
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
|
33
|
+
throw new Error(`${name} must be a number in [0, 1] (got ${n})`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Coerce a caller/provider-supplied count to a finite nonnegative integer,
|
|
37
|
+
// mapping NaN/Infinity/garbage to 0 rather than poisoning downstream totals.
|
|
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);
|
|
15
43
|
// Built-in attribution dimensions. `user` and `action` are always populated
|
|
16
44
|
// from a request's userId/actionName; apps can add any other dimensions
|
|
17
45
|
// (team, project, customer, env, …) as tags. These two names are reserved —
|
|
@@ -63,6 +91,16 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
|
|
|
63
91
|
// audit table — and the sensitive content in it — from growing without bound.
|
|
64
92
|
// Override per-deployment via setRetention.
|
|
65
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
|
|
66
104
|
const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
|
|
67
105
|
const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
|
|
68
106
|
// Cached (prompt-cache-read) input tokens are billed far below the normal input
|
|
@@ -75,10 +113,15 @@ const CACHE_DISCOUNT = 0.1;
|
|
|
75
113
|
// bills real money. Charging the conservative max instead keeps the caps honest
|
|
76
114
|
// (over-counting is the safe direction); admins can pin an exact price via
|
|
77
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.
|
|
78
121
|
const CONSERVATIVE_PRICE = Object.values(DEFAULT_PRICES).reduce((m, p) => ({
|
|
79
122
|
input: Math.max(m.input, p.input),
|
|
80
123
|
output: Math.max(m.output, p.output),
|
|
81
|
-
}), { input:
|
|
124
|
+
}), { input: 20_000_000_000, output: 100_000_000_000 });
|
|
82
125
|
async function getPrice(ctx, model) {
|
|
83
126
|
const override = await ctx.db
|
|
84
127
|
.query("prices")
|
|
@@ -355,6 +398,11 @@ export const startRequest = mutation({
|
|
|
355
398
|
if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
|
|
356
399
|
throw new Error("reserveTtlMs must be finite and nonnegative");
|
|
357
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");
|
|
358
406
|
const extraTags = sanitizeExtraTags(args.tags);
|
|
359
407
|
// Record the blocked attempt and return a rejection (throwing would roll
|
|
360
408
|
// back the record). `persist` is false for the high-frequency-by-design
|
|
@@ -525,8 +573,14 @@ export const startRequest = mutation({
|
|
|
525
573
|
notices.push(...globalEval.notices);
|
|
526
574
|
}
|
|
527
575
|
// Consume only after every admission check succeeds, in the same
|
|
528
|
-
// transaction as the reservations and request insert.
|
|
529
|
-
//
|
|
576
|
+
// transaction as the reservations and request insert. `throws: true` is
|
|
577
|
+
// deliberate, not a rough edge: we already `.check`ed every bucket above in
|
|
578
|
+
// this same serializable transaction, so a `.limit` here cannot fail on a
|
|
579
|
+
// bucket that passed check — and if it somehow did (or a later bucket did),
|
|
580
|
+
// throwing rolls back the WHOLE transaction, including the rate we already
|
|
581
|
+
// consumed on earlier buckets. Converting this to a graceful `{allowed:false}`
|
|
582
|
+
// return would COMMIT the partial consumption and leak rate capacity, so keep
|
|
583
|
+
// the throw.
|
|
530
584
|
for (const b of buckets) {
|
|
531
585
|
if (b.requestsPerMinute !== undefined) {
|
|
532
586
|
await requestRateLimiter.limit(ctx, "requests", {
|
|
@@ -612,29 +666,41 @@ export const finishRequest = mutation({
|
|
|
612
666
|
returns: v.object({ costNanos: v.number() }),
|
|
613
667
|
handler: async (ctx, args) => {
|
|
614
668
|
const request = await ctx.db.get(args.requestId);
|
|
669
|
+
// The request may be gone — retention purged it, or the owning bucket was
|
|
670
|
+
// deleted. A late/duplicate webhook must be an idempotent no-op, not a 500
|
|
671
|
+
// (the caller can't do anything useful with the error, and it triggers retries).
|
|
615
672
|
if (!request)
|
|
616
|
-
|
|
673
|
+
return { costNanos: 0 };
|
|
617
674
|
// Expiry releases capacity, but is not evidence that the provider charged
|
|
618
675
|
// nothing. Accept one final result even after expiry; duplicates stay no-ops.
|
|
619
676
|
if (request.status !== "pending" && !request.reservationExpired) {
|
|
620
677
|
return { costNanos: request.costNanos ?? 0 };
|
|
621
678
|
}
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
const
|
|
626
|
-
const
|
|
679
|
+
// Coerce caller/provider-supplied token counts to finite nonnegative
|
|
680
|
+
// integers: negatives would refund below a cap, and NaN/Infinity (which
|
|
681
|
+
// v.number() allows) would poison every downstream total and cap check.
|
|
682
|
+
const promptTokens = safeCount(args.promptTokens);
|
|
683
|
+
const completionTokens = safeCount(args.completionTokens);
|
|
684
|
+
const cachedTokens = Math.min(promptTokens, safeCount(args.cachedTokens));
|
|
627
685
|
// Prefer an authoritative gateway cost when supplied (it already includes
|
|
628
686
|
// tool fees); otherwise price from tokens — discounting the cached
|
|
629
|
-
// (prompt-cache-read) slice — plus any server-tool per-call fees.
|
|
687
|
+
// (prompt-cache-read) slice — plus any server-tool per-call fees. Require it
|
|
688
|
+
// finite: +Infinity passes a bare `>= 0` and would corrupt the totals.
|
|
630
689
|
let costNanos;
|
|
631
|
-
if (args.costNanos !== undefined && args.costNanos >= 0) {
|
|
690
|
+
if (args.costNanos !== undefined && Number.isFinite(args.costNanos) && args.costNanos >= 0) {
|
|
632
691
|
costNanos = Math.round(args.costNanos);
|
|
633
692
|
}
|
|
634
693
|
else {
|
|
635
694
|
const settings = await getSettings(ctx);
|
|
636
|
-
|
|
637
|
-
|
|
695
|
+
const priced = settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model)) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
|
|
696
|
+
// Fail closed: if the settle carried NO usable cost signal — no tokens and
|
|
697
|
+
// no priced server-tool fee (an unpriced tool like `video_seconds`, or a
|
|
698
|
+
// bare settle()) — fall back to the reserved estimate rather than recording
|
|
699
|
+
// $0. Known-cost calls (image/video/audio) set estimatedCostNanos at
|
|
700
|
+
// reserve time precisely so this floor is their real cost. A caller that
|
|
701
|
+
// truly wants $0 passes an explicit authoritative costNanos: 0 above.
|
|
702
|
+
const noSignal = promptTokens === 0 && completionTokens === 0 && priced === 0;
|
|
703
|
+
costNanos = noSignal ? (request.estimatedNanos ?? 0) : priced;
|
|
638
704
|
}
|
|
639
705
|
// Durable write to the request's OWN row only — uncontended, so it always
|
|
640
706
|
// lands. `settled: false` hands it to the fold step; the row is never left
|
|
@@ -676,13 +742,13 @@ async function releaseReservation(ctx, req) {
|
|
|
676
742
|
if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b)))
|
|
677
743
|
continue;
|
|
678
744
|
await ctx.db.patch(b._id, {
|
|
679
|
-
reservedTodayNanos: Math.max(0, (b.reservedTodayNanos
|
|
680
|
-
reservedMonthNanos: Math.max(0, (b.reservedMonthNanos
|
|
681
|
-
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos
|
|
682
|
-
reservedTodayTokens: Math.max(0, (b.reservedTodayTokens
|
|
683
|
-
reservedMonthTokens: Math.max(0, (b.reservedMonthTokens
|
|
684
|
-
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens
|
|
685
|
-
pendingCount: Math.max(0, (b.pendingCount
|
|
745
|
+
reservedTodayNanos: Math.max(0, fin(b.reservedTodayNanos) - (b.dayStamp === day ? cost : 0)),
|
|
746
|
+
reservedMonthNanos: Math.max(0, fin(b.reservedMonthNanos) - (b.monthStamp === month ? cost : 0)),
|
|
747
|
+
reservedTotalNanos: Math.max(0, fin(b.reservedTotalNanos) - cost),
|
|
748
|
+
reservedTodayTokens: Math.max(0, fin(b.reservedTodayTokens) - (b.dayStamp === day ? tokens : 0)),
|
|
749
|
+
reservedMonthTokens: Math.max(0, fin(b.reservedMonthTokens) - (b.monthStamp === month ? tokens : 0)),
|
|
750
|
+
reservedTotalTokens: Math.max(0, fin(b.reservedTotalTokens) - tokens),
|
|
751
|
+
pendingCount: Math.max(0, fin(b.pendingCount) - 1),
|
|
686
752
|
});
|
|
687
753
|
}
|
|
688
754
|
await ctx.db.patch(req._id, { reservationReleased: true });
|
|
@@ -738,27 +804,49 @@ export const foldTotals = internalMutation({
|
|
|
738
804
|
// Backstop for both failure modes: folds finished requests whose scheduled fold
|
|
739
805
|
// lost the retry race, and releases reservations for requests that never
|
|
740
806
|
// settled (their action crashed). Runs on a cron.
|
|
807
|
+
// Cron entry: kick off each reconciliation phase as its OWN transaction so a
|
|
808
|
+
// failure in one (e.g. an oversized retention scan) can't stall the others, and
|
|
809
|
+
// so the hot fold path doesn't share a transaction with retention. Each phase
|
|
810
|
+
// self-reschedules while it has a full batch of backlog.
|
|
741
811
|
export const reconcile = internalMutation({
|
|
742
812
|
args: {},
|
|
743
|
-
returns: v.
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
813
|
+
returns: v.null(),
|
|
814
|
+
handler: async (ctx) => {
|
|
815
|
+
await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
|
|
816
|
+
await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
|
|
817
|
+
await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
|
|
818
|
+
return null;
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
// Fold finished-but-unfolded requests whose scheduled fold lost the OCC race.
|
|
822
|
+
export const foldPhase = internalMutation({
|
|
823
|
+
args: {},
|
|
824
|
+
returns: v.object({ folded: v.number() }),
|
|
748
825
|
handler: async (ctx) => {
|
|
749
826
|
const toFold = await ctx.db
|
|
750
827
|
.query("requests")
|
|
751
828
|
.withIndex("settled", (q) => q.eq("settled", false))
|
|
752
|
-
.take(
|
|
829
|
+
.take(RECONCILE_BATCH);
|
|
753
830
|
for (const req of toFold)
|
|
754
831
|
await foldOne(ctx, req);
|
|
832
|
+
if (toFold.length === RECONCILE_BATCH)
|
|
833
|
+
await ctx.scheduler.runAfter(0, internal.lib.foldPhase, {});
|
|
834
|
+
return { folded: toFold.length };
|
|
835
|
+
},
|
|
836
|
+
});
|
|
837
|
+
// Release reservations for requests that never settled (their action crashed),
|
|
838
|
+
// and lazily backfill deadlines on legacy pending rows.
|
|
839
|
+
export const expirePhase = internalMutation({
|
|
840
|
+
args: {},
|
|
841
|
+
returns: v.object({ expired: v.number() }),
|
|
842
|
+
handler: async (ctx) => {
|
|
755
843
|
// Lazily migrate old pending rows in bounded batches. Once indexed, long
|
|
756
844
|
// TTL jobs cannot hide expired jobs behind them in creation-time order.
|
|
757
|
-
const legacy = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").eq("expiresAt", undefined)).take(
|
|
845
|
+
const legacy = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").eq("expiresAt", undefined)).take(RECONCILE_BATCH);
|
|
758
846
|
for (const req of legacy) {
|
|
759
847
|
await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
|
|
760
848
|
}
|
|
761
|
-
const candidates = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").gt("expiresAt", 0).lte("expiresAt", Date.now())).take(
|
|
849
|
+
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);
|
|
762
850
|
for (const req of candidates) {
|
|
763
851
|
await releaseReservation(ctx, req);
|
|
764
852
|
await ctx.db.patch(req._id, {
|
|
@@ -766,33 +854,57 @@ export const reconcile = internalMutation({
|
|
|
766
854
|
reservationExpired: true, expiresAt: undefined, settled: true,
|
|
767
855
|
});
|
|
768
856
|
}
|
|
769
|
-
|
|
770
|
-
|
|
857
|
+
if (legacy.length === RECONCILE_BATCH || candidates.length === RECONCILE_BATCH)
|
|
858
|
+
await ctx.scheduler.runAfter(0, internal.lib.expirePhase, {});
|
|
859
|
+
return { expired: candidates.length };
|
|
860
|
+
},
|
|
861
|
+
});
|
|
862
|
+
// Delete terminal, fully-accounted request rows past the retention window; purge
|
|
863
|
+
// content from expired tombstones; and finally delete tombstones past the
|
|
864
|
+
// late-settle horizon so they can't accumulate forever.
|
|
865
|
+
export const retentionPhase = internalMutation({
|
|
866
|
+
args: {},
|
|
867
|
+
returns: v.object({ purged: v.number() }),
|
|
868
|
+
handler: async (ctx) => {
|
|
771
869
|
const settings = await getSettings(ctx);
|
|
772
870
|
const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
871
|
+
if (retentionMs <= 0)
|
|
872
|
+
return { purged: 0 };
|
|
873
|
+
const retentionCutoff = Date.now() - retentionMs;
|
|
773
874
|
let purged = 0;
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
// billing tombstones must not repeatedly occupy the front of a scan.
|
|
778
|
-
const old = [];
|
|
779
|
-
for (const expiredFlag of [undefined, false]) {
|
|
780
|
-
old.push(...await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", expiredFlag).eq("settled", true)
|
|
781
|
-
.lt("_creationTime", retentionCutoff)).take(200));
|
|
782
|
-
}
|
|
783
|
-
old.push(...await ctx.db.query("requests").withIndex("status", q => q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
|
|
784
|
-
for (const req of old) {
|
|
875
|
+
let more = false;
|
|
876
|
+
const sweep = async (rows) => {
|
|
877
|
+
for (const req of rows) {
|
|
785
878
|
await deleteRequestTags(ctx, req._id);
|
|
786
879
|
await ctx.db.delete(req._id);
|
|
787
880
|
purged++;
|
|
788
881
|
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
882
|
+
if (rows.length === RECONCILE_BATCH)
|
|
883
|
+
more = true;
|
|
884
|
+
};
|
|
885
|
+
// Settled, non-expired terminal rows past the window.
|
|
886
|
+
for (const expiredFlag of [undefined, false]) {
|
|
887
|
+
await sweep(await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", expiredFlag).eq("settled", true)
|
|
888
|
+
.lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
|
|
889
|
+
}
|
|
890
|
+
// Blocked attempts past the window.
|
|
891
|
+
await sweep(await ctx.db.query("requests").withIndex("status", q => q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH));
|
|
892
|
+
// Expired billing tombstones past the late-settle horizon (content already
|
|
893
|
+
// gone). Without this they'd live forever (one per crashed request).
|
|
894
|
+
const tombstoneCutoff = Date.now() - Math.max(retentionMs, LATE_SETTLE_HORIZON_MS);
|
|
895
|
+
await sweep(await ctx.db.query("requests").withIndex("retention", q => q.eq("reservationExpired", true).eq("settled", true)
|
|
896
|
+
.lt("_creationTime", tombstoneCutoff)).take(RECONCILE_BATCH));
|
|
897
|
+
// Strip PII from expired tombstones still inside the horizon.
|
|
898
|
+
const expiredContent = await ctx.db.query("requests").withIndex("expired_content", q => q.eq("reservationExpired", true).eq("contentPurged", undefined)
|
|
899
|
+
.lt("_creationTime", retentionCutoff)).take(RECONCILE_BATCH);
|
|
900
|
+
for (const req of expiredContent) {
|
|
901
|
+
await ctx.db.patch(req._id, { messages: [], responseText: undefined, contentPurged: true });
|
|
794
902
|
}
|
|
795
|
-
|
|
903
|
+
if (expiredContent.length === RECONCILE_BATCH)
|
|
904
|
+
more = true;
|
|
905
|
+
if (more)
|
|
906
|
+
await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
|
|
907
|
+
return { purged };
|
|
796
908
|
},
|
|
797
909
|
});
|
|
798
910
|
export const setRetention = mutation({
|
|
@@ -937,10 +1049,15 @@ export const setBucketLimits = mutation({
|
|
|
937
1049
|
},
|
|
938
1050
|
returns: v.null(),
|
|
939
1051
|
handler: async (ctx, args) => {
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1052
|
+
assertAmount(args.requestsPerMinute, "requestsPerMinute");
|
|
1053
|
+
assertAmount(args.maxConcurrent, "maxConcurrent");
|
|
1054
|
+
assertAmount(args.dailySpendLimitNanos, "dailySpendLimitNanos");
|
|
1055
|
+
assertAmount(args.monthlySpendLimitNanos, "monthlySpendLimitNanos");
|
|
1056
|
+
assertAmount(args.lifetimeSpendLimitNanos, "lifetimeSpendLimitNanos");
|
|
1057
|
+
assertAmount(args.dailyTokenLimit, "dailyTokenLimit");
|
|
1058
|
+
assertAmount(args.monthlyTokenLimit, "monthlyTokenLimit");
|
|
1059
|
+
assertAmount(args.lifetimeTokenLimit, "lifetimeTokenLimit");
|
|
1060
|
+
assertFraction(args.warnAtPct, "warnAtPct");
|
|
944
1061
|
const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
|
|
945
1062
|
const { dimension: _d, value: _v, ...limits } = args;
|
|
946
1063
|
await ctx.db.patch(bucket._id, limits);
|
|
@@ -960,6 +1077,9 @@ export const bumpBucket = mutation({
|
|
|
960
1077
|
args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
|
|
961
1078
|
returns: v.null(),
|
|
962
1079
|
handler: async (ctx, { dimension, value, dailyNanos, monthlyNanos, lifetimeNanos }) => {
|
|
1080
|
+
assertAmount(dailyNanos, "dailyNanos");
|
|
1081
|
+
assertAmount(monthlyNanos, "monthlyNanos");
|
|
1082
|
+
assertAmount(lifetimeNanos, "lifetimeNanos");
|
|
963
1083
|
const bucket = await getOrCreateBucket(ctx, dimension, value);
|
|
964
1084
|
const today = dayStamp();
|
|
965
1085
|
const month = monthStamp();
|
|
@@ -989,6 +1109,8 @@ export const adjustBucket = mutation({
|
|
|
989
1109
|
},
|
|
990
1110
|
returns: v.null(),
|
|
991
1111
|
handler: async (ctx, { dimension, value, deltaNanos, tokens, reason }) => {
|
|
1112
|
+
assertAmount(deltaNanos, "deltaNanos", { signed: true });
|
|
1113
|
+
assertAmount(tokens, "tokens", { signed: true });
|
|
992
1114
|
const b = await getOrCreateBucket(ctx, dimension, value);
|
|
993
1115
|
const today = dayStamp();
|
|
994
1116
|
const month = monthStamp();
|
|
@@ -1004,6 +1126,12 @@ export const adjustBucket = mutation({
|
|
|
1004
1126
|
tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
|
|
1005
1127
|
spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
|
|
1006
1128
|
tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
|
|
1129
|
+
// Advancing the window here must also clear the OLD window's reserved
|
|
1130
|
+
// holds, or an in-flight request from the previous day/month would be
|
|
1131
|
+
// treated as reserving against the new window and its later release would
|
|
1132
|
+
// no longer match — stranding those reserved nanos/tokens.
|
|
1133
|
+
...(dSame ? {} : { reservedTodayNanos: 0, reservedTodayTokens: 0 }),
|
|
1134
|
+
...(mSame ? {} : { reservedMonthNanos: 0, reservedMonthTokens: 0 }),
|
|
1007
1135
|
});
|
|
1008
1136
|
await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
|
|
1009
1137
|
await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
|
|
@@ -1064,6 +1192,17 @@ export const deleteBucket = mutation({
|
|
|
1064
1192
|
.withIndex("userId", (q) => q.eq("userId", value))
|
|
1065
1193
|
.take(DELETE_BATCH);
|
|
1066
1194
|
for (const r of rows) {
|
|
1195
|
+
// Before dropping the row, free or settle any hold it placed on OTHER
|
|
1196
|
+
// (shared) buckets — an action/customer bucket this user's request
|
|
1197
|
+
// reserved against. Otherwise deleting the only row that could release
|
|
1198
|
+
// that hold strands the shared bucket's reservation + pendingCount
|
|
1199
|
+
// forever, and a late finish would throw. A finished-but-unfolded row
|
|
1200
|
+
// is folded (the charge lands on the shared buckets); a still-pending
|
|
1201
|
+
// one just has its reservation released.
|
|
1202
|
+
if (r.settled === false)
|
|
1203
|
+
await foldOne(ctx, r);
|
|
1204
|
+
else if (r.status === "pending")
|
|
1205
|
+
await releaseReservation(ctx, r);
|
|
1067
1206
|
await deleteRequestTags(ctx, r._id);
|
|
1068
1207
|
await ctx.db.delete(r._id);
|
|
1069
1208
|
}
|
|
@@ -1139,18 +1278,29 @@ export const getGlobalStatus = query({
|
|
|
1139
1278
|
});
|
|
1140
1279
|
export const setGlobalLimits = mutation({
|
|
1141
1280
|
args: {
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1281
|
+
// Absent = leave unchanged; explicit null = clear that limit. (A bare
|
|
1282
|
+
// v.optional(number) that always rebuilt the full patch would let a
|
|
1283
|
+
// one-field edit — exactly what the dashboard sends — silently wipe the
|
|
1284
|
+
// other global controls by patching them to undefined.)
|
|
1285
|
+
dailySpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1286
|
+
lifetimeSpendLimitNanos: v.optional(v.union(v.number(), v.null())),
|
|
1287
|
+
enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"), v.null())),
|
|
1145
1288
|
},
|
|
1146
1289
|
returns: v.null(),
|
|
1147
1290
|
handler: async (ctx, args) => {
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1291
|
+
if (typeof args.dailySpendLimitNanos === "number")
|
|
1292
|
+
assertAmount(args.dailySpendLimitNanos, "dailySpendLimitNanos");
|
|
1293
|
+
if (typeof args.lifetimeSpendLimitNanos === "number")
|
|
1294
|
+
assertAmount(args.lifetimeSpendLimitNanos, "lifetimeSpendLimitNanos");
|
|
1295
|
+
// The settings fields are "global"-prefixed; map the friendly arg names,
|
|
1296
|
+
// touching ONLY the keys the caller actually passed. null -> clear.
|
|
1297
|
+
const patch = {};
|
|
1298
|
+
if ("dailySpendLimitNanos" in args)
|
|
1299
|
+
patch.globalDailySpendLimitNanos = args.dailySpendLimitNanos ?? undefined;
|
|
1300
|
+
if ("lifetimeSpendLimitNanos" in args)
|
|
1301
|
+
patch.globalLifetimeSpendLimitNanos = args.lifetimeSpendLimitNanos ?? undefined;
|
|
1302
|
+
if ("enforcement" in args)
|
|
1303
|
+
patch.globalEnforcement = args.enforcement ?? undefined;
|
|
1154
1304
|
const existing = await getSettings(ctx);
|
|
1155
1305
|
if (existing) {
|
|
1156
1306
|
await ctx.db.patch(existing._id, patch);
|
|
@@ -1216,11 +1366,11 @@ export const setPrice = mutation({
|
|
|
1216
1366
|
handler: async (ctx, args) => {
|
|
1217
1367
|
// Negative prices would make costOf return a negative cost, which folds
|
|
1218
1368
|
// into totals as a spend *refund* — pushing a user back under their cap.
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1369
|
+
// NaN/Infinity (allowed by v.number()) are just as corrupting — a NaN rate
|
|
1370
|
+
// poisons every settled cost for the model — so require finite integers.
|
|
1371
|
+
assertAmount(args.inputNanosPerMTok, "inputNanosPerMTok");
|
|
1372
|
+
assertAmount(args.outputNanosPerMTok, "outputNanosPerMTok");
|
|
1373
|
+
assertAmount(args.cachedNanosPerMTok, "cachedNanosPerMTok");
|
|
1224
1374
|
const existing = await ctx.db
|
|
1225
1375
|
.query("prices")
|
|
1226
1376
|
.withIndex("model", (q) => q.eq("model", args.model))
|
package/package.json
CHANGED
package/src/client/dashboard.ts
CHANGED
|
@@ -97,8 +97,18 @@ async function renderBuckets() {
|
|
|
97
97
|
const dims = ["user", "action"];
|
|
98
98
|
const state = { dimension: window.__dim ?? "" };
|
|
99
99
|
const rows = await get("/buckets", state.dimension ? { dimension: state.dimension } : {});
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
// Within ONE dimension each request bills exactly one bucket, so summing is a
|
|
101
|
+
// true total. Across dimensions a request bills several buckets (user + action
|
|
102
|
+
// + tags), so summing multi-counts — use the deployment-wide spend instead.
|
|
103
|
+
let totalText;
|
|
104
|
+
if (state.dimension) {
|
|
105
|
+
const grand = rows.reduce((s, b) => s + b.totalSpendNanos, 0);
|
|
106
|
+
totalText = rows.length + " buckets · " + usd(grand) + " total (" + state.dimension + ")";
|
|
107
|
+
} else {
|
|
108
|
+
const g = await get("/global", {});
|
|
109
|
+
totalText = rows.length + " buckets · " + usd(g.spentTotalNanos ?? 0) + " spent (deployment)";
|
|
110
|
+
}
|
|
111
|
+
document.getElementById("total").textContent = totalText;
|
|
102
112
|
const dimSet = [...new Set(rows.map((b) => b.dimension).concat(dims))];
|
|
103
113
|
const sel = el("select", { value: state.dimension, style: "width:140px",
|
|
104
114
|
onchange: (e) => { window.__dim = e.target.value; render(); } },
|