@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/dist/component/lib.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ export declare const startRequest: import("convex/server").RegisteredMutation<"p
|
|
|
4
4
|
dimension: string;
|
|
5
5
|
value: string;
|
|
6
6
|
}[] | undefined;
|
|
7
|
+
reserveTtlMs?: number | undefined;
|
|
7
8
|
rerunOf?: import("convex/values").GenericId<"requests"> | undefined;
|
|
9
|
+
estimatedCostNanos?: number | undefined;
|
|
8
10
|
userId: string;
|
|
9
11
|
model: string;
|
|
10
12
|
messages: {
|
|
@@ -27,6 +29,7 @@ export declare const finishRequest: import("convex/server").RegisteredMutation<"
|
|
|
27
29
|
promptTokens?: number | undefined;
|
|
28
30
|
completionTokens?: number | undefined;
|
|
29
31
|
cachedTokens?: number | undefined;
|
|
32
|
+
serverToolUses?: Record<string, number> | undefined;
|
|
30
33
|
costNanos?: number | undefined;
|
|
31
34
|
latencyMs?: number | undefined;
|
|
32
35
|
requestId: import("convex/values").GenericId<"requests">;
|
|
@@ -65,6 +68,8 @@ export declare const lineage: import("convex/server").RegisteredQuery<"public",
|
|
|
65
68
|
promptTokens?: number | undefined;
|
|
66
69
|
completionTokens?: number | undefined;
|
|
67
70
|
cachedTokens?: number | undefined;
|
|
71
|
+
serverToolUses?: Record<string, number> | undefined;
|
|
72
|
+
reserveTtlMs?: number | undefined;
|
|
68
73
|
costNanos?: number | undefined;
|
|
69
74
|
latencyMs?: number | undefined;
|
|
70
75
|
rerunOf?: import("convex/values").GenericId<"requests"> | undefined;
|
|
@@ -94,6 +99,8 @@ export declare const lineage: import("convex/server").RegisteredQuery<"public",
|
|
|
94
99
|
promptTokens?: number | undefined;
|
|
95
100
|
completionTokens?: number | undefined;
|
|
96
101
|
cachedTokens?: number | undefined;
|
|
102
|
+
serverToolUses?: Record<string, number> | undefined;
|
|
103
|
+
reserveTtlMs?: number | undefined;
|
|
97
104
|
costNanos?: number | undefined;
|
|
98
105
|
latencyMs?: number | undefined;
|
|
99
106
|
rerunOf?: import("convex/values").GenericId<"requests"> | undefined;
|
|
@@ -126,6 +133,8 @@ export declare const getRequest: import("convex/server").RegisteredQuery<"public
|
|
|
126
133
|
promptTokens?: number | undefined;
|
|
127
134
|
completionTokens?: number | undefined;
|
|
128
135
|
cachedTokens?: number | undefined;
|
|
136
|
+
serverToolUses?: Record<string, number> | undefined;
|
|
137
|
+
reserveTtlMs?: number | undefined;
|
|
129
138
|
costNanos?: number | undefined;
|
|
130
139
|
latencyMs?: number | undefined;
|
|
131
140
|
rerunOf?: import("convex/values").GenericId<"requests"> | undefined;
|
|
@@ -160,6 +169,8 @@ export declare const listRequests: import("convex/server").RegisteredQuery<"publ
|
|
|
160
169
|
promptTokens?: number | undefined;
|
|
161
170
|
completionTokens?: number | undefined;
|
|
162
171
|
cachedTokens?: number | undefined;
|
|
172
|
+
serverToolUses?: Record<string, number> | undefined;
|
|
173
|
+
reserveTtlMs?: number | undefined;
|
|
163
174
|
costNanos?: number | undefined;
|
|
164
175
|
latencyMs?: number | undefined;
|
|
165
176
|
rerunOf?: import("convex/values").GenericId<"requests"> | undefined;
|
|
@@ -359,3 +370,10 @@ export declare const listPrices: import("convex/server").RegisteredQuery<"public
|
|
|
359
370
|
cached?: number;
|
|
360
371
|
overridden: boolean;
|
|
361
372
|
}>>>;
|
|
373
|
+
export declare const listServerToolPrices: import("convex/server").RegisteredQuery<"public", {}, Promise<{
|
|
374
|
+
[x: string]: number;
|
|
375
|
+
}>>;
|
|
376
|
+
export declare const setServerToolPrice: import("convex/server").RegisteredMutation<"public", {
|
|
377
|
+
tool: string;
|
|
378
|
+
nanosPerCall: number;
|
|
379
|
+
}, Promise<null>>;
|
package/dist/component/lib.js
CHANGED
|
@@ -32,6 +32,14 @@ const DEFAULT_PRICES = {
|
|
|
32
32
|
"openai/gpt-5": { input: 1_250_000_000, output: 10_000_000_000 },
|
|
33
33
|
"openai/gpt-5-mini": { input: 250_000_000, output: 2_000_000_000 },
|
|
34
34
|
};
|
|
35
|
+
// Per-call price (nanodollars) for provider server-side tools that bill a fee on
|
|
36
|
+
// top of tokens — e.g. Anthropic web search at ~$0.01/call. Keyed by the tool
|
|
37
|
+
// name the caller reports in `serverToolUses` (e.g. { web_search: 3 }). Used
|
|
38
|
+
// only when a request settles WITHOUT an authoritative gateway cost; if you pass
|
|
39
|
+
// `costNanos`, that already includes tool fees. Override via setServerToolPrice.
|
|
40
|
+
const DEFAULT_SERVER_TOOL_PRICES = {
|
|
41
|
+
web_search: 10_000_000, // $0.01 per search
|
|
42
|
+
};
|
|
35
43
|
// Pessimistic assumed output length when reserving budget up front. This makes
|
|
36
44
|
// concurrent admission atomic against the estimate; a response that exceeds the
|
|
37
45
|
// estimate can still settle above the cap by the estimation delta.
|
|
@@ -99,6 +107,20 @@ const settleCost = (promptTokens, cachedTokens, completionTokens, price) => {
|
|
|
99
107
|
(cached / 1e6) * cachedRate(price) +
|
|
100
108
|
(completionTokens / 1e6) * price.output);
|
|
101
109
|
};
|
|
110
|
+
// Per-call fees for provider server tools (web search, etc.), merging the
|
|
111
|
+
// defaults with any deployment overrides. Unknown tools price at 0 (recorded
|
|
112
|
+
// but not charged) rather than guessing.
|
|
113
|
+
const serverToolCost = (uses, overrides) => {
|
|
114
|
+
if (!uses)
|
|
115
|
+
return 0;
|
|
116
|
+
const prices = { ...DEFAULT_SERVER_TOOL_PRICES, ...(overrides ?? {}) };
|
|
117
|
+
let total = 0;
|
|
118
|
+
for (const [tool, count] of Object.entries(uses)) {
|
|
119
|
+
if (count > 0 && prices[tool] > 0)
|
|
120
|
+
total += Math.round(count * prices[tool]);
|
|
121
|
+
}
|
|
122
|
+
return total;
|
|
123
|
+
};
|
|
102
124
|
// Upsert-add a settled amount into the durable per-(bucket, period) usage row.
|
|
103
125
|
// These rows are never swept by request retention, so spend history survives.
|
|
104
126
|
async function addUsage(ctx, dimension, value, period, stamp, spendNanos, tokens, requests) {
|
|
@@ -281,6 +303,15 @@ export const startRequest = mutation({
|
|
|
281
303
|
tags: v.optional(v.array(vTag)),
|
|
282
304
|
model: v.string(),
|
|
283
305
|
messages: v.array(vMessage),
|
|
306
|
+
// Reserve this exact amount (nanodollars) instead of the token-based
|
|
307
|
+
// estimate. Use it whenever the cost is known up front — image generation
|
|
308
|
+
// (n × per-image), audio, per-call APIs — so a hard cap reserves the real
|
|
309
|
+
// amount rather than a meaningless token guess.
|
|
310
|
+
estimatedCostNanos: v.optional(v.number()),
|
|
311
|
+
// Hold the reservation this long (ms) before the reconciler may reap it —
|
|
312
|
+
// for long async jobs (video) that settle minutes later. Extends the 30-min
|
|
313
|
+
// default floor.
|
|
314
|
+
reserveTtlMs: v.optional(v.number()),
|
|
284
315
|
rerunOf: v.optional(v.id("requests")),
|
|
285
316
|
},
|
|
286
317
|
returns: vStartResult,
|
|
@@ -310,6 +341,11 @@ export const startRequest = mutation({
|
|
|
310
341
|
const month = monthStamp();
|
|
311
342
|
const priceInfo = await getPrice(ctx, args.model);
|
|
312
343
|
const est = estimateUsage(args.messages, priceInfo);
|
|
344
|
+
// A caller-supplied known cost (image gen, audio, per-call APIs) reserves
|
|
345
|
+
// the real amount up front; the token estimate stays as the token reserve.
|
|
346
|
+
if (args.estimatedCostNanos !== undefined && args.estimatedCostNanos >= 0) {
|
|
347
|
+
est.cost = Math.round(args.estimatedCostNanos);
|
|
348
|
+
}
|
|
313
349
|
const warnings = [];
|
|
314
350
|
const notices = [];
|
|
315
351
|
// Model allow/deny policy (component-wide).
|
|
@@ -499,6 +535,7 @@ export const startRequest = mutation({
|
|
|
499
535
|
model: args.model,
|
|
500
536
|
messages: args.messages,
|
|
501
537
|
rerunOf: args.rerunOf,
|
|
538
|
+
...(args.reserveTtlMs !== undefined ? { reserveTtlMs: args.reserveTtlMs } : {}),
|
|
502
539
|
status: "pending",
|
|
503
540
|
estimatedNanos: est.cost,
|
|
504
541
|
estimatedTokens: est.tokens,
|
|
@@ -525,9 +562,13 @@ export const finishRequest = mutation({
|
|
|
525
562
|
promptTokens: v.optional(v.number()),
|
|
526
563
|
completionTokens: v.optional(v.number()),
|
|
527
564
|
cachedTokens: v.optional(v.number()),
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
565
|
+
// Provider server-tool invocations that bill a per-call fee (e.g.
|
|
566
|
+
// { web_search: 3 }). Added to the token cost when no authoritative cost is
|
|
567
|
+
// supplied; recorded either way.
|
|
568
|
+
serverToolUses: v.optional(v.record(v.string(), v.number())),
|
|
569
|
+
// Authoritative cost from the gateway/provider, if reported. When present
|
|
570
|
+
// it's recorded verbatim (already includes any tool fees); when absent we
|
|
571
|
+
// price from tokens (cache-aware) plus server-tool fees.
|
|
531
572
|
costNanos: v.optional(v.number()),
|
|
532
573
|
latencyMs: v.optional(v.number()),
|
|
533
574
|
},
|
|
@@ -551,11 +592,18 @@ export const finishRequest = mutation({
|
|
|
551
592
|
const promptTokens = Math.max(0, args.promptTokens ?? 0);
|
|
552
593
|
const completionTokens = Math.max(0, args.completionTokens ?? 0);
|
|
553
594
|
const cachedTokens = Math.min(promptTokens, Math.max(0, args.cachedTokens ?? 0));
|
|
554
|
-
// Prefer an authoritative gateway cost when supplied
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
595
|
+
// Prefer an authoritative gateway cost when supplied (it already includes
|
|
596
|
+
// tool fees); otherwise price from tokens — discounting the cached
|
|
597
|
+
// (prompt-cache-read) slice — plus any server-tool per-call fees.
|
|
598
|
+
let costNanos;
|
|
599
|
+
if (args.costNanos !== undefined && args.costNanos >= 0) {
|
|
600
|
+
costNanos = Math.round(args.costNanos);
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
const settings = await getSettings(ctx);
|
|
604
|
+
costNanos =
|
|
605
|
+
settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model)) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
|
|
606
|
+
}
|
|
559
607
|
// Durable write to the request's OWN row only — uncontended, so it always
|
|
560
608
|
// lands. `settled: false` hands it to the fold step; the row is never left
|
|
561
609
|
// orphaned in "pending" even if the totals update below fails and retries.
|
|
@@ -566,6 +614,7 @@ export const finishRequest = mutation({
|
|
|
566
614
|
promptTokens,
|
|
567
615
|
completionTokens,
|
|
568
616
|
...(cachedTokens > 0 ? { cachedTokens } : {}),
|
|
617
|
+
...(args.serverToolUses ? { serverToolUses: args.serverToolUses } : {}),
|
|
569
618
|
costNanos,
|
|
570
619
|
latencyMs: args.latencyMs,
|
|
571
620
|
settled: false,
|
|
@@ -659,12 +708,19 @@ export const reconcile = internalMutation({
|
|
|
659
708
|
.take(200);
|
|
660
709
|
for (const req of toFold)
|
|
661
710
|
await foldOne(ctx, req);
|
|
711
|
+
// Reap dead reservations: pending rows older than the default floor, but a
|
|
712
|
+
// per-request reserveTtlMs (set for long async jobs like video) holds the
|
|
713
|
+
// reservation until *its* deadline so a still-running job isn't reaped.
|
|
662
714
|
const cutoff = Date.now() - STALE_PENDING_MS;
|
|
663
|
-
const
|
|
715
|
+
const candidates = await ctx.db
|
|
664
716
|
.query("requests")
|
|
665
717
|
.withIndex("status", (q) => q.eq("status", "pending").lt("_creationTime", cutoff))
|
|
666
718
|
.take(200);
|
|
667
|
-
|
|
719
|
+
let expired = 0;
|
|
720
|
+
for (const req of candidates) {
|
|
721
|
+
const ttl = req.reserveTtlMs ?? STALE_PENDING_MS;
|
|
722
|
+
if (Date.now() - req._creationTime <= ttl)
|
|
723
|
+
continue; // still within its window
|
|
668
724
|
await ctx.db.patch(req._id, {
|
|
669
725
|
status: "error",
|
|
670
726
|
error: "Timed out before settling; reservation released",
|
|
@@ -672,6 +728,7 @@ export const reconcile = internalMutation({
|
|
|
672
728
|
settled: false,
|
|
673
729
|
});
|
|
674
730
|
await foldOne(ctx, await ctx.db.get(req._id));
|
|
731
|
+
expired++;
|
|
675
732
|
}
|
|
676
733
|
// Retention: delete terminal, fully-accounted request rows past the window.
|
|
677
734
|
const settings = await getSettings(ctx);
|
|
@@ -693,7 +750,7 @@ export const reconcile = internalMutation({
|
|
|
693
750
|
}
|
|
694
751
|
}
|
|
695
752
|
}
|
|
696
|
-
return { folded: toFold.length, expired
|
|
753
|
+
return { folded: toFold.length, expired, purged };
|
|
697
754
|
},
|
|
698
755
|
});
|
|
699
756
|
export const setRetention = mutation({
|
|
@@ -1139,3 +1196,27 @@ export const listPrices = query({
|
|
|
1139
1196
|
return merged;
|
|
1140
1197
|
},
|
|
1141
1198
|
});
|
|
1199
|
+
// Per-call fees for provider server tools (web search, etc.), defaults merged
|
|
1200
|
+
// with any deployment overrides.
|
|
1201
|
+
export const listServerToolPrices = query({
|
|
1202
|
+
args: {},
|
|
1203
|
+
handler: async (ctx) => {
|
|
1204
|
+
const s = await getSettings(ctx);
|
|
1205
|
+
return { ...DEFAULT_SERVER_TOOL_PRICES, ...(s?.serverToolPrices ?? {}) };
|
|
1206
|
+
},
|
|
1207
|
+
});
|
|
1208
|
+
export const setServerToolPrice = mutation({
|
|
1209
|
+
args: { tool: v.string(), nanosPerCall: v.number() },
|
|
1210
|
+
returns: v.null(),
|
|
1211
|
+
handler: async (ctx, { tool, nanosPerCall }) => {
|
|
1212
|
+
if (nanosPerCall < 0)
|
|
1213
|
+
throw new Error("Prices must be non-negative");
|
|
1214
|
+
const s = await getSettings(ctx);
|
|
1215
|
+
const serverToolPrices = { ...(s?.serverToolPrices ?? {}), [tool]: nanosPerCall };
|
|
1216
|
+
if (s)
|
|
1217
|
+
await ctx.db.patch(s._id, { serverToolPrices });
|
|
1218
|
+
else
|
|
1219
|
+
await ctx.db.insert("settings", { key: "singleton", serverToolPrices });
|
|
1220
|
+
return null;
|
|
1221
|
+
},
|
|
1222
|
+
});
|
|
@@ -150,6 +150,8 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
150
150
|
promptTokens?: number | undefined;
|
|
151
151
|
completionTokens?: number | undefined;
|
|
152
152
|
cachedTokens?: number | undefined;
|
|
153
|
+
serverToolUses?: Record<string, number> | undefined;
|
|
154
|
+
reserveTtlMs?: number | undefined;
|
|
153
155
|
costNanos?: number | undefined;
|
|
154
156
|
latencyMs?: number | undefined;
|
|
155
157
|
rerunOf?: import("convex/values").GenericId<"requests"> | undefined;
|
|
@@ -195,10 +197,12 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
195
197
|
promptTokens: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
196
198
|
completionTokens: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
197
199
|
cachedTokens: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
200
|
+
serverToolUses: import("convex/values").VRecord<Record<string, number> | undefined, import("convex/values").VString<string, "required">, import("convex/values").VFloat64<number, "required">, "optional", string>;
|
|
201
|
+
reserveTtlMs: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
198
202
|
costNanos: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
199
203
|
latencyMs: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
200
204
|
rerunOf: import("convex/values").VId<import("convex/values").GenericId<"requests"> | undefined, "optional">;
|
|
201
|
-
}, "required", "userId" | "actionName" | "tags" | "model" | "estimatedNanos" | "estimatedTokens" | "unpricedModel" | "overBudget" | "settled" | "messages" | "status" | "error" | "responseText" | "promptTokens" | "completionTokens" | "cachedTokens" | "costNanos" | "latencyMs" | "rerunOf"
|
|
205
|
+
}, "required", "userId" | "actionName" | "tags" | "model" | "estimatedNanos" | "estimatedTokens" | "unpricedModel" | "overBudget" | "settled" | "messages" | "status" | "error" | "responseText" | "promptTokens" | "completionTokens" | "cachedTokens" | "serverToolUses" | "reserveTtlMs" | "costNanos" | "latencyMs" | "rerunOf" | `serverToolUses.${string}`>, {
|
|
202
206
|
userId: ["userId", "_creationTime"];
|
|
203
207
|
status: ["status", "_creationTime"];
|
|
204
208
|
rerunOf: ["rerunOf", "_creationTime"];
|
|
@@ -229,6 +233,7 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
229
233
|
globalBumpDayStamp?: string | undefined;
|
|
230
234
|
retentionMs?: number | undefined;
|
|
231
235
|
defaultWarnAtPct?: number | undefined;
|
|
236
|
+
serverToolPrices?: Record<string, number> | undefined;
|
|
232
237
|
key: string;
|
|
233
238
|
}, {
|
|
234
239
|
key: import("convex/values").VString<string, "required">;
|
|
@@ -242,7 +247,8 @@ declare const _default: import("convex/server").SchemaDefinition<{
|
|
|
242
247
|
globalBumpDayStamp: import("convex/values").VString<string | undefined, "optional">;
|
|
243
248
|
retentionMs: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
244
249
|
defaultWarnAtPct: import("convex/values").VFloat64<number | undefined, "optional">;
|
|
245
|
-
|
|
250
|
+
serverToolPrices: import("convex/values").VRecord<Record<string, number> | undefined, import("convex/values").VString<string, "required">, import("convex/values").VFloat64<number, "required">, "optional", string>;
|
|
251
|
+
}, "required", "key" | "modelMode" | "models" | "globalDailySpendLimitNanos" | "globalLifetimeSpendLimitNanos" | "globalEnforcement" | "globalDailyBumpNanos" | "globalLifetimeBumpNanos" | "globalBumpDayStamp" | "retentionMs" | "defaultWarnAtPct" | "serverToolPrices" | `serverToolPrices.${string}`>, {
|
|
246
252
|
key: ["key", "_creationTime"];
|
|
247
253
|
}, {}, {}>;
|
|
248
254
|
}, true>;
|
package/dist/component/schema.js
CHANGED
|
@@ -120,6 +120,14 @@ export default defineSchema({
|
|
|
120
120
|
completionTokens: v.optional(v.number()),
|
|
121
121
|
// subset of promptTokens served from the provider's prompt cache (cheaper).
|
|
122
122
|
cachedTokens: v.optional(v.number()),
|
|
123
|
+
// server-side tool invocations that bill a per-call fee on top of tokens
|
|
124
|
+
// (e.g. { web_search: 3 }). Priced via serverToolPrices at settle.
|
|
125
|
+
serverToolUses: v.optional(v.record(v.string(), v.number())),
|
|
126
|
+
// How long the reservation may stay held before the reconciler reaps it as
|
|
127
|
+
// dead (ms). For long async jobs (video generation) set this to the job's
|
|
128
|
+
// max duration so the hold isn't released mid-flight. Extends the default
|
|
129
|
+
// 30-min floor; only stored while pending.
|
|
130
|
+
reserveTtlMs: v.optional(v.number()),
|
|
123
131
|
costNanos: v.optional(v.number()),
|
|
124
132
|
latencyMs: v.optional(v.number()),
|
|
125
133
|
rerunOf: v.optional(v.id("requests")),
|
|
@@ -162,5 +170,8 @@ export default defineSchema({
|
|
|
162
170
|
// default approaching-limit alert threshold (fraction of a cap) for buckets
|
|
163
171
|
// that don't set their own warnAtPct. 0/unset disables threshold alerts.
|
|
164
172
|
defaultWarnAtPct: v.optional(v.number()),
|
|
173
|
+
// per-call price (nanodollars) overrides for provider server tools, keyed by
|
|
174
|
+
// tool name (e.g. { web_search: 12_000_000 }). Merged over the defaults.
|
|
175
|
+
serverToolPrices: v.optional(v.record(v.string(), v.number())),
|
|
165
176
|
}).index("key", ["key"]),
|
|
166
177
|
});
|