@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.
@@ -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>>;
@@ -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
- // Authoritative cost from the gateway, if it ever reports one. When present
529
- // it's recorded verbatim (no token-based estimate); when absent we price
530
- // from tokens (cache-aware). Wired now so adopting a real cost is one line.
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; otherwise price from
555
- // tokens, discounting the cached (prompt-cache-read) slice of the prompt.
556
- const costNanos = args.costNanos !== undefined && args.costNanos >= 0
557
- ? Math.round(args.costNanos)
558
- : settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model));
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 stale = await ctx.db
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
- for (const req of stale) {
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: stale.length, purged };
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
- }, "required", "key" | "modelMode" | "models" | "globalDailySpendLimitNanos" | "globalLifetimeSpendLimitNanos" | "globalEnforcement" | "globalDailyBumpNanos" | "globalLifetimeBumpNanos" | "globalBumpDayStamp" | "retentionMs" | "defaultWarnAtPct">, {
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>;
@@ -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
  });
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "support@convex.dev",
8
8
  "url": "https://github.com/get-convex/ai-budget/issues"
9
9
  },
10
- "version": "0.0.2-alpha.10",
10
+ "version": "0.0.2-alpha.13",
11
11
  "license": "Apache-2.0",
12
12
  "type": "module",
13
13
  "keywords": [