@convex-dev/ai-budget 0.0.2-alpha.10 → 0.0.2-alpha.13
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 +112 -12
- package/dist/client/index.d.ts +138 -3
- package/dist/client/index.js +152 -37
- package/dist/component/_generated/component.d.ts +8 -0
- package/dist/component/lib.d.ts +18 -0
- package/dist/component/lib.js +92 -11
- package/dist/component/schema.d.ts +8 -2
- package/dist/component/schema.js +11 -0
- package/package.json +1 -1
- package/src/client/index.ts +259 -61
- package/src/component/_generated/component.ts +17 -0
- package/src/component/lib.test.ts +103 -0
- package/src/component/lib.ts +97 -17
- package/src/component/schema.ts +11 -0
package/src/component/lib.ts
CHANGED
|
@@ -43,6 +43,15 @@ const DEFAULT_PRICES: Record<string, { input: number; output: number }> = {
|
|
|
43
43
|
"openai/gpt-5-mini": { input: 250_000_000, output: 2_000_000_000 },
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
// Per-call price (nanodollars) for provider server-side tools that bill a fee on
|
|
47
|
+
// top of tokens — e.g. Anthropic web search at ~$0.01/call. Keyed by the tool
|
|
48
|
+
// name the caller reports in `serverToolUses` (e.g. { web_search: 3 }). Used
|
|
49
|
+
// only when a request settles WITHOUT an authoritative gateway cost; if you pass
|
|
50
|
+
// `costNanos`, that already includes tool fees. Override via setServerToolPrice.
|
|
51
|
+
const DEFAULT_SERVER_TOOL_PRICES: Record<string, number> = {
|
|
52
|
+
web_search: 10_000_000, // $0.01 per search
|
|
53
|
+
};
|
|
54
|
+
|
|
46
55
|
// Pessimistic assumed output length when reserving budget up front. This makes
|
|
47
56
|
// concurrent admission atomic against the estimate; a response that exceeds the
|
|
48
57
|
// estimate can still settle above the cap by the estimation delta.
|
|
@@ -136,6 +145,22 @@ const settleCost = (
|
|
|
136
145
|
);
|
|
137
146
|
};
|
|
138
147
|
|
|
148
|
+
// Per-call fees for provider server tools (web search, etc.), merging the
|
|
149
|
+
// defaults with any deployment overrides. Unknown tools price at 0 (recorded
|
|
150
|
+
// but not charged) rather than guessing.
|
|
151
|
+
const serverToolCost = (
|
|
152
|
+
uses: Record<string, number> | undefined,
|
|
153
|
+
overrides: Record<string, number> | undefined
|
|
154
|
+
) => {
|
|
155
|
+
if (!uses) return 0;
|
|
156
|
+
const prices = { ...DEFAULT_SERVER_TOOL_PRICES, ...(overrides ?? {}) };
|
|
157
|
+
let total = 0;
|
|
158
|
+
for (const [tool, count] of Object.entries(uses)) {
|
|
159
|
+
if (count > 0 && prices[tool] > 0) total += Math.round(count * prices[tool]);
|
|
160
|
+
}
|
|
161
|
+
return total;
|
|
162
|
+
};
|
|
163
|
+
|
|
139
164
|
// Upsert-add a settled amount into the durable per-(bucket, period) usage row.
|
|
140
165
|
// These rows are never swept by request retention, so spend history survives.
|
|
141
166
|
async function addUsage(
|
|
@@ -394,6 +419,15 @@ export const startRequest = mutation({
|
|
|
394
419
|
tags: v.optional(v.array(vTag)),
|
|
395
420
|
model: v.string(),
|
|
396
421
|
messages: v.array(vMessage),
|
|
422
|
+
// Reserve this exact amount (nanodollars) instead of the token-based
|
|
423
|
+
// estimate. Use it whenever the cost is known up front — image generation
|
|
424
|
+
// (n × per-image), audio, per-call APIs — so a hard cap reserves the real
|
|
425
|
+
// amount rather than a meaningless token guess.
|
|
426
|
+
estimatedCostNanos: v.optional(v.number()),
|
|
427
|
+
// Hold the reservation this long (ms) before the reconciler may reap it —
|
|
428
|
+
// for long async jobs (video) that settle minutes later. Extends the 30-min
|
|
429
|
+
// default floor.
|
|
430
|
+
reserveTtlMs: v.optional(v.number()),
|
|
397
431
|
rerunOf: v.optional(v.id("requests")),
|
|
398
432
|
},
|
|
399
433
|
returns: vStartResult,
|
|
@@ -424,6 +458,11 @@ export const startRequest = mutation({
|
|
|
424
458
|
const month = monthStamp();
|
|
425
459
|
const priceInfo = await getPrice(ctx, args.model);
|
|
426
460
|
const est = estimateUsage(args.messages, priceInfo);
|
|
461
|
+
// A caller-supplied known cost (image gen, audio, per-call APIs) reserves
|
|
462
|
+
// the real amount up front; the token estimate stays as the token reserve.
|
|
463
|
+
if (args.estimatedCostNanos !== undefined && args.estimatedCostNanos >= 0) {
|
|
464
|
+
est.cost = Math.round(args.estimatedCostNanos);
|
|
465
|
+
}
|
|
427
466
|
const warnings: string[] = [];
|
|
428
467
|
const notices: string[] = [];
|
|
429
468
|
|
|
@@ -657,6 +696,7 @@ export const startRequest = mutation({
|
|
|
657
696
|
model: args.model,
|
|
658
697
|
messages: args.messages,
|
|
659
698
|
rerunOf: args.rerunOf,
|
|
699
|
+
...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
|
|
660
700
|
status: "pending",
|
|
661
701
|
estimatedNanos: est.cost,
|
|
662
702
|
estimatedTokens: est.tokens,
|
|
@@ -684,9 +724,13 @@ export const finishRequest = mutation({
|
|
|
684
724
|
promptTokens: v.optional(v.number()),
|
|
685
725
|
completionTokens: v.optional(v.number()),
|
|
686
726
|
cachedTokens: v.optional(v.number()),
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
727
|
+
// Provider server-tool invocations that bill a per-call fee (e.g.
|
|
728
|
+
// { web_search: 3 }). Added to the token cost when no authoritative cost is
|
|
729
|
+
// supplied; recorded either way.
|
|
730
|
+
serverToolUses: v.optional(v.record(v.string(), v.number())),
|
|
731
|
+
// Authoritative cost from the gateway/provider, if reported. When present
|
|
732
|
+
// it's recorded verbatim (already includes any tool fees); when absent we
|
|
733
|
+
// price from tokens (cache-aware) plus server-tool fees.
|
|
690
734
|
costNanos: v.optional(v.number()),
|
|
691
735
|
latencyMs: v.optional(v.number()),
|
|
692
736
|
},
|
|
@@ -711,17 +755,22 @@ export const finishRequest = mutation({
|
|
|
711
755
|
const promptTokens = Math.max(0, args.promptTokens ?? 0);
|
|
712
756
|
const completionTokens = Math.max(0, args.completionTokens ?? 0);
|
|
713
757
|
const cachedTokens = Math.min(promptTokens, Math.max(0, args.cachedTokens ?? 0));
|
|
714
|
-
// Prefer an authoritative gateway cost when supplied
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
758
|
+
// Prefer an authoritative gateway cost when supplied (it already includes
|
|
759
|
+
// tool fees); otherwise price from tokens — discounting the cached
|
|
760
|
+
// (prompt-cache-read) slice — plus any server-tool per-call fees.
|
|
761
|
+
let costNanos: number;
|
|
762
|
+
if (args.costNanos !== undefined && args.costNanos >= 0) {
|
|
763
|
+
costNanos = Math.round(args.costNanos);
|
|
764
|
+
} else {
|
|
765
|
+
const settings = await getSettings(ctx);
|
|
766
|
+
costNanos =
|
|
767
|
+
settleCost(
|
|
768
|
+
promptTokens,
|
|
769
|
+
cachedTokens,
|
|
770
|
+
completionTokens,
|
|
771
|
+
await getPrice(ctx, request.model)
|
|
772
|
+
) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
|
|
773
|
+
}
|
|
725
774
|
|
|
726
775
|
// Durable write to the request's OWN row only — uncontended, so it always
|
|
727
776
|
// lands. `settled: false` hands it to the fold step; the row is never left
|
|
@@ -733,6 +782,7 @@ export const finishRequest = mutation({
|
|
|
733
782
|
promptTokens,
|
|
734
783
|
completionTokens,
|
|
735
784
|
...(cachedTokens > 0 ? { cachedTokens } : {}),
|
|
785
|
+
...(args.serverToolUses ? { serverToolUses: args.serverToolUses } : {}),
|
|
736
786
|
costNanos,
|
|
737
787
|
latencyMs: args.latencyMs,
|
|
738
788
|
settled: false,
|
|
@@ -833,14 +883,20 @@ export const reconcile = internalMutation({
|
|
|
833
883
|
.take(200);
|
|
834
884
|
for (const req of toFold) await foldOne(ctx, req);
|
|
835
885
|
|
|
886
|
+
// Reap dead reservations: pending rows older than the default floor, but a
|
|
887
|
+
// per-request reserveTtlMs (set for long async jobs like video) holds the
|
|
888
|
+
// reservation until *its* deadline so a still-running job isn't reaped.
|
|
836
889
|
const cutoff = Date.now() - STALE_PENDING_MS;
|
|
837
|
-
const
|
|
890
|
+
const candidates = await ctx.db
|
|
838
891
|
.query("requests")
|
|
839
892
|
.withIndex("status", (q) =>
|
|
840
893
|
q.eq("status", "pending").lt("_creationTime", cutoff)
|
|
841
894
|
)
|
|
842
895
|
.take(200);
|
|
843
|
-
|
|
896
|
+
let expired = 0;
|
|
897
|
+
for (const req of candidates) {
|
|
898
|
+
const ttl = req.reserveTtlMs ?? STALE_PENDING_MS;
|
|
899
|
+
if (Date.now() - req._creationTime <= ttl) continue; // still within its window
|
|
844
900
|
await ctx.db.patch(req._id, {
|
|
845
901
|
status: "error",
|
|
846
902
|
error: "Timed out before settling; reservation released",
|
|
@@ -848,6 +904,7 @@ export const reconcile = internalMutation({
|
|
|
848
904
|
settled: false,
|
|
849
905
|
});
|
|
850
906
|
await foldOne(ctx, await ctx.db.get(req._id));
|
|
907
|
+
expired++;
|
|
851
908
|
}
|
|
852
909
|
|
|
853
910
|
// Retention: delete terminal, fully-accounted request rows past the window.
|
|
@@ -872,7 +929,7 @@ export const reconcile = internalMutation({
|
|
|
872
929
|
}
|
|
873
930
|
}
|
|
874
931
|
}
|
|
875
|
-
return { folded: toFold.length, expired
|
|
932
|
+
return { folded: toFold.length, expired, purged };
|
|
876
933
|
},
|
|
877
934
|
});
|
|
878
935
|
|
|
@@ -1347,3 +1404,26 @@ export const listPrices = query({
|
|
|
1347
1404
|
return merged;
|
|
1348
1405
|
},
|
|
1349
1406
|
});
|
|
1407
|
+
|
|
1408
|
+
// Per-call fees for provider server tools (web search, etc.), defaults merged
|
|
1409
|
+
// with any deployment overrides.
|
|
1410
|
+
export const listServerToolPrices = query({
|
|
1411
|
+
args: {},
|
|
1412
|
+
handler: async (ctx) => {
|
|
1413
|
+
const s = await getSettings(ctx as any);
|
|
1414
|
+
return { ...DEFAULT_SERVER_TOOL_PRICES, ...(s?.serverToolPrices ?? {}) };
|
|
1415
|
+
},
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
export const setServerToolPrice = mutation({
|
|
1419
|
+
args: { tool: v.string(), nanosPerCall: v.number() },
|
|
1420
|
+
returns: v.null(),
|
|
1421
|
+
handler: async (ctx, { tool, nanosPerCall }) => {
|
|
1422
|
+
if (nanosPerCall < 0) throw new Error("Prices must be non-negative");
|
|
1423
|
+
const s = await getSettings(ctx);
|
|
1424
|
+
const serverToolPrices = { ...(s?.serverToolPrices ?? {}), [tool]: nanosPerCall };
|
|
1425
|
+
if (s) await ctx.db.patch(s._id, { serverToolPrices });
|
|
1426
|
+
else await ctx.db.insert("settings", { key: "singleton", serverToolPrices });
|
|
1427
|
+
return null;
|
|
1428
|
+
},
|
|
1429
|
+
});
|
package/src/component/schema.ts
CHANGED
|
@@ -132,6 +132,14 @@ export default defineSchema({
|
|
|
132
132
|
completionTokens: v.optional(v.number()),
|
|
133
133
|
// subset of promptTokens served from the provider's prompt cache (cheaper).
|
|
134
134
|
cachedTokens: v.optional(v.number()),
|
|
135
|
+
// server-side tool invocations that bill a per-call fee on top of tokens
|
|
136
|
+
// (e.g. { web_search: 3 }). Priced via serverToolPrices at settle.
|
|
137
|
+
serverToolUses: v.optional(v.record(v.string(), v.number())),
|
|
138
|
+
// How long the reservation may stay held before the reconciler reaps it as
|
|
139
|
+
// dead (ms). For long async jobs (video generation) set this to the job's
|
|
140
|
+
// max duration so the hold isn't released mid-flight. Extends the default
|
|
141
|
+
// 30-min floor; only stored while pending.
|
|
142
|
+
reserveTtlMs: v.optional(v.number()),
|
|
135
143
|
costNanos: v.optional(v.number()),
|
|
136
144
|
latencyMs: v.optional(v.number()),
|
|
137
145
|
rerunOf: v.optional(v.id("requests")),
|
|
@@ -184,5 +192,8 @@ export default defineSchema({
|
|
|
184
192
|
// default approaching-limit alert threshold (fraction of a cap) for buckets
|
|
185
193
|
// that don't set their own warnAtPct. 0/unset disables threshold alerts.
|
|
186
194
|
defaultWarnAtPct: v.optional(v.number()),
|
|
195
|
+
// per-call price (nanodollars) overrides for provider server tools, keyed by
|
|
196
|
+
// tool name (e.g. { web_search: 12_000_000 }). Merged over the defaults.
|
|
197
|
+
serverToolPrices: v.optional(v.record(v.string(), v.number())),
|
|
187
198
|
}).index("key", ["key"]),
|
|
188
199
|
});
|