@convex-dev/ai-budget 0.0.2-alpha.17 → 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 +31 -8
- package/dist/client/index.js +43 -7
- package/dist/component/_generated/component.d.ts +3 -3
- package/dist/component/lib.d.ts +6 -1
- package/dist/component/lib.js +114 -37
- package/package.json +1 -1
- package/src/client/index.ts +46 -11
- package/src/component/_generated/component.ts +3 -3
- package/src/component/lib.test.ts +60 -7
- package/src/component/lib.ts +115 -39
package/README.md
CHANGED
|
@@ -584,19 +584,29 @@ import { AIBudget } from "@convex-dev/ai-budget";
|
|
|
584
584
|
const ai = new AIBudget(components.aiBudget);
|
|
585
585
|
const http = httpRouter();
|
|
586
586
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
});
|
|
587
|
+
// Recommended: token mode. Set AI_BUDGET_DASHBOARD_TOKEN in the deployment env;
|
|
588
|
+
// open the dashboard with ?token=<it> once and the page keeps using it.
|
|
589
|
+
ai.registerRoutes(http);
|
|
591
590
|
|
|
592
591
|
export default http;
|
|
593
592
|
```
|
|
594
593
|
|
|
595
594
|
It lives at `https://<deployment>.convex.site/aibudget` (override with `path`).
|
|
596
|
-
**It is a public internet endpoint, so you must gate it
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
595
|
+
**It is a public internet endpoint, so you must gate it**, one of two ways:
|
|
596
|
+
|
|
597
|
+
- **Token (recommended, works end-to-end):** set the `AI_BUDGET_DASHBOARD_TOKEN`
|
|
598
|
+
env var. Open `…/aibudget?token=<token>` once; the page strips it from the URL
|
|
599
|
+
and sends it as a bearer on every API call. `?token=` is accepted only on the
|
|
600
|
+
page navigation, and the compare is constant-time.
|
|
601
|
+
- **`authorize(ctx, request)` callback:** must authenticate from something the
|
|
602
|
+
**browser sends on a top-level navigation** — a cookie or a header *you*
|
|
603
|
+
control — because a page load carries no `Authorization` bearer. `ctx.auth`
|
|
604
|
+
(deployment JWT) is `null` for the HTML page, so `authorize: (ctx) => (await
|
|
605
|
+
ctx.auth.getUserIdentity())?.role === "admin"` will 401 the page. Use it only
|
|
606
|
+
when you have your own session cookie to check.
|
|
607
|
+
|
|
608
|
+
With neither configured, every route returns 401. Everything the page shows is
|
|
609
|
+
backed by the component's own functions — nothing else to wire up.
|
|
600
610
|
|
|
601
611
|
---
|
|
602
612
|
|
|
@@ -641,6 +651,19 @@ more tokens or costs more than estimated, settlement records the real amount and
|
|
|
641
651
|
the final total can exceed a hard cap by that request's estimation delta. The next
|
|
642
652
|
admission sees the settled total and blocks until there is headroom again.
|
|
643
653
|
|
|
654
|
+
**Throughput characteristics (know these before you turn on a global cap).**
|
|
655
|
+
Reconciliation runs as small, independently-rescheduling phases, so folding,
|
|
656
|
+
expiry, and retention can't stall each other and each drains its own backlog.
|
|
657
|
+
Two shared-write hot spots remain, by design:
|
|
658
|
+
- The **global cap** reads a sharded counter *inside* admission, which contends
|
|
659
|
+
with every fold that writes it — so a configured global cap adds contention on
|
|
660
|
+
the hot path. It's a deployment-wide killswitch, not a high-throughput per-call
|
|
661
|
+
limit; prefer per-bucket caps for the common case.
|
|
662
|
+
- **Settlement writes every attributed bucket**, including the per-request
|
|
663
|
+
`action` bucket that every call shares. That single row is written by every
|
|
664
|
+
settle, so an extremely high single-action settle rate can lag totals. Spread
|
|
665
|
+
load across naturally-sharded dimensions (per user/customer) where you can.
|
|
666
|
+
|
|
644
667
|
The `error.md` file documents the adversarial audits this design survived, with
|
|
645
668
|
live repros.
|
|
646
669
|
|
package/dist/client/index.js
CHANGED
|
@@ -39,10 +39,17 @@ function toTokenCount(x) {
|
|
|
39
39
|
}
|
|
40
40
|
function extractUsage(usage) {
|
|
41
41
|
return {
|
|
42
|
-
// Cover AI SDK camelCase (v5/v7)
|
|
43
|
-
// `
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
// Cover AI SDK camelCase (v5/v7), raw OpenAI-compatible snake_case, AND raw
|
|
43
|
+
// Anthropic (`input_tokens`/`output_tokens`), so a `meter` caller passing any
|
|
44
|
+
// provider's raw `usage` object still gets counts.
|
|
45
|
+
promptTokens: toTokenCount(usage?.inputTokens ??
|
|
46
|
+
usage?.promptTokens ??
|
|
47
|
+
usage?.prompt_tokens ??
|
|
48
|
+
usage?.input_tokens),
|
|
49
|
+
completionTokens: toTokenCount(usage?.outputTokens ??
|
|
50
|
+
usage?.completionTokens ??
|
|
51
|
+
usage?.completion_tokens ??
|
|
52
|
+
usage?.output_tokens),
|
|
46
53
|
// cached prompt tokens. The Convex gateway reports these at
|
|
47
54
|
// `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
|
|
48
55
|
// (`cachedInputTokens`) and raw OpenAI-compatible shapes.
|
|
@@ -50,7 +57,8 @@ function extractUsage(usage) {
|
|
|
50
57
|
usage?.cachedInputTokens ??
|
|
51
58
|
usage?.promptTokensDetails?.cachedTokens ??
|
|
52
59
|
usage?.prompt_tokens_details?.cached_tokens ??
|
|
53
|
-
usage?.cached_tokens
|
|
60
|
+
usage?.cached_tokens ??
|
|
61
|
+
usage?.cache_read_input_tokens),
|
|
54
62
|
};
|
|
55
63
|
}
|
|
56
64
|
// The AI Gateway reports the authoritative dollar cost of each request.
|
|
@@ -72,6 +80,10 @@ function extractGatewayCostNanos(result) {
|
|
|
72
80
|
return undefined;
|
|
73
81
|
}
|
|
74
82
|
const NANOS_PER_DOLLAR = 1e9;
|
|
83
|
+
// Cap stored prompt/response content so one row can't approach Convex's 1 MiB
|
|
84
|
+
// document limit (which would fail the call) or bloat the reconciler's scans.
|
|
85
|
+
const MAX_STORED_CONTENT = 32 * 1024;
|
|
86
|
+
const capContent = (s) => s.length > MAX_STORED_CONTENT ? s.slice(0, MAX_STORED_CONTENT) + "…[truncated]" : s;
|
|
75
87
|
// Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
|
|
76
88
|
function simplifyPrompt(prompt) {
|
|
77
89
|
if (!Array.isArray(prompt))
|
|
@@ -83,13 +95,22 @@ function simplifyPrompt(prompt) {
|
|
|
83
95
|
}
|
|
84
96
|
else if (Array.isArray(m.content)) {
|
|
85
97
|
content = m.content
|
|
86
|
-
.map((part) =>
|
|
98
|
+
.map((part) => {
|
|
99
|
+
if (part?.type === "text")
|
|
100
|
+
return part.text ?? "";
|
|
101
|
+
// NEVER inline a base64 image/file part — a data URL or Uint8Array here
|
|
102
|
+
// becomes megabytes, pushing the stored row past the 1 MiB doc limit
|
|
103
|
+
// (failing the call) and turning a 200 KB image into a ~50k-token
|
|
104
|
+
// estimate. Store a compact placeholder.
|
|
105
|
+
const bytes = part?.data?.length ?? part?.image?.length ?? part?.data?.byteLength;
|
|
106
|
+
return `[${part?.type ?? "part"}${typeof bytes === "number" ? ` ${bytes}b` : ""}]`;
|
|
107
|
+
})
|
|
87
108
|
.join("");
|
|
88
109
|
}
|
|
89
110
|
else {
|
|
90
111
|
content = JSON.stringify(m.content);
|
|
91
112
|
}
|
|
92
|
-
return { role: String(m.role), content };
|
|
113
|
+
return { role: String(m.role), content: capContent(content) };
|
|
93
114
|
});
|
|
94
115
|
}
|
|
95
116
|
function extractText(result) {
|
|
@@ -491,6 +512,21 @@ export class AIBudget {
|
|
|
491
512
|
async flush() {
|
|
492
513
|
await settle();
|
|
493
514
|
},
|
|
515
|
+
// A cancelled/aborted stream (client disconnect, AbortSignal, or
|
|
516
|
+
// breaking out of `for await`) does NOT run `flush`. Without this
|
|
517
|
+
// the request would sit pending until the 30-min reservation
|
|
518
|
+
// expiry and be recorded as $0 though the provider charged for
|
|
519
|
+
// what streamed. Settle here with the text so far, estimating the
|
|
520
|
+
// completion tokens when the provider gave no usage.
|
|
521
|
+
// `cancel` is a newer Streams-spec transformer hook not yet in
|
|
522
|
+
// the TS DOM lib types; cast the literal so it compiles (runtimes
|
|
523
|
+
// without it simply won't call it, and the reconciler backstops).
|
|
524
|
+
async cancel(reason) {
|
|
525
|
+
if (usage === undefined && text) {
|
|
526
|
+
usage = { outputTokens: Math.ceil(text.length / 4) };
|
|
527
|
+
}
|
|
528
|
+
await settle(`cancelled: ${String(reason)}`);
|
|
529
|
+
},
|
|
494
530
|
}));
|
|
495
531
|
return { ...result, stream: tapped };
|
|
496
532
|
}
|
|
@@ -117,9 +117,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
117
117
|
warnAtPct?: number;
|
|
118
118
|
}, null, Name>;
|
|
119
119
|
setGlobalLimits: FunctionReference<"mutation", "internal", {
|
|
120
|
-
dailySpendLimitNanos?: number;
|
|
121
|
-
enforcement?: "hard" | "soft";
|
|
122
|
-
lifetimeSpendLimitNanos?: number;
|
|
120
|
+
dailySpendLimitNanos?: number | null;
|
|
121
|
+
enforcement?: "hard" | "soft" | null;
|
|
122
|
+
lifetimeSpendLimitNanos?: number | null;
|
|
123
123
|
}, null, Name>;
|
|
124
124
|
setModelPolicy: FunctionReference<"mutation", "internal", {
|
|
125
125
|
mode: "open" | "allowlist" | "denylist";
|
package/dist/component/lib.d.ts
CHANGED
|
@@ -39,9 +39,14 @@ export declare const finishRequest: import("convex/server").RegisteredMutation<"
|
|
|
39
39
|
export declare const foldTotals: import("convex/server").RegisteredMutation<"internal", {
|
|
40
40
|
requestId: import("convex/values").GenericId<"requests">;
|
|
41
41
|
}, Promise<null>>;
|
|
42
|
-
export declare const reconcile: import("convex/server").RegisteredMutation<"internal", {}, Promise<
|
|
42
|
+
export declare const reconcile: import("convex/server").RegisteredMutation<"internal", {}, Promise<null>>;
|
|
43
|
+
export declare const foldPhase: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
|
|
43
44
|
folded: number;
|
|
45
|
+
}>>;
|
|
46
|
+
export declare const expirePhase: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
|
|
44
47
|
expired: number;
|
|
48
|
+
}>>;
|
|
49
|
+
export declare const retentionPhase: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
|
|
45
50
|
purged: number;
|
|
46
51
|
}>>;
|
|
47
52
|
export declare const setRetention: import("convex/server").RegisteredMutation<"public", {
|
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")
|
|
@@ -379,6 +398,11 @@ 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);
|
|
383
407
|
// Record the blocked attempt and return a rejection (throwing would roll
|
|
384
408
|
// back the record). `persist` is false for the high-frequency-by-design
|
|
@@ -668,8 +692,15 @@ export const finishRequest = mutation({
|
|
|
668
692
|
}
|
|
669
693
|
else {
|
|
670
694
|
const settings = await getSettings(ctx);
|
|
671
|
-
|
|
672
|
-
|
|
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;
|
|
673
704
|
}
|
|
674
705
|
// Durable write to the request's OWN row only — uncontended, so it always
|
|
675
706
|
// lands. `settled: false` hands it to the fold step; the row is never left
|
|
@@ -711,13 +742,13 @@ async function releaseReservation(ctx, req) {
|
|
|
711
742
|
if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b)))
|
|
712
743
|
continue;
|
|
713
744
|
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
|
|
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),
|
|
721
752
|
});
|
|
722
753
|
}
|
|
723
754
|
await ctx.db.patch(req._id, { reservationReleased: true });
|
|
@@ -773,27 +804,49 @@ export const foldTotals = internalMutation({
|
|
|
773
804
|
// Backstop for both failure modes: folds finished requests whose scheduled fold
|
|
774
805
|
// lost the retry race, and releases reservations for requests that never
|
|
775
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.
|
|
776
811
|
export const reconcile = internalMutation({
|
|
777
812
|
args: {},
|
|
778
|
-
returns: v.
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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() }),
|
|
783
825
|
handler: async (ctx) => {
|
|
784
826
|
const toFold = await ctx.db
|
|
785
827
|
.query("requests")
|
|
786
828
|
.withIndex("settled", (q) => q.eq("settled", false))
|
|
787
|
-
.take(
|
|
829
|
+
.take(RECONCILE_BATCH);
|
|
788
830
|
for (const req of toFold)
|
|
789
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) => {
|
|
790
843
|
// Lazily migrate old pending rows in bounded batches. Once indexed, long
|
|
791
844
|
// 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(
|
|
845
|
+
const legacy = await ctx.db.query("requests").withIndex("status_expires", q => q.eq("status", "pending").eq("expiresAt", undefined)).take(RECONCILE_BATCH);
|
|
793
846
|
for (const req of legacy) {
|
|
794
847
|
await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
|
|
795
848
|
}
|
|
796
|
-
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);
|
|
797
850
|
for (const req of candidates) {
|
|
798
851
|
await releaseReservation(ctx, req);
|
|
799
852
|
await ctx.db.patch(req._id, {
|
|
@@ -801,33 +854,57 @@ export const reconcile = internalMutation({
|
|
|
801
854
|
reservationExpired: true, expiresAt: undefined, settled: true,
|
|
802
855
|
});
|
|
803
856
|
}
|
|
804
|
-
|
|
805
|
-
|
|
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) => {
|
|
806
869
|
const settings = await getSettings(ctx);
|
|
807
870
|
const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
871
|
+
if (retentionMs <= 0)
|
|
872
|
+
return { purged: 0 };
|
|
873
|
+
const retentionCutoff = Date.now() - retentionMs;
|
|
808
874
|
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) {
|
|
875
|
+
let more = false;
|
|
876
|
+
const sweep = async (rows) => {
|
|
877
|
+
for (const req of rows) {
|
|
820
878
|
await deleteRequestTags(ctx, req._id);
|
|
821
879
|
await ctx.db.delete(req._id);
|
|
822
880
|
purged++;
|
|
823
881
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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 });
|
|
829
902
|
}
|
|
830
|
-
|
|
903
|
+
if (expiredContent.length === RECONCILE_BATCH)
|
|
904
|
+
more = true;
|
|
905
|
+
if (more)
|
|
906
|
+
await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
|
|
907
|
+
return { purged };
|
|
831
908
|
},
|
|
832
909
|
});
|
|
833
910
|
export const setRetention = mutation({
|
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -172,13 +172,20 @@ function extractUsage(usage: any): {
|
|
|
172
172
|
cachedTokens: number;
|
|
173
173
|
} {
|
|
174
174
|
return {
|
|
175
|
-
// Cover AI SDK camelCase (v5/v7)
|
|
176
|
-
// `
|
|
175
|
+
// Cover AI SDK camelCase (v5/v7), raw OpenAI-compatible snake_case, AND raw
|
|
176
|
+
// Anthropic (`input_tokens`/`output_tokens`), so a `meter` caller passing any
|
|
177
|
+
// provider's raw `usage` object still gets counts.
|
|
177
178
|
promptTokens: toTokenCount(
|
|
178
|
-
usage?.inputTokens ??
|
|
179
|
+
usage?.inputTokens ??
|
|
180
|
+
usage?.promptTokens ??
|
|
181
|
+
usage?.prompt_tokens ??
|
|
182
|
+
usage?.input_tokens
|
|
179
183
|
),
|
|
180
184
|
completionTokens: toTokenCount(
|
|
181
|
-
usage?.outputTokens ??
|
|
185
|
+
usage?.outputTokens ??
|
|
186
|
+
usage?.completionTokens ??
|
|
187
|
+
usage?.completion_tokens ??
|
|
188
|
+
usage?.output_tokens
|
|
182
189
|
),
|
|
183
190
|
// cached prompt tokens. The Convex gateway reports these at
|
|
184
191
|
// `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
|
|
@@ -188,7 +195,8 @@ function extractUsage(usage: any): {
|
|
|
188
195
|
usage?.cachedInputTokens ??
|
|
189
196
|
usage?.promptTokensDetails?.cachedTokens ??
|
|
190
197
|
usage?.prompt_tokens_details?.cached_tokens ??
|
|
191
|
-
usage?.cached_tokens
|
|
198
|
+
usage?.cached_tokens ??
|
|
199
|
+
usage?.cache_read_input_tokens
|
|
192
200
|
),
|
|
193
201
|
};
|
|
194
202
|
}
|
|
@@ -214,6 +222,12 @@ function extractGatewayCostNanos(result: any): number | undefined {
|
|
|
214
222
|
|
|
215
223
|
const NANOS_PER_DOLLAR = 1e9;
|
|
216
224
|
|
|
225
|
+
// Cap stored prompt/response content so one row can't approach Convex's 1 MiB
|
|
226
|
+
// document limit (which would fail the call) or bloat the reconciler's scans.
|
|
227
|
+
const MAX_STORED_CONTENT = 32 * 1024;
|
|
228
|
+
const capContent = (s: string) =>
|
|
229
|
+
s.length > MAX_STORED_CONTENT ? s.slice(0, MAX_STORED_CONTENT) + "…[truncated]" : s;
|
|
230
|
+
|
|
217
231
|
// Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
|
|
218
232
|
function simplifyPrompt(prompt: any): Message[] {
|
|
219
233
|
if (!Array.isArray(prompt)) return [];
|
|
@@ -223,14 +237,20 @@ function simplifyPrompt(prompt: any): Message[] {
|
|
|
223
237
|
content = m.content;
|
|
224
238
|
} else if (Array.isArray(m.content)) {
|
|
225
239
|
content = m.content
|
|
226
|
-
.map((part: any) =>
|
|
227
|
-
part?.type === "text"
|
|
228
|
-
|
|
240
|
+
.map((part: any) => {
|
|
241
|
+
if (part?.type === "text") return part.text ?? "";
|
|
242
|
+
// NEVER inline a base64 image/file part — a data URL or Uint8Array here
|
|
243
|
+
// becomes megabytes, pushing the stored row past the 1 MiB doc limit
|
|
244
|
+
// (failing the call) and turning a 200 KB image into a ~50k-token
|
|
245
|
+
// estimate. Store a compact placeholder.
|
|
246
|
+
const bytes = part?.data?.length ?? part?.image?.length ?? part?.data?.byteLength;
|
|
247
|
+
return `[${part?.type ?? "part"}${typeof bytes === "number" ? ` ${bytes}b` : ""}]`;
|
|
248
|
+
})
|
|
229
249
|
.join("");
|
|
230
250
|
} else {
|
|
231
251
|
content = JSON.stringify(m.content);
|
|
232
252
|
}
|
|
233
|
-
return { role: String(m.role), content };
|
|
253
|
+
return { role: String(m.role), content: capContent(content) };
|
|
234
254
|
});
|
|
235
255
|
}
|
|
236
256
|
|
|
@@ -781,7 +801,7 @@ export class AIBudget {
|
|
|
781
801
|
}));
|
|
782
802
|
const tapped = result.stream.pipeThrough(
|
|
783
803
|
new TransformStream({
|
|
784
|
-
async transform(chunk: any, controller) {
|
|
804
|
+
async transform(chunk: any, controller: any) {
|
|
785
805
|
if (chunk?.type === "text-delta") {
|
|
786
806
|
text += chunk.delta ?? chunk.textDelta ?? "";
|
|
787
807
|
}
|
|
@@ -797,7 +817,22 @@ export class AIBudget {
|
|
|
797
817
|
async flush() {
|
|
798
818
|
await settle();
|
|
799
819
|
},
|
|
800
|
-
|
|
820
|
+
// A cancelled/aborted stream (client disconnect, AbortSignal, or
|
|
821
|
+
// breaking out of `for await`) does NOT run `flush`. Without this
|
|
822
|
+
// the request would sit pending until the 30-min reservation
|
|
823
|
+
// expiry and be recorded as $0 though the provider charged for
|
|
824
|
+
// what streamed. Settle here with the text so far, estimating the
|
|
825
|
+
// completion tokens when the provider gave no usage.
|
|
826
|
+
// `cancel` is a newer Streams-spec transformer hook not yet in
|
|
827
|
+
// the TS DOM lib types; cast the literal so it compiles (runtimes
|
|
828
|
+
// without it simply won't call it, and the reconciler backstops).
|
|
829
|
+
async cancel(reason: any) {
|
|
830
|
+
if (usage === undefined && text) {
|
|
831
|
+
usage = { outputTokens: Math.ceil(text.length / 4) };
|
|
832
|
+
}
|
|
833
|
+
await settle(`cancelled: ${String(reason)}`);
|
|
834
|
+
},
|
|
835
|
+
} as any)
|
|
801
836
|
);
|
|
802
837
|
return { ...result, stream: tapped };
|
|
803
838
|
} catch (e) {
|
|
@@ -185,9 +185,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
185
185
|
"mutation",
|
|
186
186
|
"internal",
|
|
187
187
|
{
|
|
188
|
-
dailySpendLimitNanos?: number;
|
|
189
|
-
enforcement?: "hard" | "soft";
|
|
190
|
-
lifetimeSpendLimitNanos?: number;
|
|
188
|
+
dailySpendLimitNanos?: number | null;
|
|
189
|
+
enforcement?: "hard" | "soft" | null;
|
|
190
|
+
lifetimeSpendLimitNanos?: number | null;
|
|
191
191
|
},
|
|
192
192
|
null,
|
|
193
193
|
Name
|
|
@@ -554,8 +554,9 @@ describe("F-04 fail-closed pricing", () => {
|
|
|
554
554
|
expect(r.allowed).toBe(true);
|
|
555
555
|
await settle(t, r.requestId, 1_000_000, 1_000_000);
|
|
556
556
|
const u = await userOf(t, "u");
|
|
557
|
-
//
|
|
558
|
-
|
|
557
|
+
// Conservative fallback is a frontier ceiling of {$20 in, $100 out}/Mtok, so
|
|
558
|
+
// 1M in + 1M out => $120 = 120e9 nano (over-count is the safe direction).
|
|
559
|
+
expect(u.totalSpendNanos).toBe(120_000_000_000);
|
|
559
560
|
const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
|
|
560
561
|
expect(req.unpricedModel).toBe(true);
|
|
561
562
|
});
|
|
@@ -598,7 +599,7 @@ describe("accounting lifecycle regressions", () => {
|
|
|
598
599
|
await setUserLimits(t, "u", { dailySpendLimitNanos: 1000 });
|
|
599
600
|
const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
600
601
|
vi.advanceTimersByTime(2 * 60 * 60_000);
|
|
601
|
-
await t.mutation(internal.lib.
|
|
602
|
+
await t.mutation(internal.lib.expirePhase, {});
|
|
602
603
|
expect((await userOf(t, "u")).reservedTotalNanos).toBe(0);
|
|
603
604
|
await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
604
605
|
await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 75 });
|
|
@@ -625,7 +626,7 @@ describe("accounting lifecycle regressions", () => {
|
|
|
625
626
|
});
|
|
626
627
|
const short = await start(t, { userId: "short" });
|
|
627
628
|
vi.advanceTimersByTime(31 * 60_000);
|
|
628
|
-
const result = await t.mutation(internal.lib.
|
|
629
|
+
const result = await t.mutation(internal.lib.expirePhase, {});
|
|
629
630
|
expect(result.expired).toBe(1);
|
|
630
631
|
expect((await t.run(ctx => ctx.db.get(short.requestId))).reservationExpired).toBe(true);
|
|
631
632
|
} finally { vi.useRealTimers(); }
|
|
@@ -664,9 +665,11 @@ test("legacy pending rows acquire deadlines without starving newer expired work"
|
|
|
664
665
|
});
|
|
665
666
|
const job = await start(t, { userId: "new" });
|
|
666
667
|
vi.advanceTimersByTime(31 * 60_000);
|
|
667
|
-
expect((await t.mutation(internal.lib.
|
|
668
|
+
expect((await t.mutation(internal.lib.expirePhase, {})).expired).toBe(1);
|
|
668
669
|
expect((await t.run(ctx => ctx.db.get(job.requestId))).reservationExpired).toBe(true);
|
|
669
|
-
|
|
670
|
+
// The phase self-reschedules to backfill the remaining legacy rows in
|
|
671
|
+
// batches; drain those scheduled continuations.
|
|
672
|
+
await t.finishAllScheduledFunctions(vi.runAllTimers);
|
|
670
673
|
expect(await t.run(ctx => ctx.db.query("requests").withIndex("status_expires", q =>
|
|
671
674
|
q.eq("status", "pending").eq("expiresAt", undefined)).take(1))).toHaveLength(0);
|
|
672
675
|
} finally { vi.useRealTimers(); }
|
|
@@ -685,7 +688,7 @@ test("retention progresses past unresolved jobs", async () => {
|
|
|
685
688
|
await t.mutation(api.lib.finishRequest, { requestId: job.requestId, costNanos: 0 });
|
|
686
689
|
await t.mutation(internal.lib.foldTotals, { requestId: job.requestId });
|
|
687
690
|
vi.advanceTimersByTime(2 * 60 * 60_000);
|
|
688
|
-
expect((await t.mutation(internal.lib.
|
|
691
|
+
expect((await t.mutation(internal.lib.retentionPhase, {})).purged).toBe(1);
|
|
689
692
|
expect(await t.run(ctx => ctx.db.get(job.requestId))).toBeNull();
|
|
690
693
|
} finally { vi.useRealTimers(); }
|
|
691
694
|
});
|
|
@@ -791,3 +794,53 @@ describe("v1 hardening", () => {
|
|
|
791
794
|
).rejects.toThrow(/dailySpendLimitNanos/);
|
|
792
795
|
});
|
|
793
796
|
});
|
|
797
|
+
|
|
798
|
+
describe("v1 hardening (round 2)", () => {
|
|
799
|
+
test("a non-finite estimatedCostNanos is rejected before it can poison a bucket", async () => {
|
|
800
|
+
const t = initTest();
|
|
801
|
+
for (const estimatedCostNanos of [Infinity, NaN, -1]) {
|
|
802
|
+
await expect(
|
|
803
|
+
start(t, { userId: "u", estimatedCostNanos })
|
|
804
|
+
).rejects.toThrow(/estimatedCostNanos/);
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
test("a settle with no cost signal falls back to the reserved estimate, not $0", async () => {
|
|
809
|
+
const t = initTest();
|
|
810
|
+
// $2 reserved up front (e.g. a video job).
|
|
811
|
+
const r = await start(t, { userId: "u", estimatedCostNanos: 2_000_000_000 });
|
|
812
|
+
// Settle with an unpriced server tool and no tokens/authoritative cost.
|
|
813
|
+
const out = await t.mutation(api.lib.finishRequest, {
|
|
814
|
+
requestId: r.requestId,
|
|
815
|
+
serverToolUses: { video_seconds: 8 }, // no configured price
|
|
816
|
+
});
|
|
817
|
+
expect(out.costNanos).toBe(2_000_000_000); // NOT 0
|
|
818
|
+
vi.useFakeTimers();
|
|
819
|
+
await t.finishAllScheduledFunctions(vi.runAllTimers);
|
|
820
|
+
vi.useRealTimers();
|
|
821
|
+
const u = await userOf(t, "u");
|
|
822
|
+
expect(u.totalSpendNanos).toBe(2_000_000_000);
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
describe("v1 hardening (round 3): reconcile phases", () => {
|
|
827
|
+
test("expired tombstones are deleted after the late-settle horizon", async () => {
|
|
828
|
+
vi.useFakeTimers();
|
|
829
|
+
try {
|
|
830
|
+
const t = initTest();
|
|
831
|
+
const job = await start(t, { userId: "u", estimatedCostNanos: 100 });
|
|
832
|
+
vi.advanceTimersByTime(31 * 60_000);
|
|
833
|
+
// Expire it into a billing tombstone (reservationExpired + settled).
|
|
834
|
+
await t.mutation(internal.lib.expirePhase, {});
|
|
835
|
+
// Within the 7-day late-settle horizon: retention keeps the tombstone.
|
|
836
|
+
await t.mutation(internal.lib.retentionPhase, {});
|
|
837
|
+
expect(await t.run((ctx) => ctx.db.get(job.requestId))).not.toBeNull();
|
|
838
|
+
// Past the horizon: the tombstone is deleted so they can't accumulate.
|
|
839
|
+
vi.advanceTimersByTime(8 * 24 * 60 * 60_000);
|
|
840
|
+
await t.mutation(internal.lib.retentionPhase, {});
|
|
841
|
+
expect(await t.run((ctx) => ctx.db.get(job.requestId))).toBeNull();
|
|
842
|
+
} finally {
|
|
843
|
+
vi.useRealTimers();
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
});
|
package/src/component/lib.ts
CHANGED
|
@@ -46,6 +46,10 @@ function assertFraction(n: number | undefined, name: string) {
|
|
|
46
46
|
// mapping NaN/Infinity/garbage to 0 rather than poisoning downstream totals.
|
|
47
47
|
const safeCount = (n: number | undefined) =>
|
|
48
48
|
Number.isFinite(n) ? Math.max(0, Math.floor(n as number)) : 0;
|
|
49
|
+
// Treat a non-finite stored accounting field as 0, so a bucket that was poisoned
|
|
50
|
+
// before validation existed self-heals on its next reserve/release instead of
|
|
51
|
+
// staying NaN forever.
|
|
52
|
+
const fin = (n: number | undefined) => (Number.isFinite(n) ? (n as number) : 0);
|
|
49
53
|
|
|
50
54
|
// Built-in attribution dimensions. `user` and `action` are always populated
|
|
51
55
|
// from a request's userId/actionName; apps can add any other dimensions
|
|
@@ -103,6 +107,16 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
|
|
|
103
107
|
// audit table — and the sensitive content in it — from growing without bound.
|
|
104
108
|
// Override per-deployment via setRetention.
|
|
105
109
|
const DEFAULT_RETENTION_MS = 60 * 60 * 1000; // 1 hour
|
|
110
|
+
// Reconciliation runs as small, independently-rescheduling phases. Keeping the
|
|
111
|
+
// batch small bounds the bytes read per transaction (each request row can carry
|
|
112
|
+
// prompts/responses) so a burst can't push one phase past Convex's 8 MiB / 16k-
|
|
113
|
+
// doc read limit and stall the whole reconciler. A phase that fills its batch
|
|
114
|
+
// reschedules itself immediately, so throughput still scales with backlog.
|
|
115
|
+
const RECONCILE_BATCH = 50;
|
|
116
|
+
// Keep an expired billing tombstone (content already purged) this long so a very
|
|
117
|
+
// late provider charge can still land against it, then delete it. A finish after
|
|
118
|
+
// deletion is a graceful no-op.
|
|
119
|
+
const LATE_SETTLE_HORIZON_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
106
120
|
|
|
107
121
|
const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
|
|
108
122
|
const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
|
|
@@ -118,12 +132,17 @@ const CACHE_DISCOUNT = 0.1;
|
|
|
118
132
|
// bills real money. Charging the conservative max instead keeps the caps honest
|
|
119
133
|
// (over-counting is the safe direction); admins can pin an exact price via
|
|
120
134
|
// setPrice, which also clears the `unpricedModel` flag on future requests.
|
|
135
|
+
// Seed with a true frontier ceiling ($20/$100 per Mtok), not just the max of the
|
|
136
|
+
// small built-in table — otherwise premium models (Opus-class $15/$75, etc.) not
|
|
137
|
+
// in the table would be under-counted several-fold whenever the gateway's
|
|
138
|
+
// authoritative cost isn't available. Over-counting an unpriced model is the safe
|
|
139
|
+
// direction; admins pin the exact rate with setPrice.
|
|
121
140
|
const CONSERVATIVE_PRICE = Object.values(DEFAULT_PRICES).reduce(
|
|
122
141
|
(m, p) => ({
|
|
123
142
|
input: Math.max(m.input, p.input),
|
|
124
143
|
output: Math.max(m.output, p.output),
|
|
125
144
|
}),
|
|
126
|
-
{ input:
|
|
145
|
+
{ input: 20_000_000_000, output: 100_000_000_000 }
|
|
127
146
|
);
|
|
128
147
|
|
|
129
148
|
async function getPrice(ctx: MutationCtx, model: string) {
|
|
@@ -500,6 +519,11 @@ export const startRequest = mutation({
|
|
|
500
519
|
if (args.reserveTtlMs !== undefined && (!Number.isFinite(args.reserveTtlMs) || args.reserveTtlMs < 0)) {
|
|
501
520
|
throw new Error("reserveTtlMs must be finite and nonnegative");
|
|
502
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");
|
|
503
527
|
const extraTags = sanitizeExtraTags(args.tags);
|
|
504
528
|
// Record the blocked attempt and return a rejection (throwing would roll
|
|
505
529
|
// back the record). `persist` is false for the high-frequency-by-design
|
|
@@ -830,13 +854,21 @@ export const finishRequest = mutation({
|
|
|
830
854
|
costNanos = Math.round(args.costNanos);
|
|
831
855
|
} else {
|
|
832
856
|
const settings = await getSettings(ctx);
|
|
833
|
-
|
|
857
|
+
const priced =
|
|
834
858
|
settleCost(
|
|
835
859
|
promptTokens,
|
|
836
860
|
cachedTokens,
|
|
837
861
|
completionTokens,
|
|
838
862
|
await getPrice(ctx, request.model)
|
|
839
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;
|
|
840
872
|
}
|
|
841
873
|
|
|
842
874
|
// Durable write to the request's OWN row only — uncontended, so it always
|
|
@@ -879,13 +911,13 @@ async function releaseReservation(ctx: MutationCtx, req: Doc<"requests">) {
|
|
|
879
911
|
const b = await getBucketDoc(ctx, t.dimension, t.value);
|
|
880
912
|
if (!b || (req.heldBucketIds ? !req.heldBucketIds.includes(b._id) : !needsReserve(b))) continue;
|
|
881
913
|
await ctx.db.patch(b._id, {
|
|
882
|
-
reservedTodayNanos: Math.max(0, (b.reservedTodayNanos
|
|
883
|
-
reservedMonthNanos: Math.max(0, (b.reservedMonthNanos
|
|
884
|
-
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos
|
|
885
|
-
reservedTodayTokens: Math.max(0, (b.reservedTodayTokens
|
|
886
|
-
reservedMonthTokens: Math.max(0, (b.reservedMonthTokens
|
|
887
|
-
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens
|
|
888
|
-
pendingCount: Math.max(0, (b.pendingCount
|
|
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),
|
|
889
921
|
});
|
|
890
922
|
}
|
|
891
923
|
await ctx.db.patch(req._id, { reservationReleased: true });
|
|
@@ -943,29 +975,52 @@ export const foldTotals = internalMutation({
|
|
|
943
975
|
// Backstop for both failure modes: folds finished requests whose scheduled fold
|
|
944
976
|
// lost the retry race, and releases reservations for requests that never
|
|
945
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.
|
|
946
982
|
export const reconcile = internalMutation({
|
|
947
983
|
args: {},
|
|
948
|
-
returns: v.
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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() }),
|
|
953
997
|
handler: async (ctx) => {
|
|
954
998
|
const toFold = await ctx.db
|
|
955
999
|
.query("requests")
|
|
956
1000
|
.withIndex("settled", (q) => q.eq("settled", false))
|
|
957
|
-
.take(
|
|
1001
|
+
.take(RECONCILE_BATCH);
|
|
958
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
|
+
});
|
|
959
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) => {
|
|
960
1015
|
// Lazily migrate old pending rows in bounded batches. Once indexed, long
|
|
961
1016
|
// TTL jobs cannot hide expired jobs behind them in creation-time order.
|
|
962
1017
|
const legacy = await ctx.db.query("requests").withIndex("status_expires", q =>
|
|
963
|
-
q.eq("status", "pending").eq("expiresAt", undefined)).take(
|
|
1018
|
+
q.eq("status", "pending").eq("expiresAt", undefined)).take(RECONCILE_BATCH);
|
|
964
1019
|
for (const req of legacy) {
|
|
965
1020
|
await ctx.db.patch(req._id, { expiresAt: req._creationTime + Math.max(STALE_PENDING_MS, req.reserveTtlMs ?? 0) });
|
|
966
1021
|
}
|
|
967
1022
|
const candidates = await ctx.db.query("requests").withIndex("status_expires", q =>
|
|
968
|
-
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);
|
|
969
1024
|
for (const req of candidates) {
|
|
970
1025
|
await releaseReservation(ctx, req);
|
|
971
1026
|
await ctx.db.patch(req._id, {
|
|
@@ -973,37 +1028,58 @@ export const reconcile = internalMutation({
|
|
|
973
1028
|
reservationExpired: true, expiresAt: undefined, settled: true,
|
|
974
1029
|
});
|
|
975
1030
|
}
|
|
976
|
-
|
|
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
|
+
});
|
|
977
1036
|
|
|
978
|
-
|
|
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) => {
|
|
979
1044
|
const settings = await getSettings(ctx);
|
|
980
1045
|
const retentionMs = settings?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
1046
|
+
if (retentionMs <= 0) return { purged: 0 };
|
|
1047
|
+
const retentionCutoff = Date.now() - retentionMs;
|
|
981
1048
|
let purged = 0;
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
// billing tombstones must not repeatedly occupy the front of a scan.
|
|
986
|
-
const old = [];
|
|
987
|
-
for (const expiredFlag of [undefined, false]) {
|
|
988
|
-
old.push(...await ctx.db.query("requests").withIndex("retention", q =>
|
|
989
|
-
q.eq("reservationExpired", expiredFlag).eq("settled", true)
|
|
990
|
-
.lt("_creationTime", retentionCutoff)).take(200));
|
|
991
|
-
}
|
|
992
|
-
old.push(...await ctx.db.query("requests").withIndex("status", q =>
|
|
993
|
-
q.eq("status", "blocked").lt("_creationTime", retentionCutoff)).take(100));
|
|
994
|
-
for (const req of old) {
|
|
1049
|
+
let more = false;
|
|
1050
|
+
const sweep = async (rows: Doc<"requests">[]) => {
|
|
1051
|
+
for (const req of rows) {
|
|
995
1052
|
await deleteRequestTags(ctx, req._id);
|
|
996
1053
|
await ctx.db.delete(req._id);
|
|
997
1054
|
purged++;
|
|
998
1055
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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));
|
|
1063
|
+
}
|
|
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 });
|
|
1005
1079
|
}
|
|
1006
|
-
|
|
1080
|
+
if (expiredContent.length === RECONCILE_BATCH) more = true;
|
|
1081
|
+
if (more) await ctx.scheduler.runAfter(0, internal.lib.retentionPhase, {});
|
|
1082
|
+
return { purged };
|
|
1007
1083
|
},
|
|
1008
1084
|
});
|
|
1009
1085
|
|