@convex-dev/ai-budget 0.0.2-alpha.0 → 0.0.2-alpha.12

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.
@@ -6,7 +6,7 @@ import {
6
6
  type MutationCtx,
7
7
  } from "./_generated/server";
8
8
  import { api, internal, components } from "./_generated/api";
9
- import { vMessage } from "./schema";
9
+ import { vMessage, vTag } from "./schema";
10
10
  import type { Doc } from "./_generated/dataModel";
11
11
  import { ShardedCounter } from "@convex-dev/sharded-counter";
12
12
 
@@ -19,6 +19,13 @@ import { ShardedCounter } from "@convex-dev/sharded-counter";
19
19
  const NANOS_PER_DOLLAR = 1e9;
20
20
  const fmtUsd = (nanos: number) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
21
21
 
22
+ // Built-in attribution dimensions. `user` and `action` are always populated
23
+ // from a request's userId/actionName; apps can add any other dimensions
24
+ // (team, project, customer, env, …) as tags. These two names are reserved —
25
+ // tags carrying them are ignored in favor of the first-class fields.
26
+ const USER_DIM = "user";
27
+ const ACTION_DIM = "action";
28
+
22
29
  // Deployment-wide spend totals (nanodollars), sharded for high write throughput.
23
30
  // Keyed "total" (lifetime) and "day:<UTC date>" (natural daily reset).
24
31
  const globalSpend = new ShardedCounter(components.shardedCounter);
@@ -36,9 +43,18 @@ const DEFAULT_PRICES: Record<string, { input: number; output: number }> = {
36
43
  "openai/gpt-5-mini": { input: 250_000_000, output: 2_000_000_000 },
37
44
  };
38
45
 
39
- // Pessimistic assumed output length when reserving budget up front. Reservations
40
- // only need to be an upper bound often enough to keep a hard cap honest; the
41
- // actual cost replaces the estimate the moment the request settles.
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
+
55
+ // Pessimistic assumed output length when reserving budget up front. This makes
56
+ // concurrent admission atomic against the estimate; a response that exceeds the
57
+ // estimate can still settle above the cap by the estimation delta.
42
58
  const ESTIMATED_OUTPUT_TOKENS = 800;
43
59
  // A request still "pending" after this long is presumed dead (its action
44
60
  // crashed before settling); the reconciler releases its reservation. Set well
@@ -53,7 +69,13 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
53
69
  // Override per-deployment via setRetention.
54
70
  const DEFAULT_RETENTION_MS = 60 * 60 * 1000; // 1 hour
55
71
 
56
- const dayStamp = () => new Date().toISOString().slice(0, 10);
72
+ const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
73
+ const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
74
+
75
+ // Cached (prompt-cache-read) input tokens are billed far below the normal input
76
+ // rate. When a model's price has no explicit cachedNanosPerMTok, charge this
77
+ // fraction of its input rate (providers commonly discount ~90%).
78
+ const CACHE_DISCOUNT = 0.1;
57
79
 
58
80
  // Conservative fallback for any model not in the price table: the max of every
59
81
  // known price dimension. Falling back to 0 would be fail-open — an unpriced
@@ -78,14 +100,21 @@ async function getPrice(ctx: MutationCtx, model: string) {
78
100
  return {
79
101
  input: override.inputNanosPerMTok,
80
102
  output: override.outputNanosPerMTok,
103
+ cached: override.cachedNanosPerMTok,
81
104
  known: true,
82
105
  };
83
106
  }
84
107
  const known = DEFAULT_PRICES[model];
85
- if (known) return { ...known, known: true };
86
- return { ...CONSERVATIVE_PRICE, known: false };
108
+ if (known) return { ...known, cached: undefined, known: true };
109
+ return { ...CONSERVATIVE_PRICE, cached: undefined, known: false };
87
110
  }
88
111
 
112
+ type Price = { input: number; output: number; cached?: number };
113
+ // The per-Mtok rate for cached (prompt-cache-read) tokens: an explicit override,
114
+ // else a discount off the input rate.
115
+ const cachedRate = (p: Price) =>
116
+ p.cached ?? Math.round(p.input * CACHE_DISCOUNT);
117
+
89
118
  // Integer nanodollars. Divide-before-multiply keeps the intermediate product
90
119
  // within 2^53 even for large token counts × large per-Mtok prices.
