@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.
@@ -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
- // Authoritative cost from the gateway, if it ever reports one. When present
688
- // it's recorded verbatim (no token-based estimate); when absent we price
689
- // from tokens (cache-aware). Wired now so adopting a real cost is one line.
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; otherwise price from
715
- // tokens, discounting the cached (prompt-cache-read) slice of the prompt.
716
- const costNanos =
717
- args.costNanos !== undefined && args.costNanos >= 0
718
- ? Math.round(args.costNanos)
719
- : settleCost(
720
- promptTokens,
721
- cachedTokens,
722
- completionTokens,
723
- await getPrice(ctx, request.model)
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 stale = await ctx.db
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
- for (const req of stale) {
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: stale.length, purged };
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
+ });
@@ -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
  });