91
120
  const costOf = (
@@ -97,6 +126,82 @@ const costOf = (
97
126
  (inputTokens / 1e6) * price.input + (outputTokens / 1e6) * price.output
98
127
  );
99
128
 
129
+ // Cache-aware settle cost: the cached slice of the prompt is billed at the
130
+ // (discounted) cache rate, the rest of the prompt at the input rate, and
131
+ // completions at the output rate. `cachedTokens` is the gateway's real
132
+ // prompt-cache-read count (usage.inputTokenDetails.cacheReadTokens).
133
+ const settleCost = (
134
+ promptTokens: number,
135
+ cachedTokens: number,
136
+ completionTokens: number,
137
+ price: Price
138
+ ) => {
139
+ const cached = Math.min(Math.max(0, cachedTokens), Math.max(0, promptTokens));
140
+ const fresh = Math.max(0, promptTokens - cached);
141
+ return Math.round(
142
+ (fresh / 1e6) * price.input +
143
+ (cached / 1e6) * cachedRate(price) +
144
+ (completionTokens / 1e6) * price.output
145
+ );
146
+ };
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
+
164
+ // Upsert-add a settled amount into the durable per-(bucket, period) usage row.
165
+ // These rows are never swept by request retention, so spend history survives.
166
+ async function addUsage(
167
+ ctx: MutationCtx,
168
+ dimension: string,
169
+ value: string,
170
+ period: "day" | "month",
171
+ stamp: string,
172
+ spendNanos: number,
173
+ tokens: number,
174
+ requests: number
175
+ ) {
176
+ const existing = await ctx.db
177
+ .query("usage")
178
+ .withIndex("bucket_period_stamp", (q) =>
179
+ q
180
+ .eq("dimension", dimension)
181
+ .eq("value", value)
182
+ .eq("period", period)
183
+ .eq("stamp", stamp)
184
+ )
185
+ .unique();
186
+ if (existing) {
187
+ await ctx.db.patch(existing._id, {
188
+ spendNanos: existing.spendNanos + spendNanos,
189
+ tokens: existing.tokens + tokens,
190
+ requests: existing.requests + requests,
191
+ });
192
+ } else {
193
+ await ctx.db.insert("usage", {
194
+ dimension,
195
+ value,
196
+ period,
197
+ stamp,
198
+ spendNanos,
199
+ tokens,
200
+ requests,
201
+ });
202
+ }
203
+ }
204
+
100
205
  // Up-front estimate of a request's cost and token count, reserved before the
101
206
  // call so concurrent in-flight requests are visible to each other's caps.
102
207
  function estimateUsage(
@@ -111,58 +216,116 @@ function estimateUsage(
111
216
  };
112
217
  }
113
218
 
114
- // Evaluate an entity's (user or action) spend + token budgets against
115
- // committed + reserved + this request's estimate. Returns a hard rejection
116
- // (block) or a list of soft warnings (allow), per the entity's enforcement.
219
+ // The full set of attribution buckets a request touches: the built-in `user`
220
+ // and `action` dimensions plus any extra tags. Reserved dimensions in `extra`
221
+ // are dropped (userId/actionName own them), and (dimension, value) pairs are
222
+ // de-duplicated. Used identically at reserve time (startRequest) and settle
223
+ // time (foldOne), so a request always settles exactly the buckets it reserved.
224
+ function requestBuckets(
225
+ userId: string,
226
+ actionName: string | undefined,
227
+ extra: { dimension: string; value: string }[] | undefined
228
+ ): { dimension: string; value: string }[] {
229
+ const out = [{ dimension: USER_DIM, value: userId }];
230
+ if (actionName !== undefined)
231
+ out.push({ dimension: ACTION_DIM, value: actionName });
232
+ for (const t of extra ?? []) {
233
+ if (t.dimension === USER_DIM || t.dimension === ACTION_DIM) continue;
234
+ if (!t.dimension || !t.value) continue;
235
+ if (out.some((x) => x.dimension === t.dimension && x.value === t.value))
236
+ continue;
237
+ out.push({ dimension: t.dimension, value: t.value });
238
+ }
239
+ return out;
240
+ }
241
+
242
+ // Drop reserved/empty/duplicate tags from a caller-supplied list, leaving the
243
+ // "extra" dimensions stored on the request row.
244
+ function sanitizeExtraTags(
245
+ extra: { dimension: string; value: string }[] | undefined
246
+ ): { dimension: string; value: string }[] {
247
+ const out: { dimension: string; value: string }[] = [];
248
+ for (const t of extra ?? []) {
249
+ if (t.dimension === USER_DIM || t.dimension === ACTION_DIM) continue;
250
+ if (!t.dimension || !t.value) continue;
251
+ if (out.some((x) => x.dimension === t.dimension && x.value === t.value))
252
+ continue;
253
+ out.push({ dimension: t.dimension, value: t.value });
254
+ }
255
+ return out;
256
+ }
257
+
258
+ // Evaluate a bucket's spend + token budgets against committed + reserved + this
259
+ // request's estimate. Returns a hard rejection (block), soft warnings (allow),
260
+ // and threshold notices (approaching a cap — see warnAtPct). Each window (daily,
261
+ // monthly, lifetime) × kind (spend, token) is one check.
117
262
  function evaluateCaps(o: {
118
263
  label: string;
119
264
  name: string;
120
265
  enforcement: "hard" | "soft";
266
+ warnAtPct?: number;
121
267
  estCost: number;
122
268
  estTokens: number;
123
269
  spendToday: number;
124
270
  reservedSpendToday: number;
271
+ spendThisMonth: number;
272
+ reservedSpendMonth: number;
125
273
  totalSpend: number;
126
274
  reservedSpendTotal: number;
127
275
  tokensToday: number;
128
276
  reservedTokensToday: number;
277
+ tokensThisMonth: number;
278
+ reservedTokensMonth: number;
129
279
  totalTokens: number;
130
280
  reservedTokensTotal: number;
131
281
  dailySpendLimitNanos?: number;
282
+ monthlySpendLimitNanos?: number;
132
283
  lifetimeSpendLimitNanos?: number;
133
284
  dailyTokenLimit?: number;
285
+ monthlyTokenLimit?: number;
134
286
  lifetimeTokenLimit?: number;
135
- }): { hard?: { code: string; reason: string }; warnings: string[] } {
287
+ }): {
288
+ hard?: { code: string; reason: string };
289
+ warnings: string[];
290
+ notices: string[];
291
+ } {
292
+ // { code, projected usage (incl. this estimate), cap, human window label,
293
+ // whether it's a money cap (formatted as $), spend? for notices }
294
+ const checks = [
295
+ { w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost, cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
296
+ { w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost, cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
297
+ { w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost, cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
298
+ { w: "daily_token_limit", used: o.tokensToday + o.reservedTokensToday + o.estTokens, cap: o.dailyTokenLimit, label: "daily token limit", money: false },
299
+ { w: "monthly_token_limit", used: o.tokensThisMonth + o.reservedTokensMonth + o.estTokens, cap: o.monthlyTokenLimit, label: "monthly token limit", money: false },
300
+ { w: "lifetime_token_limit", used: o.totalTokens + o.reservedTokensTotal + o.estTokens, cap: o.lifetimeTokenLimit, label: "lifetime token limit", money: false },
301
+ ];
302
+
136
303
  const violations: { code: string; reason: string }[] = [];
137
- const push = (code: string, reason: string) =>
138
- violations.push({ code: `${o.label}_${code}`, reason });
139
-
140
- if (
141
- o.dailySpendLimitNanos !== undefined &&
142
- o.spendToday + o.reservedSpendToday + o.estCost > o.dailySpendLimitNanos
143
- )
144
- push("daily_spend_limit", `Daily spend limit reached for ${o.label} "${o.name}" (${fmtUsd(o.dailySpendLimitNanos)}/day)`);
145
- if (
146
- o.lifetimeSpendLimitNanos !== undefined &&
147
- o.totalSpend + o.reservedSpendTotal + o.estCost > o.lifetimeSpendLimitNanos
148
- )
149
- push("lifetime_spend_limit", `Lifetime spend limit reached for ${o.label} "${o.name}"`);
150
- if (
151
- o.dailyTokenLimit !== undefined &&
152
- o.tokensToday + o.reservedTokensToday + o.estTokens > o.dailyTokenLimit
153
- )
154
- push("daily_token_limit", `Daily token limit reached for ${o.label} "${o.name}" (${o.dailyTokenLimit}/day)`);
155
- if (
156
- o.lifetimeTokenLimit !== undefined &&
157
- o.totalTokens + o.reservedTokensTotal + o.estTokens > o.lifetimeTokenLimit
158
- )
159
- push("lifetime_token_limit", `Lifetime token limit reached for ${o.label} "${o.name}"`);
160
-
161
- if (violations.length === 0) return { warnings: [] };
162
- if (o.enforcement === "soft") return { warnings: violations.map((v) => v.reason) };
163
- return { hard: violations[0], warnings: [] };
304
+ const notices: string[] = [];
305
+ const pct = o.warnAtPct;
306
+ for (const c of checks) {
307
+ if (c.cap === undefined) continue;
308
+ const capStr = c.money ? `${fmtUsd(c.cap)}` : `${c.cap} tokens`;
309
+ if (c.used > c.cap) {
310
+ violations.push({
311
+ code: `${o.label}_${c.w}`,
312
+ reason: `${cap(c.label)} reached for ${o.label} "${o.name}" (${capStr})`,
313
+ });
314
+ } else if (pct !== undefined && pct > 0 && pct < 1 && c.used >= pct * c.cap) {
315
+ notices.push(
316
+ `${o.label} "${o.name}" at ${Math.round((c.used / c.cap) * 100)}% of ${c.label} (${capStr})`
317
+ );
318
+ }
319
+ }
320
+
321
+ if (violations.length === 0) return { warnings: [], notices };
322
+ if (o.enforcement === "soft")
323
+ return { warnings: violations.map((v) => v.reason), notices };
324
+ return { hard: violations[0], warnings: [], notices };
164
325
  }
165
326
 
327
+ const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
328
+
166
329
  // A cap plus any one-time bump. Returns undefined when there's no base cap
167
330
  // (a bump alone never creates a cap).
168
331
  const withBump = (base: number | undefined, bump: number | undefined) =>
@@ -170,40 +333,43 @@ const withBump = (base: number | undefined, bump: number | undefined) =>
170
333
 
171
334
  const hasAnyCap = (e: {
172
335
  dailySpendLimitNanos?: number;
336
+ monthlySpendLimitNanos?: number;
173
337
  lifetimeSpendLimitNanos?: number;
174
338
  dailyTokenLimit?: number;
339
+ monthlyTokenLimit?: number;
175
340
  lifetimeTokenLimit?: number;
176
341
  }) =>
177
342
  e.dailySpendLimitNanos !== undefined ||
343
+ e.monthlySpendLimitNanos !== undefined ||
178
344
  e.lifetimeSpendLimitNanos !== undefined ||
179
345
  e.dailyTokenLimit !== undefined ||
346
+ e.monthlyTokenLimit !== undefined ||
180
347
  e.lifetimeTokenLimit !== undefined;
181
348
 
182
- async function getOrCreateUser(ctx: MutationCtx, userId: string) {
183
- const existing = await ctx.db
184
- .query("users")
185
- .withIndex("userId", (q) => q.eq("userId", userId))
349
+ // A bucket needs a reservation row-write if it has any spend/token cap OR a
350
+ // concurrency cap (which reads pendingCount, incremented at reserve time).
351
+ const needsReserve = (e: Parameters<typeof hasAnyCap>[0] & { maxConcurrent?: number }) =>
352
+ hasAnyCap(e) || e.maxConcurrent !== undefined;
353
+
354
+ async function getBucketDoc(ctx: MutationCtx, dimension: string, value: string) {
355
+ return await ctx.db
356
+ .query("buckets")
357
+ .withIndex("dim_value", (q) =>
358
+ q.eq("dimension", dimension).eq("value", value)
359
+ )
186
360
  .unique();
187
- if (existing) return existing;
188
- const id = await ctx.db.insert("users", {
189
- userId,
190
- totalSpendNanos: 0,
191
- totalRequests: 0,
192
- totalTokens: 0,
193
- dayStamp: dayStamp(),
194
- spendTodayNanos: 0,
195
- });
196
- return (await ctx.db.get(id))!;
197
361
  }
198
362
 
199
- async function getOrCreateAction(ctx: MutationCtx, name: string) {
200
- const existing = await ctx.db
201
- .query("actions")
202
- .withIndex("name", (q) => q.eq("name", name))
203
- .unique();
363
+ async function getOrCreateBucket(
364
+ ctx: MutationCtx,
365
+ dimension: string,
366
+ value: string
367
+ ) {
368
+ const existing = await getBucketDoc(ctx, dimension, value);
204
369
  if (existing) return existing;
205
- const id = await ctx.db.insert("actions", {
206
- name,
370
+ const id = await ctx.db.insert("buckets", {
371
+ dimension,
372
+ value,
207
373
  totalSpendNanos: 0,
208
374
  totalRequests: 0,
209
375
  totalTokens: 0,
@@ -220,11 +386,22 @@ async function getSettings(ctx: MutationCtx) {
220
386
  .unique();
221
387
  }
222
388
 
389
+ // Delete the reverse-index rows for a request (called when the request row is
390
+ // deleted, so the tag index never outlives the request it points at).
391
+ async function deleteRequestTags(ctx: MutationCtx, requestId: Doc<"requests">["_id"]) {
392
+ const tags = await ctx.db
393
+ .query("requestTags")
394
+ .withIndex("requestId", (q) => q.eq("requestId", requestId))
395
+ .collect();
396
+ for (const t of tags) await ctx.db.delete(t._id);
397
+ }
398
+
223
399
  const vStartResult = v.union(
224
400
  v.object({
225
401
  allowed: v.literal(true),
226
402
  requestId: v.id("requests"),
227
- warnings: v.array(v.string()),
403
+ warnings: v.array(v.string()), // soft caps exceeded (allowed with a warning)
404
+ notices: v.array(v.string()), // approaching a cap (warnAtPct threshold)
228
405
  }),
229
406
  v.object({
230
407
  allowed: v.literal(false),
@@ -237,13 +414,16 @@ export const startRequest = mutation({
237
414
  args: {
238
415
  userId: v.string(),
239
416
  actionName: v.optional(v.string()),
417
+ // Extra attribution dimensions to bill/limit (team, customer, env, …).
418
+ // `user` and `action` are reserved (owned by userId/actionName).
419
+ tags: v.optional(v.array(vTag)),
240
420
  model: v.string(),
241
421
  messages: v.array(vMessage),
242
422
  rerunOf: v.optional(v.id("requests")),
243
423
  },
244
424
  returns: vStartResult,
245
425
  handler: async (ctx, args) => {
246
- const user = await getOrCreateUser(ctx, args.userId);
426
+ const extraTags = sanitizeExtraTags(args.tags);
247
427
  // Record the blocked attempt and return a rejection (throwing would roll
248
428
  // back the record). `persist` is false for the high-frequency-by-design
249
429
  // rejections (rate limit, blocked user) that a client retries in a tight
@@ -252,7 +432,12 @@ export const startRequest = mutation({
252
432
  const reject = async (code: string, reason: string, persist = true) => {
253
433
  if (persist) {
254
434
  await ctx.db.insert("requests", {
255
- ...args,
435
+ userId: args.userId,
436
+ actionName: args.actionName,
437
+ ...(extraTags.length ? { tags: extraTags } : {}),
438
+ model: args.model,
439
+ messages: args.messages,
440
+ rerunOf: args.rerunOf,
256
441
  status: "blocked" as const,
257
442
  error: reason,
258
443
  });
@@ -261,18 +446,18 @@ export const startRequest = mutation({
261
446
  };
262
447
 
263
448
  const today = dayStamp();
449
+ const month = monthStamp();
264
450
  const priceInfo = await getPrice(ctx, args.model);
265
451
  const est = estimateUsage(args.messages, priceInfo);
266
452
  const warnings: string[] = [];
453
+ const notices: string[] = [];
267
454
 
268
- if (user.blocked) {
269
- return reject("blocked", `User "${args.userId}" is blocked`, false);
270
- }
271
455
  // Model allow/deny policy (component-wide).
272
- const policy = await getSettings(ctx);
273
- if (policy) {
274
- const mode = policy.modelMode ?? "open";
275
- const list = policy.models ?? [];
456
+ const settings = await getSettings(ctx);
457
+ const defaultWarnAtPct = settings?.defaultWarnAtPct;
458
+ if (settings) {
459
+ const mode = settings.modelMode ?? "open";
460
+ const list = settings.models ?? [];
276
461
  if (mode === "allowlist" && !list.includes(args.model)) {
277
462
  return reject(
278
463
  "model_not_allowed",
@@ -283,178 +468,236 @@ export const startRequest = mutation({
283
468
  return reject("model_denied", `Model "${args.model}" is denied`);
284
469
  }
285
470
  }
286
- if (user.requestsPerMinute !== undefined) {
287
- // Bounded read: we only need to know whether non-blocked requests in the
288
- // window have reached the limit. Reading limit+1 non-blocked rows is
289
- // enough, and .take caps the scan even if the window is flooded. Blocked
290
- // rows aren't persisted for rate-limit/blocked rejections (see reject),
291
- // so the window stays small.
292
- const recent = await ctx.db
293
- .query("requests")
294
- .withIndex("userId", (q) =>
295
- q.eq("userId", args.userId).gt("_creationTime", Date.now() - 60_000)
296
- )
297
- .take(user.requestsPerMinute + 50);
298
- if (
299
- recent.filter((r) => r.status !== "blocked").length >=
300
- user.requestsPerMinute
301
- ) {
471
+
472
+ // Fetch/create every bucket this request is attributed to (user, action,
473
+ // and any extra tags). Each may carry its own budget.
474
+ const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
475
+ const buckets: Doc<"buckets">[] = [];
476
+ for (const t of bucketTags) {
477
+ buckets.push(await getOrCreateBucket(ctx, t.dimension, t.value));
478
+ }
479
+
480
+ // A hard block on ANY bucket rejects the request. The user dimension's block
481
+ // isn't persisted (retried in a loop); config-level blocks on other
482
+ // dimensions are rarer, so they persist for the audit log.
483
+ for (const b of buckets) {
484
+ if (b.blocked) {
485
+ const label = b.dimension === USER_DIM ? "User" : b.dimension;
302
486
  return reject(
303
- "rate_limit",
304
- `Rate limit exceeded for "${args.userId}" (${user.requestsPerMinute}/min)`,
487
+ `${b.dimension}_blocked`,
488
+ `${label} "${b.value}" is blocked`,
489
+ b.dimension !== USER_DIM
490
+ );
491
+ }
492
+ }
493
+
494
+ // Concurrency cap: reject when a bucket already has maxConcurrent requests
495
+ // in flight (pendingCount). A transient limit like rate-limiting, so it
496
+ // isn't persisted (the caller retries once something settles).
497
+ for (const b of buckets) {
498
+ if (b.maxConcurrent !== undefined && (b.pendingCount ?? 0) >= b.maxConcurrent) {
499
+ return reject(
500
+ `${b.dimension}_max_concurrent`,
501
+ `Too many concurrent requests for ${b.dimension} "${b.value}" (max ${b.maxConcurrent})`,
305
502
  false
306
503
  );
307
504
  }
308
505
  }
309
- // Committed + already-reserved in-flight usage + this estimate must fit
310
- // under each cap. Decided and reserved in one transaction, so Convex's
311
- // serializable isolation makes it a true atomic check-and-reserve. Soft
312
- // enforcement turns a violation into a warning instead of a block.
313
- const userSameDay = user.dayStamp === today;
314
- const userEval = evaluateCaps({
315
- label: "user",
316
- name: args.userId,
317
- enforcement: user.enforcement ?? "hard",
318
- estCost: est.cost,
319
- estTokens: est.tokens,
320
- spendToday: userSameDay ? user.spendTodayNanos : 0,
321
- reservedSpendToday: userSameDay ? user.reservedTodayNanos ?? 0 : 0,
322
- totalSpend: user.totalSpendNanos,
323
- reservedSpendTotal: user.reservedTotalNanos ?? 0,
324
- tokensToday: userSameDay ? user.tokensToday ?? 0 : 0,
325
- reservedTokensToday: userSameDay ? user.reservedTodayTokens ?? 0 : 0,
326
- totalTokens: user.totalTokens,
327
- reservedTokensTotal: user.reservedTotalTokens ?? 0,
328
- dailySpendLimitNanos: withBump(
329
- user.dailySpendLimitNanos,
330
- user.bumpDayStamp === today ? user.dailyBumpNanos : 0
331
- ),
332
- lifetimeSpendLimitNanos: withBump(
333
- user.lifetimeSpendLimitNanos,
334
- user.lifetimeBumpNanos
335
- ),
336
- dailyTokenLimit: user.dailyTokenLimit,
337
- lifetimeTokenLimit: user.lifetimeTokenLimit,
338
- });
339
- if (userEval.hard) return reject(userEval.hard.code, userEval.hard.reason);
340
- warnings.push(...userEval.warnings);
341
-
342
- let action: Doc<"actions"> | null = null;
343
- if (args.actionName !== undefined) {
344
- action = await getOrCreateAction(ctx, args.actionName);
345
- if (action.disabled) {
346
- return reject("action_disabled", `Action "${action.name}" is disabled`);
506
+
507
+ // Enforce the rolling 60-second rate limit on every configured dimension.
508
+ // User/action requests use their first-class indexes; custom dimensions use
509
+ // the requestTags reverse index. All reads are bounded by the configured
510
+ // limit (plus a small allowance for persisted blocked attempts).
511
+ const rateCutoff = Date.now() - 60_000;
512
+ for (const b of buckets) {
513
+ const limit = b.requestsPerMinute;
514
+ if (limit === undefined) continue;
515
+
516
+ let recentCount: number;
517
+ if (b.dimension === USER_DIM) {
518
+ const recent = await ctx.db
519
+ .query("requests")
520
+ .withIndex("userId", (q) =>
521
+ q.eq("userId", b.value).gt("_creationTime", rateCutoff)
522
+ )
523
+ .take(limit + 50);
524
+ recentCount = recent.filter((r) => r.status !== "blocked").length;
525
+ } else if (b.dimension === ACTION_DIM) {
526
+ const recent = await ctx.db
527
+ .query("requests")
528
+ .withIndex("actionName", (q) =>
529
+ q.eq("actionName", b.value).gt("_creationTime", rateCutoff)
530
+ )
531
+ .take(limit + 50);
532
+ recentCount = recent.filter((r) => r.status !== "blocked").length;
533
+ } else {
534
+ recentCount = (
535
+ await ctx.db
536
+ .query("requestTags")
537
+ .withIndex("dim_value", (q) =>
538
+ q
539
+ .eq("dimension", b.dimension)
540
+ .eq("value", b.value)
541
+ .gt("_creationTime", rateCutoff)
542
+ )
543
+ .take(limit)
544
+ ).length;
545
+ }
546
+
547
+ if (recentCount >= limit) {
548
+ const code = b.dimension === USER_DIM ? "rate_limit" : `${b.dimension}_rate_limit`;
549
+ return reject(
550
+ code,
551
+ `Rate limit exceeded for ${b.dimension} "${b.value}" (${limit}/min)`,
552
+ false
553
+ );
347
554
  }
348
- const aSameDay = action.dayStamp === today;
349
- const actionEval = evaluateCaps({
350
- label: "action",
351
- name: action.name,
352
- enforcement: action.enforcement ?? "hard",
555
+ }
556
+
557
+ // Committed + already-reserved in-flight usage + this estimate must fit
558
+ // under EACH bucket's cap. Decided and reserved in one transaction, so
559
+ // Convex's serializable isolation makes concurrent admission atomic across
560
+ // every capped bucket. Final usage can exceed the estimate; settlement then
561
+ // records the actual amount. Soft enforcement turns a violation into a
562
+ // warning instead of a block.
563
+ for (const b of buckets) {
564
+ const sameDay = b.dayStamp === today;
565
+ const sameMonth = b.monthStamp === month;
566
+ const ev = evaluateCaps({
567
+ label: b.dimension,
568
+ name: b.value,
569
+ enforcement: b.enforcement ?? "hard",
570
+ warnAtPct: b.warnAtPct ?? defaultWarnAtPct,
353
571
  estCost: est.cost,
354
572
  estTokens: est.tokens,
355
- spendToday: aSameDay ? action.spendTodayNanos : 0,
356
- reservedSpendToday: aSameDay ? action.reservedTodayNanos ?? 0 : 0,
357
- totalSpend: action.totalSpendNanos,
358
- reservedSpendTotal: action.reservedTotalNanos ?? 0,
359
- tokensToday: aSameDay ? action.tokensToday ?? 0 : 0,
360
- reservedTokensToday: aSameDay ? action.reservedTodayTokens ?? 0 : 0,
361
- totalTokens: action.totalTokens,
362
- reservedTokensTotal: action.reservedTotalTokens ?? 0,
573
+ spendToday: sameDay ? b.spendTodayNanos : 0,
574
+ reservedSpendToday: sameDay ? b.reservedTodayNanos ?? 0 : 0,
575
+ spendThisMonth: sameMonth ? b.spendThisMonthNanos ?? 0 : 0,
576
+ reservedSpendMonth: sameMonth ? b.reservedMonthNanos ?? 0 : 0,
577
+ totalSpend: b.totalSpendNanos,
578
+ reservedSpendTotal: b.reservedTotalNanos ?? 0,
579
+ tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
580
+ reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
581
+ tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
582
+ reservedTokensMonth: sameMonth ? b.reservedMonthTokens ?? 0 : 0,
583
+ totalTokens: b.totalTokens,
584
+ reservedTokensTotal: b.reservedTotalTokens ?? 0,
363
585
  dailySpendLimitNanos: withBump(
364
- action.dailySpendLimitNanos,
365
- action.bumpDayStamp === today ? action.dailyBumpNanos : 0
586
+ b.dailySpendLimitNanos,
587
+ b.bumpDayStamp === today ? b.dailyBumpNanos : 0
588
+ ),
589
+ monthlySpendLimitNanos: withBump(
590
+ b.monthlySpendLimitNanos,
591
+ b.bumpMonthStamp === month ? b.monthlyBumpNanos : 0
366
592
  ),
367
593
  lifetimeSpendLimitNanos: withBump(
368
- action.lifetimeSpendLimitNanos,
369
- action.lifetimeBumpNanos
594
+ b.lifetimeSpendLimitNanos,
595
+ b.lifetimeBumpNanos
370
596
  ),
371
- dailyTokenLimit: action.dailyTokenLimit,
372
- lifetimeTokenLimit: action.lifetimeTokenLimit,
597
+ dailyTokenLimit: b.dailyTokenLimit,
598
+ monthlyTokenLimit: b.monthlyTokenLimit,
599
+ lifetimeTokenLimit: b.lifetimeTokenLimit,
373
600
  });
374
- if (actionEval.hard) return reject(actionEval.hard.code, actionEval.hard.reason);
375
- warnings.push(...actionEval.warnings);
601
+ if (ev.hard) return reject(ev.hard.code, ev.hard.reason);
602
+ warnings.push(...ev.warnings);
603
+ notices.push(...ev.notices);
376
604
  }
377
605
 
378
- // Deployment-wide ("global") spend cap — same reserve-then-settle model and
379
- // the SAME evaluateCaps logic as the per-entity caps above, so the guarantee
380
- // statement is uniform: a request is admitted only if
606
+ // Deployment-wide ("global") spend cap — the same estimated-usage admission
607
+ // rule as the per-bucket caps above: a request is admitted only if
381
608
  // committed + reserved + estimate <= cap. The ONE difference is the holder:
382
- // per-user/per-action reserve on a single row (an exact atomic
383
- // check-and-reserve), while the global holder is a sharded counter for
609
+ // per-bucket caps reserve on a single row (an atomic check-and-reserve),
610
+ // while the global holder is a sharded counter for
384
611
  // throughput — its committed total is read as an eventually-consistent sum
385
612
  // with no cross-request reservation, so a hard global cap can overshoot by a
386
- // bounded amount under burst. That's the deliberate exactness/throughput
613
+ // bounded amount under burst. That's the deliberate consistency/throughput
387
614
  // trade for a deployment-wide killswitch; it's the only approximate scope.
388
615
  if (
389
- policy &&
390
- (policy.globalDailySpendLimitNanos !== undefined ||
391
- policy.globalLifetimeSpendLimitNanos !== undefined)
616
+ settings &&
617
+ (settings.globalDailySpendLimitNanos !== undefined ||
618
+ settings.globalLifetimeSpendLimitNanos !== undefined)
392
619
  ) {
393
620
  const globalEval = evaluateCaps({
394
621
  label: "global",
395
622
  name: "deployment",
396
- enforcement: policy.globalEnforcement ?? "hard",
623
+ enforcement: settings.globalEnforcement ?? "hard",
624
+ warnAtPct: defaultWarnAtPct,
397
625
  estCost: est.cost,
398
626
  estTokens: est.tokens,
399
627
  spendToday: await globalSpend.count(ctx, globalDayKey(today)),
400
628
  reservedSpendToday: 0, // sharded holder: no cross-request reservation
629
+ spendThisMonth: 0, // global tracks daily + lifetime only
630
+ reservedSpendMonth: 0,
401
631
  totalSpend: await globalSpend.count(ctx, GLOBAL_TOTAL),
402
632
  reservedSpendTotal: 0,
403
633
  tokensToday: 0,
404
634
  reservedTokensToday: 0,
635
+ tokensThisMonth: 0,
636
+ reservedTokensMonth: 0,
405
637
  totalTokens: 0,
406
638
  reservedTokensTotal: 0,
407
639
  dailySpendLimitNanos: withBump(
408
- policy.globalDailySpendLimitNanos,
409
- policy.globalBumpDayStamp === today ? policy.globalDailyBumpNanos : 0
640
+ settings.globalDailySpendLimitNanos,
641
+ settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0
410
642
  ),
411
643
  lifetimeSpendLimitNanos: withBump(
412
- policy.globalLifetimeSpendLimitNanos,
413
- policy.globalLifetimeBumpNanos
644
+ settings.globalLifetimeSpendLimitNanos,
645
+ settings.globalLifetimeBumpNanos
414
646
  ),
415
647
  });
416
648
  if (globalEval.hard) return reject(globalEval.hard.code, globalEval.hard.reason);
417
649
  warnings.push(...globalEval.warnings);
650
+ notices.push(...globalEval.notices);
418
651
  }
419
652
 
420
- // Passed — reserve, but ONLY on entities that actually have a cap. Writing
421
- // an uncapped entity's row here would serialize every request that shares it
422
- // (e.g. all callers of one action); with no cap there's no reserved amount
423
- // to consult. Totals are still accrued later, asynchronously, in foldOne.
424
- if (hasAnyCap(user)) {
425
- await ctx.db.patch(user._id, {
426
- dayStamp: today,
427
- spendTodayNanos: userSameDay ? user.spendTodayNanos : 0,
428
- tokensToday: userSameDay ? user.tokensToday ?? 0 : 0,
429
- reservedTodayNanos: (userSameDay ? user.reservedTodayNanos ?? 0 : 0) + est.cost,
430
- reservedTotalNanos: (user.reservedTotalNanos ?? 0) + est.cost,
431
- reservedTodayTokens: (userSameDay ? user.reservedTodayTokens ?? 0 : 0) + est.tokens,
432
- reservedTotalTokens: (user.reservedTotalTokens ?? 0) + est.tokens,
433
- pendingCount: (user.pendingCount ?? 0) + 1,
434
- });
435
- }
436
- if (action && hasAnyCap(action)) {
437
- const aSameDay = action.dayStamp === today;
438
- await ctx.db.patch(action._id, {
653
+ // Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
654
+ // uncapped bucket's row here would serialize every request that shares it
655
+ // (e.g. all callers of one action, or every request in one env); with no cap
656
+ // there's no reserved amount to consult. Totals are still accrued later,
657
+ // asynchronously, in foldOne — for every bucket, capped or not.
658
+ for (const b of buckets) {
659
+ if (!needsReserve(b)) continue;
660
+ const sameDay = b.dayStamp === today;
661
+ const sameMonth = b.monthStamp === month;
662
+ await ctx.db.patch(b._id, {
439
663
  dayStamp: today,
440
- spendTodayNanos: aSameDay ? action.spendTodayNanos : 0,
441
- tokensToday: aSameDay ? action.tokensToday ?? 0 : 0,
442
- reservedTodayNanos: (aSameDay ? action.reservedTodayNanos ?? 0 : 0) + est.cost,
443
- reservedTotalNanos: (action.reservedTotalNanos ?? 0) + est.cost,
444
- reservedTodayTokens: (aSameDay ? action.reservedTodayTokens ?? 0 : 0) + est.tokens,
445
- reservedTotalTokens: (action.reservedTotalTokens ?? 0) + est.tokens,
446
- pendingCount: (action.pendingCount ?? 0) + 1,
664
+ monthStamp: month,
665
+ spendTodayNanos: sameDay ? b.spendTodayNanos : 0,
666
+ tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
667
+ spendThisMonthNanos: sameMonth ? b.spendThisMonthNanos ?? 0 : 0,
668
+ tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
669
+ reservedTodayNanos: (sameDay ? b.reservedTodayNanos ?? 0 : 0) + est.cost,
670
+ reservedMonthNanos: (sameMonth ? b.reservedMonthNanos ?? 0 : 0) + est.cost,
671
+ reservedTotalNanos: (b.reservedTotalNanos ?? 0) + est.cost,
672
+ reservedTodayTokens: (sameDay ? b.reservedTodayTokens ?? 0 : 0) + est.tokens,
673
+ reservedMonthTokens: (sameMonth ? b.reservedMonthTokens ?? 0 : 0) + est.tokens,
674
+ reservedTotalTokens: (b.reservedTotalTokens ?? 0) + est.tokens,
675
+ pendingCount: (b.pendingCount ?? 0) + 1,
447
676
  });
448
677
  }
449
678
  const requestId = await ctx.db.insert("requests", {
450
- ...args,
679
+ userId: args.userId,
680
+ actionName: args.actionName,
681
+ ...(extraTags.length ? { tags: extraTags } : {}),
682
+ model: args.model,
683
+ messages: args.messages,
684
+ rerunOf: args.rerunOf,
451
685
  status: "pending",
452
686
  estimatedNanos: est.cost,
453
687
  estimatedTokens: est.tokens,
454
688
  ...(priceInfo.known ? {} : { unpricedModel: true }),
455
689
  ...(warnings.length > 0 ? { overBudget: true } : {}),
456
690
  });
457
- return { allowed: true as const, requestId, warnings };
691
+ // Reverse index so the request log can be filtered by any extra tag
692
+ // dimension (user/action are already indexed on the requests table).
693
+ for (const t of extraTags) {
694
+ await ctx.db.insert("requestTags", {
695
+ dimension: t.dimension,
696
+ value: t.value,
697
+ requestId,
698
+ });
699
+ }
700
+ return { allowed: true as const, requestId, warnings, notices };
458
701
  },
459
702
  });
460
703
 
@@ -466,6 +709,14 @@ export const finishRequest = mutation({
466
709
  promptTokens: v.optional(v.number()),
467
710
  completionTokens: v.optional(v.number()),
468
711
  cachedTokens: v.optional(v.number()),
712
+ // Provider server-tool invocations that bill a per-call fee (e.g.
713
+ // { web_search: 3 }). Added to the token cost when no authoritative cost is
714
+ // supplied; recorded either way.
715
+ serverToolUses: v.optional(v.record(v.string(), v.number())),
716
+ // Authoritative cost from the gateway/provider, if reported. When present
717
+ // it's recorded verbatim (already includes any tool fees); when absent we
718
+ // price from tokens (cache-aware) plus server-tool fees.
719
+ costNanos: v.optional(v.number()),
469
720
  latencyMs: v.optional(v.number()),
470
721
  },
471
722
  returns: v.object({ costNanos: v.number() }),
@@ -489,10 +740,22 @@ export const finishRequest = mutation({
489
740
  const promptTokens = Math.max(0, args.promptTokens ?? 0);
490
741
  const completionTokens = Math.max(0, args.completionTokens ?? 0);
491
742
  const cachedTokens = Math.min(promptTokens, Math.max(0, args.cachedTokens ?? 0));
492
- const costNanos = Math.max(
493
- 0,
494
- costOf(promptTokens, completionTokens, await getPrice(ctx, request.model))
495
- );
743
+ // Prefer an authoritative gateway cost when supplied (it already includes
744
+ // tool fees); otherwise price from tokens — discounting the cached
745
+ // (prompt-cache-read) slice plus any server-tool per-call fees.
746
+ let costNanos: number;
747
+ if (args.costNanos !== undefined && args.costNanos >= 0) {
748
+ costNanos = Math.round(args.costNanos);
749
+ } else {
750
+ const settings = await getSettings(ctx);
751
+ costNanos =
752
+ settleCost(
753
+ promptTokens,
754
+ cachedTokens,
755
+ completionTokens,
756
+ await getPrice(ctx, request.model)
757
+ ) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
758
+ }
496
759
 
497
760
  // Durable write to the request's OWN row only — uncontended, so it always
498
761
  // lands. `settled: false` hands it to the fold step; the row is never left
@@ -504,12 +767,13 @@ export const finishRequest = mutation({
504
767
  promptTokens,
505
768
  completionTokens,
506
769
  ...(cachedTokens > 0 ? { cachedTokens } : {}),
770
+ ...(args.serverToolUses ? { serverToolUses: args.serverToolUses } : {}),
507
771
  costNanos,
508
772
  latencyMs: args.latencyMs,
509
773
  settled: false,
510
774
  });
511
775
 
512
- // Fold into the (hot) user/action counters in a separate mutation. If it
776
+ // Fold into the (hot) per-bucket counters in a separate mutation. If it
513
777
  // exhausts retries under contention, the cron reconciler picks it up.
514
778
  await ctx.scheduler.runAfter(0, internal.lib.foldTotals, {
515
779
  requestId: args.requestId,
@@ -518,9 +782,9 @@ export const finishRequest = mutation({
518
782
  },
519
783
  });
520
784
 
521
- // Fold one finished request into the user/action running totals, releasing its
522
- // reservation. Idempotent: guarded by `settled` so the scheduler and the cron
523
- // reconciler can never double-count.
785
+ // Fold one finished request into every attributed bucket's running totals,
786
+ // releasing its reservation. Idempotent: guarded by `settled` so the scheduler
787
+ // and the cron reconciler can never double-count.
524
788
  async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
525
789
  if (!req || req.settled !== false) return;
526
790
  const actual = req.costNanos ?? 0;
@@ -528,48 +792,44 @@ async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
528
792
  const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
529
793
  const estTokens = req.estimatedTokens ?? 0;
530
794
  const today = dayStamp();
795
+ const month = monthStamp();
531
796
 
532
- const user = await getOrCreateUser(ctx, req.userId);
533
- const uSameDay = user.dayStamp === today;
534
- await ctx.db.patch(user._id, {
535
- totalSpendNanos: user.totalSpendNanos + actual,
536
- totalRequests: user.totalRequests + 1,
537
- totalTokens: user.totalTokens + tokens,
538
- dayStamp: today,
539
- spendTodayNanos: (uSameDay ? user.spendTodayNanos : 0) + actual,
540
- tokensToday: (uSameDay ? user.tokensToday ?? 0 : 0) + tokens,
541
- reservedTodayNanos: Math.max(0, (uSameDay ? user.reservedTodayNanos ?? 0 : 0) - estCost),
542
- reservedTotalNanos: Math.max(0, (user.reservedTotalNanos ?? 0) - estCost),
543
- reservedTodayTokens: Math.max(0, (uSameDay ? user.reservedTodayTokens ?? 0 : 0) - estTokens),
544
- reservedTotalTokens: Math.max(0, (user.reservedTotalTokens ?? 0) - estTokens),
545
- pendingCount: Math.max(0, (user.pendingCount ?? 0) - 1),
546
- });
547
-
548
- if (req.actionName !== undefined) {
549
- const action = await getOrCreateAction(ctx, req.actionName);
550
- const aSameDay = action.dayStamp === today;
551
- await ctx.db.patch(action._id, {
552
- totalSpendNanos: action.totalSpendNanos + actual,
553
- totalRequests: action.totalRequests + 1,
554
- totalTokens: action.totalTokens + tokens,
797
+ // Accrue into every attributed bucket (user, action, and each tag) — capped
798
+ // or not. Buckets that never held a reservation have their reserved fields
799
+ // clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
800
+ // Also write the durable per-(bucket, day/month) usage rows that survive
801
+ // request retention, so spend history outlives the raw request log.
802
+ for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
803
+ const b = await getOrCreateBucket(ctx, t.dimension, t.value);
804
+ const sameDay = b.dayStamp === today;
805
+ const sameMonth = b.monthStamp === month;
806
+ await ctx.db.patch(b._id, {
807
+ totalSpendNanos: b.totalSpendNanos + actual,
808
+ totalRequests: b.totalRequests + 1,
809
+ totalTokens: b.totalTokens + tokens,
555
810
  dayStamp: today,
556
- spendTodayNanos: (aSameDay ? action.spendTodayNanos : 0) + actual,
557
- tokensToday: (aSameDay ? action.tokensToday ?? 0 : 0) + tokens,
558
- reservedTodayNanos: Math.max(0, (aSameDay ? action.reservedTodayNanos ?? 0 : 0) - estCost),
559
- reservedTotalNanos: Math.max(0, (action.reservedTotalNanos ?? 0) - estCost),
560
- reservedTodayTokens: Math.max(0, (aSameDay ? action.reservedTodayTokens ?? 0 : 0) - estTokens),
561
- reservedTotalTokens: Math.max(0, (action.reservedTotalTokens ?? 0) - estTokens),
562
- pendingCount: Math.max(0, (action.pendingCount ?? 0) - 1),
811
+ monthStamp: month,
812
+ spendTodayNanos: (sameDay ? b.spendTodayNanos : 0) + actual,
813
+ tokensToday: (sameDay ? b.tokensToday ?? 0 : 0) + tokens,
814
+ spendThisMonthNanos: (sameMonth ? b.spendThisMonthNanos ?? 0 : 0) + actual,
815
+ tokensThisMonth: (sameMonth ? b.tokensThisMonth ?? 0 : 0) + tokens,
816
+ reservedTodayNanos: Math.max(0, (sameDay ? b.reservedTodayNanos ?? 0 : 0) - estCost),
817
+ reservedMonthNanos: Math.max(0, (sameMonth ? b.reservedMonthNanos ?? 0 : 0) - estCost),
818
+ reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
819
+ reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
820
+ reservedMonthTokens: Math.max(0, (sameMonth ? b.reservedMonthTokens ?? 0 : 0) - estTokens),
821
+ reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
822
+ pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
563
823
  });
824
+ await addUsage(ctx, t.dimension, t.value, "day", today, actual, tokens, 1);
825
+ await addUsage(ctx, t.dimension, t.value, "month", month, actual, tokens, 1);
564
826
  }
827
+
565
828
  // Deployment-wide totals via the sharded counter (only when a global cap is
566
829
  // configured — otherwise skip the writes entirely). Distributed across shards,
567
830
  // so this does not serialize on a single row.
568
831
  if (actual > 0) {
569
- const settings = await ctx.db
570
- .query("settings")
571
- .withIndex("key", (q) => q.eq("key", "singleton"))
572
- .unique();
832
+ const settings = await getSettings(ctx);
573
833
  if (
574
834
  settings &&
575
835
  (settings.globalDailySpendLimitNanos !== undefined ||
@@ -641,6 +901,7 @@ export const reconcile = internalMutation({
641
901
  // Only rows that are done and accounted: folded (settled === true) or a
642
902
  // blocked attempt (never needs folding). Never a pending/unfolded row.
643
903
  if (req.settled === true || req.status === "blocked") {
904
+ await deleteRequestTags(ctx, req._id);
644
905
  await ctx.db.delete(req._id);
645
906
  purged++;
646
907
  }
@@ -687,10 +948,19 @@ export const getRequest = query({
687
948
  });
688
949
 
689
950
  export const listRequests = query({
690
- args: { userId: v.optional(v.string()), limit: v.optional(v.number()) },
951
+ args: {
952
+ userId: v.optional(v.string()),
953
+ // Filter by any attribution dimension (user/action indexed on the request;
954
+ // custom tag dimensions resolved via the requestTags reverse index).
955
+ dimension: v.optional(v.string()),
956
+ value: v.optional(v.string()),
957
+ limit: v.optional(v.number()),
958
+ },
691
959
  handler: async (ctx, args) => {
692
960
  const limit = args.limit ?? 50;
693
- if (args.userId !== undefined) {
961
+ const dim = args.dimension;
962
+ const val = args.value ?? args.userId;
963
+ if (dim === undefined && args.userId !== undefined) {
694
964
  const userId = args.userId;
695
965
  return await ctx.db
696
966
  .query("requests")
@@ -698,161 +968,248 @@ export const listRequests = query({
698
968
  .order("desc")
699
969
  .take(limit);
700
970
  }
971
+ if (dim !== undefined && val !== undefined) {
972
+ if (dim === USER_DIM) {
973
+ return await ctx.db
974
+ .query("requests")
975
+ .withIndex("userId", (q) => q.eq("userId", val))
976
+ .order("desc")
977
+ .take(limit);
978
+ }
979
+ if (dim === ACTION_DIM) {
980
+ return await ctx.db
981
+ .query("requests")
982
+ .withIndex("actionName", (q) => q.eq("actionName", val))
983
+ .order("desc")
984
+ .take(limit);
985
+ }
986
+ // Custom tag dimension: walk the reverse index, then fetch each request.
987
+ const tagRows = await ctx.db
988
+ .query("requestTags")
989
+ .withIndex("dim_value", (q) => q.eq("dimension", dim).eq("value", val))
990
+ .order("desc")
991
+ .take(limit);
992
+ const rows = await Promise.all(tagRows.map((t) => ctx.db.get(t.requestId)));
993
+ return rows.filter((r): r is Doc<"requests"> => r !== null);
994
+ }
701
995
  return await ctx.db.query("requests").order("desc").take(limit);
702
996
  },
703
997
  });
704
998
 
705
999
  const ADMIN_LIST_CAP = 2000;
706
- export const listUsers = query({
707
- args: {},
708
- handler: async (ctx) => {
1000
+
1001
+ // List budget buckets, optionally filtered to one dimension ("user", "action",
1002
+ // or any custom tag dimension). Today's spend is zeroed for stale day windows.
1003
+ export const listBuckets = query({
1004
+ args: { dimension: v.optional(v.string()) },
1005
+ handler: async (ctx, args) => {
709
1006
  // Bounded to avoid an unbounded full-table scan on this reactive query.
710
- // Paginate (ctx.db.query("users").paginate(...)) for larger deployments.
711
- const users = await ctx.db.query("users").take(ADMIN_LIST_CAP);
1007
+ // Paginate (ctx.db.query("buckets").paginate(...)) for larger deployments.
1008
+ const rows =
1009
+ args.dimension !== undefined
1010
+ ? await ctx.db
1011
+ .query("buckets")
1012
+ .withIndex("dimension", (q) => q.eq("dimension", args.dimension!))
1013
+ .take(ADMIN_LIST_CAP)
1014
+ : await ctx.db.query("buckets").take(ADMIN_LIST_CAP);
712
1015
  const today = dayStamp();
713
- return users.map((u) => ({
714
- ...u,
715
- spendTodayNanos: u.dayStamp === today ? u.spendTodayNanos : 0,
1016
+ const month = monthStamp();
1017
+ return rows.map((b) => ({
1018
+ ...b,
1019
+ spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
1020
+ spendThisMonthNanos: b.monthStamp === month ? b.spendThisMonthNanos ?? 0 : 0,
716
1021
  }));
717
1022
  },
718
1023
  });
719
1024
 
720
- export const setLimits = mutation({
1025
+ export const getBucket = query({
1026
+ args: { dimension: v.string(), value: v.string() },
1027
+ handler: async (ctx, args) => {
1028
+ const b = await getBucketDoc(ctx as any, args.dimension, args.value);
1029
+ if (!b) return null;
1030
+ const today = dayStamp();
1031
+ const month = monthStamp();
1032
+ return {
1033
+ ...b,
1034
+ spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
1035
+ spendThisMonthNanos: b.monthStamp === month ? b.spendThisMonthNanos ?? 0 : 0,
1036
+ };
1037
+ },
1038
+ });
1039
+
1040
+ // Set a bucket's limits/controls. `user` and `action` are just dimensions here;
1041
+ // the client's ai.users / ai.actions namespaces are thin wrappers over this.
1042
+ export const setBucketLimits = mutation({
721
1043
  args: {
722
- userId: v.string(),
1044
+ dimension: v.string(),
1045
+ value: v.string(),
723
1046
  requestsPerMinute: v.optional(v.number()),
1047
+ maxConcurrent: v.optional(v.number()),
724
1048
  dailySpendLimitNanos: v.optional(v.number()),
1049
+ monthlySpendLimitNanos: v.optional(v.number()),
725
1050
  lifetimeSpendLimitNanos: v.optional(v.number()),
726
1051
  dailyTokenLimit: v.optional(v.number()),
1052
+ monthlyTokenLimit: v.optional(v.number()),
727
1053
  lifetimeTokenLimit: v.optional(v.number()),
1054
+ warnAtPct: v.optional(v.number()),
728
1055
  enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
729
1056
  blocked: v.optional(v.boolean()),
730
1057
  },
731
1058
  returns: v.null(),
732
1059
  handler: async (ctx, args) => {
733
- const user = await getOrCreateUser(ctx, args.userId);
734
- const { userId: _userId, ...limits } = args;
735
- await ctx.db.patch(user._id, limits);
1060
+ const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
1061
+ const { dimension: _d, value: _v, ...limits } = args;
1062
+ await ctx.db.patch(bucket._id, limits);
736
1063
  return null;
737
1064
  },
738
1065
  });
739
1066
 
740
- // Delete a user and all their request rows (e.g. account deletion / GDPR).
741
- // Deletes in bounded batches and self-reschedules so it never exceeds the
742
- // per-transaction document limit — a user with millions of rows still deletes.
743
- const DELETE_BATCH = 500;
744
- export const deleteUser = mutation({
745
- args: { userId: v.string() },
746
- returns: v.object({ deletedThisBatch: v.number(), done: v.boolean() }),
747
- handler: async (ctx, { userId }) => {
748
- const rows = await ctx.db
749
- .query("requests")
750
- .withIndex("userId", (q) => q.eq("userId", userId))
751
- .take(DELETE_BATCH);
752
- for (const r of rows) await ctx.db.delete(r._id);
753
- if (rows.length === DELETE_BATCH) {
754
- // More to go — continue in a fresh transaction.
755
- await ctx.scheduler.runAfter(0, api.lib.deleteUser, { userId });
756
- return { deletedThisBatch: rows.length, done: false };
757
- }
758
- // Last batch: remove the user row itself.
759
- const user = await ctx.db
760
- .query("users")
761
- .withIndex("userId", (q) => q.eq("userId", userId))
762
- .unique();
763
- if (user) await ctx.db.delete(user._id);
764
- return { deletedThisBatch: rows.length + (user ? 1 : 0), done: true };
765
- },
766
- });
767
-
768
- export const listActions = query({
769
- args: {},
770
- handler: async (ctx) => {
771
- const actions = await ctx.db.query("actions").take(ADMIN_LIST_CAP);
772
- const today = dayStamp();
773
- return actions.map((a) => ({
774
- ...a,
775
- spendTodayNanos: a.dayStamp === today ? a.spendTodayNanos : 0,
776
- }));
777
- },
778
- });
779
-
780
1067
  const vBumpArgs = {
781
1068
  dailyNanos: v.optional(v.number()),
1069
+ monthlyNanos: v.optional(v.number()),
782
1070
  lifetimeNanos: v.optional(v.number()),
783
1071
  };
784
1072
 
785
- // One-time "approve another $X" bumps, added on top of the standing cap without
786
- // changing it. Daily bumps apply to today only; lifetime bumps are permanent.
787
- export const bumpUser = mutation({
788
- args: { userId: v.string(), ...vBumpArgs },
1073
+ // One-time "approve another $X" bumps, added on top of a bucket's standing cap
1074
+ // without changing it. Daily/monthly bumps apply to the current window only;
1075
+ // lifetime bumps persist.
1076
+ export const bumpBucket = mutation({
1077
+ args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
789
1078
  returns: v.null(),
790
- handler: async (ctx, { userId, dailyNanos, lifetimeNanos }) => {
791
- const user = await getOrCreateUser(ctx, userId);
1079
+ handler: async (ctx, { dimension, value, dailyNanos, monthlyNanos, lifetimeNanos }) => {
1080
+ const bucket = await getOrCreateBucket(ctx, dimension, value);
792
1081
  const today = dayStamp();
793
- const curDaily = user.bumpDayStamp === today ? user.dailyBumpNanos ?? 0 : 0;
794
- await ctx.db.patch(user._id, {
1082
+ const month = monthStamp();
1083
+ const curDaily =
1084
+ bucket.bumpDayStamp === today ? bucket.dailyBumpNanos ?? 0 : 0;
1085
+ const curMonthly =
1086
+ bucket.bumpMonthStamp === month ? bucket.monthlyBumpNanos ?? 0 : 0;
1087
+ await ctx.db.patch(bucket._id, {
795
1088
  bumpDayStamp: today,
796
1089
  dailyBumpNanos: curDaily + (dailyNanos ?? 0),
797
- lifetimeBumpNanos: (user.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
1090
+ bumpMonthStamp: month,
1091
+ monthlyBumpNanos: curMonthly + (monthlyNanos ?? 0),
1092
+ lifetimeBumpNanos: (bucket.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
798
1093
  });
799
1094
  return null;
800
1095
  },
801
1096
  });
802
1097
 
803
- export const bumpAction = mutation({
804
- args: { name: v.string(), ...vBumpArgs },
1098
+ // Manually credit or debit a bucket (comp a user, correct an overcharge).
1099
+ // Negative deltaNanos = credit/refund, positive = extra charge. Adjusts the
1100
+ // live day/month/lifetime windows AND the durable usage history, and records an
1101
+ // audit row. Does not touch the global sharded total or reservations.
1102
+ export const adjustBucket = mutation({
1103
+ args: {
1104
+ dimension: v.string(),
1105
+ value: v.string(),
1106
+ deltaNanos: v.number(),
1107
+ tokens: v.optional(v.number()),
1108
+ reason: v.optional(v.string()),
1109
+ },
805
1110
  returns: v.null(),
806
- handler: async (ctx, { name, dailyNanos, lifetimeNanos }) => {
807
- const action = await getOrCreateAction(ctx, name);
1111
+ handler: async (ctx, { dimension, value, deltaNanos, tokens, reason }) => {
1112
+ const b = await getOrCreateBucket(ctx, dimension, value);
808
1113
  const today = dayStamp();
809
- const curDaily = action.bumpDayStamp === today ? action.dailyBumpNanos ?? 0 : 0;
810
- await ctx.db.patch(action._id, {
811
- bumpDayStamp: today,
812
- dailyBumpNanos: curDaily + (dailyNanos ?? 0),
813
- lifetimeBumpNanos: (action.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
1114
+ const month = monthStamp();
1115
+ const dSame = b.dayStamp === today;
1116
+ const mSame = b.monthStamp === month;
1117
+ const dt = tokens ?? 0;
1118
+ await ctx.db.patch(b._id, {
1119
+ totalSpendNanos: Math.max(0, b.totalSpendNanos + deltaNanos),
1120
+ totalTokens: Math.max(0, b.totalTokens + dt),
1121
+ dayStamp: today,
1122
+ monthStamp: month,
1123
+ spendTodayNanos: Math.max(0, (dSame ? b.spendTodayNanos : 0) + deltaNanos),
1124
+ tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
1125
+ spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
1126
+ tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
814
1127
  });
1128
+ await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
1129
+ await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
1130
+ await addUsage(ctx, dimension, value, "month", month, deltaNanos, dt, 0);
815
1131
  return null;
816
1132
  },
817
1133
  });
818
1134
 
819
- export const bumpGlobal = mutation({
820
- args: vBumpArgs,
821
- returns: v.null(),
822
- handler: async (ctx, { dailyNanos, lifetimeNanos }) => {
823
- const today = dayStamp();
824
- const s = await getSettings(ctx);
825
- const curDaily = s?.globalBumpDayStamp === today ? s?.globalDailyBumpNanos ?? 0 : 0;
826
- const patch = {
827
- globalBumpDayStamp: today,
828
- globalDailyBumpNanos: curDaily + (dailyNanos ?? 0),
829
- globalLifetimeBumpNanos: (s?.globalLifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
830
- };
831
- if (s) await ctx.db.patch(s._id, patch);
832
- else await ctx.db.insert("settings", { key: "singleton", ...patch });
833
- return null;
834
- },
1135
+ export const listAdjustments = query({
1136
+ args: { dimension: v.string(), value: v.string(), limit: v.optional(v.number()) },
1137
+ handler: async (ctx, { dimension, value, limit }) =>
1138
+ ctx.db
1139
+ .query("adjustments")
1140
+ .withIndex("dim_value", (q) => q.eq("dimension", dimension).eq("value", value))
1141
+ .order("desc")
1142
+ .take(limit ?? 50),
835
1143
  });
836
1144
 
837
- export const setActionLimits = mutation({
1145
+ // Durable spend history for a bucket: per-day or per-month rows, newest first.
1146
+ // Survives request retention.
1147
+ export const usageHistory = query({
838
1148
  args: {
839
- name: v.string(),
840
- dailySpendLimitNanos: v.optional(v.number()),
841
- lifetimeSpendLimitNanos: v.optional(v.number()),
842
- dailyTokenLimit: v.optional(v.number()),
843
- lifetimeTokenLimit: v.optional(v.number()),
844
- enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
845
- disabled: v.optional(v.boolean()),
1149
+ dimension: v.string(),
1150
+ value: v.string(),
1151
+ period: v.union(v.literal("day"), v.literal("month")),
1152
+ limit: v.optional(v.number()),
846
1153
  },
1154
+ handler: async (ctx, { dimension, value, period, limit }) =>
1155
+ ctx.db
1156
+ .query("usage")
1157
+ .withIndex("bucket_period_stamp", (q) =>
1158
+ q.eq("dimension", dimension).eq("value", value).eq("period", period)
1159
+ )
1160
+ .order("desc")
1161
+ .take(limit ?? 90),
1162
+ });
1163
+
1164
+ // Deployment-wide default threshold for approaching-limit alerts (fraction of a
1165
+ // cap, e.g. 0.8). Buckets can override with their own warnAtPct.
1166
+ export const setAlertDefaults = mutation({
1167
+ args: { warnAtPct: v.optional(v.number()) },
847
1168
  returns: v.null(),
848
- handler: async (ctx, args) => {
849
- const action = await getOrCreateAction(ctx, args.name);
850
- const { name: _name, ...limits } = args;
851
- await ctx.db.patch(action._id, limits);
1169
+ handler: async (ctx, { warnAtPct }) => {
1170
+ const existing = await getSettings(ctx);
1171
+ if (existing) await ctx.db.patch(existing._id, { defaultWarnAtPct: warnAtPct });
1172
+ else await ctx.db.insert("settings", { key: "singleton", defaultWarnAtPct: warnAtPct });
852
1173
  return null;
853
1174
  },
854
1175
  });
855
1176
 
1177
+ // Delete a bucket and (for the `user` dimension) all of that user's request
1178
+ // rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
1179
+ // self-reschedules so it never exceeds the per-transaction document limit.
1180
+ const DELETE_BATCH = 500;
1181
+ export const deleteBucket = mutation({
1182
+ args: { dimension: v.string(), value: v.string() },
1183
+ returns: v.object({ deletedThisBatch: v.number(), done: v.boolean() }),
1184
+ handler: async (ctx, { dimension, value }) => {
1185
+ // Only the user dimension owns request rows (indexed by userId). Other
1186
+ // dimensions just drop their budget-holder row.
1187
+ if (dimension === USER_DIM) {
1188
+ const rows = await ctx.db
1189
+ .query("requests")
1190
+ .withIndex("userId", (q) => q.eq("userId", value))
1191
+ .take(DELETE_BATCH);
1192
+ for (const r of rows) {
1193
+ await deleteRequestTags(ctx, r._id);
1194
+ await ctx.db.delete(r._id);
1195
+ }
1196
+ if (rows.length === DELETE_BATCH) {
1197
+ await ctx.scheduler.runAfter(0, api.lib.deleteBucket, {
1198
+ dimension,
1199
+ value,
1200
+ });
1201
+ return { deletedThisBatch: rows.length, done: false };
1202
+ }
1203
+ const bucket = await getBucketDoc(ctx, dimension, value);
1204
+ if (bucket) await ctx.db.delete(bucket._id);
1205
+ return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
1206
+ }
1207
+ const bucket = await getBucketDoc(ctx, dimension, value);
1208
+ if (bucket) await ctx.db.delete(bucket._id);
1209
+ return { deletedThisBatch: bucket ? 1 : 0, done: true };
1210
+ },
1211
+ });
1212
+
856
1213
  export const getModelPolicy = query({
857
1214
  args: {},
858
1215
  returns: v.object({
@@ -880,6 +1237,9 @@ export const getGlobalStatus = query({
880
1237
  enforcement: v.union(v.literal("hard"), v.literal("soft")),
881
1238
  spentTodayNanos: v.number(),
882
1239
  spentTotalNanos: v.number(),
1240
+ // deployment-wide config (surfaced for the admin dashboard)
1241
+ retentionMs: v.union(v.number(), v.null()),
1242
+ defaultWarnAtPct: v.union(v.number(), v.null()),
883
1243
  }),
884
1244
  handler: async (ctx) => {
885
1245
  const s = await ctx.db
@@ -892,6 +1252,8 @@ export const getGlobalStatus = query({
892
1252
  enforcement: s?.globalEnforcement ?? "hard",
893
1253
  spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
894
1254
  spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
1255
+ retentionMs: s?.retentionMs ?? null,
1256
+ defaultWarnAtPct: s?.defaultWarnAtPct ?? null,
895
1257
  };
896
1258
  },
897
1259
  });
@@ -920,6 +1282,24 @@ export const setGlobalLimits = mutation({
920
1282
  },
921
1283
  });
922
1284
 
1285
+ export const bumpGlobal = mutation({
1286
+ args: vBumpArgs,
1287
+ returns: v.null(),
1288
+ handler: async (ctx, { dailyNanos, lifetimeNanos }) => {
1289
+ const today = dayStamp();
1290
+ const s = await getSettings(ctx);
1291
+ const curDaily = s?.globalBumpDayStamp === today ? s?.globalDailyBumpNanos ?? 0 : 0;
1292
+ const patch = {
1293
+ globalBumpDayStamp: today,
1294
+ globalDailyBumpNanos: curDaily + (dailyNanos ?? 0),
1295
+ globalLifetimeBumpNanos: (s?.globalLifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
1296
+ };
1297
+ if (s) await ctx.db.patch(s._id, patch);
1298
+ else await ctx.db.insert("settings", { key: "singleton", ...patch });
1299
+ return null;
1300
+ },
1301
+ });
1302
+
923
1303
  export const setModelPolicy = mutation({
924
1304
  args: {
925
1305
  mode: v.union(
@@ -953,12 +1333,18 @@ export const setPrice = mutation({
953
1333
  model: v.string(),
954
1334
  inputNanosPerMTok: v.number(),
955
1335
  outputNanosPerMTok: v.number(),
1336
+ // optional cache-read rate; if omitted, a default discount off input applies
1337
+ cachedNanosPerMTok: v.optional(v.number()),
956
1338
  },
957
1339
  returns: v.null(),
958
1340
  handler: async (ctx, args) => {
959
1341
  // Negative prices would make costOf return a negative cost, which folds
960
1342
  // into totals as a spend *refund* — pushing a user back under their cap.
961
- if (args.inputNanosPerMTok < 0 || args.outputNanosPerMTok < 0) {
1343
+ if (
1344
+ args.inputNanosPerMTok < 0 ||
1345
+ args.outputNanosPerMTok < 0 ||
1346
+ (args.cachedNanosPerMTok ?? 0) < 0
1347
+ ) {
962
1348
  throw new Error("Prices must be non-negative");
963
1349
  }
964
1350
  const existing = await ctx.db
@@ -978,7 +1364,10 @@ export const listPrices = query({
978
1364
  args: {},
979
1365
  handler: async (ctx) => {
980
1366
  const overrides = await ctx.db.query("prices").collect();
981
- const merged: Record<string, { input: number; output: number; overridden: boolean }> = {};
1367
+ const merged: Record<
1368
+ string,
1369
+ { input: number; output: number; cached?: number; overridden: boolean }
1370
+ > = {};
982
1371
  for (const [model, p] of Object.entries(DEFAULT_PRICES)) {
983
1372
  merged[model] = { ...p, overridden: false };
984
1373
  }
@@ -986,9 +1375,33 @@ export const listPrices = query({
986
1375
  merged[o.model] = {
987
1376
  input: o.inputNanosPerMTok,
988
1377
  output: o.outputNanosPerMTok,
1378
+ cached: o.cachedNanosPerMTok,
989
1379
  overridden: true,
990
1380
  };
991
1381
  }
992
1382
  return merged;
993
1383
  },
994
1384
  });
1385
+
1386
+ // Per-call fees for provider server tools (web search, etc.), defaults merged
1387
+ // with any deployment overrides.
1388
+ export const listServerToolPrices = query({
1389
+ args: {},
1390
+ handler: async (ctx) => {
1391
+ const s = await getSettings(ctx as any);
1392
+ return { ...DEFAULT_SERVER_TOOL_PRICES, ...(s?.serverToolPrices ?? {}) };
1393
+ },
1394
+ });
1395
+
1396
+ export const setServerToolPrice = mutation({
1397
+ args: { tool: v.string(), nanosPerCall: v.number() },
1398
+ returns: v.null(),
1399
+ handler: async (ctx, { tool, nanosPerCall }) => {
1400
+ if (nanosPerCall < 0) throw new Error("Prices must be non-negative");
1401
+ const s = await getSettings(ctx);
1402
+ const serverToolPrices = { ...(s?.serverToolPrices ?? {}), [tool]: nanosPerCall };
1403
+ if (s) await ctx.db.patch(s._id, { serverToolPrices });
1404
+ else await ctx.db.insert("settings", { key: "singleton", serverToolPrices });
1405
+ return null;
1406
+ },
1407
+ });