@convex-dev/ai-budget 0.0.2-alpha.4 → 0.0.2-alpha.6

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.
@@ -60,7 +60,13 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
60
60
  // Override per-deployment via setRetention.
61
61
  const DEFAULT_RETENTION_MS = 60 * 60 * 1000; // 1 hour
62
62
 
63
- const dayStamp = () => new Date().toISOString().slice(0, 10);
63
+ const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
64
+ const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
65
+
66
+ // Cached (prompt-cache-read) input tokens are billed far below the normal input
67
+ // rate. When a model's price has no explicit cachedNanosPerMTok, charge this
68
+ // fraction of its input rate (providers commonly discount ~90%).
69
+ const CACHE_DISCOUNT = 0.1;
64
70
 
65
71
  // Conservative fallback for any model not in the price table: the max of every
66
72
  // known price dimension. Falling back to 0 would be fail-open — an unpriced
@@ -85,14 +91,21 @@ async function getPrice(ctx: MutationCtx, model: string) {
85
91
  return {
86
92
  input: override.inputNanosPerMTok,
87
93
  output: override.outputNanosPerMTok,
94
+ cached: override.cachedNanosPerMTok,
88
95
  known: true,
89
96
  };
90
97
  }
91
98
  const known = DEFAULT_PRICES[model];
92
- if (known) return { ...known, known: true };
93
- return { ...CONSERVATIVE_PRICE, known: false };
99
+ if (known) return { ...known, cached: undefined, known: true };
100
+ return { ...CONSERVATIVE_PRICE, cached: undefined, known: false };
94
101
  }
95
102
 
103
+ type Price = { input: number; output: number; cached?: number };
104
+ // The per-Mtok rate for cached (prompt-cache-read) tokens: an explicit override,
105
+ // else a discount off the input rate.
106
+ const cachedRate = (p: Price) =>
107
+ p.cached ?? Math.round(p.input * CACHE_DISCOUNT);
108
+
96
109
  // Integer nanodollars. Divide-before-multiply keeps the intermediate product
97
110
  // within 2^53 even for large token counts × large per-Mtok prices.
98
111
  const costOf = (
@@ -104,6 +117,66 @@ const costOf = (
104
117
  (inputTokens / 1e6) * price.input + (outputTokens / 1e6) * price.output
105
118
  );
106
119
 
120
+ // Cache-aware settle cost: the cached slice of the prompt is billed at the
121
+ // (discounted) cache rate, the rest of the prompt at the input rate, and
122
+ // completions at the output rate. `cachedTokens` is the gateway's real
123
+ // prompt-cache-read count (usage.inputTokenDetails.cacheReadTokens).
124
+ const settleCost = (
125
+ promptTokens: number,
126
+ cachedTokens: number,
127
+ completionTokens: number,
128
+ price: Price
129
+ ) => {
130
+ const cached = Math.min(Math.max(0, cachedTokens), Math.max(0, promptTokens));
131
+ const fresh = Math.max(0, promptTokens - cached);
132
+ return Math.round(
133
+ (fresh / 1e6) * price.input +
134
+ (cached / 1e6) * cachedRate(price) +
135
+ (completionTokens / 1e6) * price.output
136
+ );
137
+ };
138
+
139
+ // Upsert-add a settled amount into the durable per-(bucket, period) usage row.
140
+ // These rows are never swept by request retention, so spend history survives.
141
+ async function addUsage(
142
+ ctx: MutationCtx,
143
+ dimension: string,
144
+ value: string,
145
+ period: "day" | "month",
146
+ stamp: string,
147
+ spendNanos: number,
148
+ tokens: number,
149
+ requests: number
150
+ ) {
151
+ const existing = await ctx.db
152
+ .query("usage")
153
+ .withIndex("bucket_period_stamp", (q) =>
154
+ q
155
+ .eq("dimension", dimension)
156
+ .eq("value", value)
157
+ .eq("period", period)
158
+ .eq("stamp", stamp)
159
+ )
160
+ .unique();
161
+ if (existing) {
162
+ await ctx.db.patch(existing._id, {
163
+ spendNanos: existing.spendNanos + spendNanos,
164
+ tokens: existing.tokens + tokens,
165
+ requests: existing.requests + requests,
166
+ });
167
+ } else {
168
+ await ctx.db.insert("usage", {
169
+ dimension,
170
+ value,
171
+ period,
172
+ stamp,
173
+ spendNanos,
174
+ tokens,
175
+ requests,
176
+ });
177
+ }
178
+ }
179
+
107
180
  // Up-front estimate of a request's cost and token count, reserved before the
108
181
  // call so concurrent in-flight requests are visible to each other's caps.
109
182
  function estimateUsage(
@@ -158,57 +231,76 @@ function sanitizeExtraTags(
158
231
  }
159
232
 
160
233
  // Evaluate a bucket's spend + token budgets against committed + reserved + this
161
- // request's estimate. Returns a hard rejection (block) or a list of soft
162
- // warnings (allow), per the bucket's enforcement.
234
+ // request's estimate. Returns a hard rejection (block), soft warnings (allow),
235
+ // and threshold notices (approaching a cap see warnAtPct). Each window (daily,
236
+ // monthly, lifetime) × kind (spend, token) is one check.
163
237
  function evaluateCaps(o: {
164
238
  label: string;
165
239
  name: string;
166
240
  enforcement: "hard" | "soft";
241
+ warnAtPct?: number;
167
242
  estCost: number;
168
243
  estTokens: number;
169
244
  spendToday: number;
170
245
  reservedSpendToday: number;
246
+ spendThisMonth: number;
247
+ reservedSpendMonth: number;
171
248
  totalSpend: number;
172
249
  reservedSpendTotal: number;
173
250
  tokensToday: number;
174
251
  reservedTokensToday: number;
252
+ tokensThisMonth: number;
253
+ reservedTokensMonth: number;
175
254
  totalTokens: number;
176
255
  reservedTokensTotal: number;
177
256
  dailySpendLimitNanos?: number;
257
+ monthlySpendLimitNanos?: number;
178
258
  lifetimeSpendLimitNanos?: number;
179
259
  dailyTokenLimit?: number;
260
+ monthlyTokenLimit?: number;
180
261
  lifetimeTokenLimit?: number;
181
- }): { hard?: { code: string; reason: string }; warnings: string[] } {
262
+ }): {
263
+ hard?: { code: string; reason: string };
264
+ warnings: string[];
265
+ notices: string[];
266
+ } {
267
+ // { code, projected usage (incl. this estimate), cap, human window label,
268
+ // whether it's a money cap (formatted as $), spend? for notices }
269
+ const checks = [
270
+ { w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost, cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
271
+ { w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost, cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
272
+ { w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost, cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
273
+ { w: "daily_token_limit", used: o.tokensToday + o.reservedTokensToday + o.estTokens, cap: o.dailyTokenLimit, label: "daily token limit", money: false },
274
+ { w: "monthly_token_limit", used: o.tokensThisMonth + o.reservedTokensMonth + o.estTokens, cap: o.monthlyTokenLimit, label: "monthly token limit", money: false },
275
+ { w: "lifetime_token_limit", used: o.totalTokens + o.reservedTokensTotal + o.estTokens, cap: o.lifetimeTokenLimit, label: "lifetime token limit", money: false },
276
+ ];
277
+
182
278
  const violations: { code: string; reason: string }[] = [];
183
- const push = (code: string, reason: string) =>
184
- violations.push({ code: `${o.label}_${code}`, reason });
185
-
186
- if (
187
- o.dailySpendLimitNanos !== undefined &&
188
- o.spendToday + o.reservedSpendToday + o.estCost > o.dailySpendLimitNanos
189
- )
190
- push("daily_spend_limit", `Daily spend limit reached for ${o.label} "${o.name}" (${fmtUsd(o.dailySpendLimitNanos)}/day)`);
191
- if (
192
- o.lifetimeSpendLimitNanos !== undefined &&
193
- o.totalSpend + o.reservedSpendTotal + o.estCost > o.lifetimeSpendLimitNanos
194
- )
195
- push("lifetime_spend_limit", `Lifetime spend limit reached for ${o.label} "${o.name}"`);
196
- if (
197
- o.dailyTokenLimit !== undefined &&
198
- o.tokensToday + o.reservedTokensToday + o.estTokens > o.dailyTokenLimit
199
- )
200
- push("daily_token_limit", `Daily token limit reached for ${o.label} "${o.name}" (${o.dailyTokenLimit}/day)`);
201
- if (
202
- o.lifetimeTokenLimit !== undefined &&
203
- o.totalTokens + o.reservedTokensTotal + o.estTokens > o.lifetimeTokenLimit
204
- )
205
- push("lifetime_token_limit", `Lifetime token limit reached for ${o.label} "${o.name}"`);
206
-
207
- if (violations.length === 0) return { warnings: [] };
208
- if (o.enforcement === "soft") return { warnings: violations.map((v) => v.reason) };
209
- return { hard: violations[0], warnings: [] };
279
+ const notices: string[] = [];
280
+ const pct = o.warnAtPct;
281
+ for (const c of checks) {
282
+ if (c.cap === undefined) continue;
283
+ const capStr = c.money ? `${fmtUsd(c.cap)}` : `${c.cap} tokens`;
284
+ if (c.used > c.cap) {
285
+ violations.push({
286
+ code: `${o.label}_${c.w}`,
287
+ reason: `${cap(c.label)} reached for ${o.label} "${o.name}" (${capStr})`,
288
+ });
289
+ } else if (pct !== undefined && pct > 0 && pct < 1 && c.used >= pct * c.cap) {
290
+ notices.push(
291
+ `${o.label} "${o.name}" at ${Math.round((c.used / c.cap) * 100)}% of ${c.label} (${capStr})`
292
+ );
293
+ }
294
+ }
295
+
296
+ if (violations.length === 0) return { warnings: [], notices };
297
+ if (o.enforcement === "soft")
298
+ return { warnings: violations.map((v) => v.reason), notices };
299
+ return { hard: violations[0], warnings: [], notices };
210
300
  }
211
301
 
302
+ const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
303
+
212
304
  // A cap plus any one-time bump. Returns undefined when there's no base cap
213
305
  // (a bump alone never creates a cap).
214
306
  const withBump = (base: number | undefined, bump: number | undefined) =>
@@ -216,15 +308,24 @@ const withBump = (base: number | undefined, bump: number | undefined) =>
216
308
 
217
309
  const hasAnyCap = (e: {
218
310
  dailySpendLimitNanos?: number;
311
+ monthlySpendLimitNanos?: number;
219
312
  lifetimeSpendLimitNanos?: number;
220
313
  dailyTokenLimit?: number;
314
+ monthlyTokenLimit?: number;
221
315
  lifetimeTokenLimit?: number;
222
316
  }) =>
223
317
  e.dailySpendLimitNanos !== undefined ||
318
+ e.monthlySpendLimitNanos !== undefined ||
224
319
  e.lifetimeSpendLimitNanos !== undefined ||
225
320
  e.dailyTokenLimit !== undefined ||
321
+ e.monthlyTokenLimit !== undefined ||
226
322
  e.lifetimeTokenLimit !== undefined;
227
323
 
324
+ // A bucket needs a reservation row-write if it has any spend/token cap OR a
325
+ // concurrency cap (which reads pendingCount, incremented at reserve time).
326
+ const needsReserve = (e: Parameters<typeof hasAnyCap>[0] & { maxConcurrent?: number }) =>
327
+ hasAnyCap(e) || e.maxConcurrent !== undefined;
328
+
228
329
  async function getBucketDoc(ctx: MutationCtx, dimension: string, value: string) {
229
330
  return await ctx.db
230
331
  .query("buckets")
@@ -260,11 +361,22 @@ async function getSettings(ctx: MutationCtx) {
260
361
  .unique();
261
362
  }
262
363
 
364
+ // Delete the reverse-index rows for a request (called when the request row is
365
+ // deleted, so the tag index never outlives the request it points at).
366
+ async function deleteRequestTags(ctx: MutationCtx, requestId: Doc<"requests">["_id"]) {
367
+ const tags = await ctx.db
368
+ .query("requestTags")
369
+ .withIndex("requestId", (q) => q.eq("requestId", requestId))
370
+ .collect();
371
+ for (const t of tags) await ctx.db.delete(t._id);
372
+ }
373
+
263
374
  const vStartResult = v.union(
264
375
  v.object({
265
376
  allowed: v.literal(true),
266
377
  requestId: v.id("requests"),
267
- warnings: v.array(v.string()),
378
+ warnings: v.array(v.string()), // soft caps exceeded (allowed with a warning)
379
+ notices: v.array(v.string()), // approaching a cap (warnAtPct threshold)
268
380
  }),
269
381
  v.object({
270
382
  allowed: v.literal(false),
@@ -309,12 +421,15 @@ export const startRequest = mutation({
309
421
  };
310
422
 
311
423
  const today = dayStamp();
424
+ const month = monthStamp();
312
425
  const priceInfo = await getPrice(ctx, args.model);
313
426
  const est = estimateUsage(args.messages, priceInfo);
314
427
  const warnings: string[] = [];
428
+ const notices: string[] = [];
315
429
 
316
430
  // Model allow/deny policy (component-wide).
317
431
  const settings = await getSettings(ctx);
432
+ const defaultWarnAtPct = settings?.defaultWarnAtPct;
318
433
  if (settings) {
319
434
  const mode = settings.modelMode ?? "open";
320
435
  const list = settings.models ?? [];
@@ -351,6 +466,19 @@ export const startRequest = mutation({
351
466
  }
352
467
  }
353
468
 
469
+ // Concurrency cap: reject when a bucket already has maxConcurrent requests
470
+ // in flight (pendingCount). A transient limit like rate-limiting, so it
471
+ // isn't persisted (the caller retries once something settles).
472
+ for (const b of buckets) {
473
+ if (b.maxConcurrent !== undefined && (b.pendingCount ?? 0) >= b.maxConcurrent) {
474
+ return reject(
475
+ `${b.dimension}_max_concurrent`,
476
+ `Too many concurrent requests for ${b.dimension} "${b.value}" (max ${b.maxConcurrent})`,
477
+ false
478
+ );
479
+ }
480
+ }
481
+
354
482
  // Rate limit is enforced on the `user` dimension (the requests table is
355
483
  // indexed by userId, so the 60s window is a bounded index read).
356
484
  const userBucket = buckets.find((b) => b.dimension === USER_DIM)!;
@@ -385,33 +513,45 @@ export const startRequest = mutation({
385
513
  // warning instead of a block.
386
514
  for (const b of buckets) {
387
515
  const sameDay = b.dayStamp === today;
516
+ const sameMonth = b.monthStamp === month;
388
517
  const ev = evaluateCaps({
389
518
  label: b.dimension,
390
519
  name: b.value,
391
520
  enforcement: b.enforcement ?? "hard",
521
+ warnAtPct: b.warnAtPct ?? defaultWarnAtPct,
392
522
  estCost: est.cost,
393
523
  estTokens: est.tokens,
394
524
  spendToday: sameDay ? b.spendTodayNanos : 0,
395
525
  reservedSpendToday: sameDay ? b.reservedTodayNanos ?? 0 : 0,
526
+ spendThisMonth: sameMonth ? b.spendThisMonthNanos ?? 0 : 0,
527
+ reservedSpendMonth: sameMonth ? b.reservedMonthNanos ?? 0 : 0,
396
528
  totalSpend: b.totalSpendNanos,
397
529
  reservedSpendTotal: b.reservedTotalNanos ?? 0,
398
530
  tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
399
531
  reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
532
+ tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
533
+ reservedTokensMonth: sameMonth ? b.reservedMonthTokens ?? 0 : 0,
400
534
  totalTokens: b.totalTokens,
401
535
  reservedTokensTotal: b.reservedTotalTokens ?? 0,
402
536
  dailySpendLimitNanos: withBump(
403
537
  b.dailySpendLimitNanos,
404
538
  b.bumpDayStamp === today ? b.dailyBumpNanos : 0
405
539
  ),
540
+ monthlySpendLimitNanos: withBump(
541
+ b.monthlySpendLimitNanos,
542
+ b.bumpMonthStamp === month ? b.monthlyBumpNanos : 0
543
+ ),
406
544
  lifetimeSpendLimitNanos: withBump(
407
545
  b.lifetimeSpendLimitNanos,
408
546
  b.lifetimeBumpNanos
409
547
  ),
410
548
  dailyTokenLimit: b.dailyTokenLimit,
549
+ monthlyTokenLimit: b.monthlyTokenLimit,
411
550
  lifetimeTokenLimit: b.lifetimeTokenLimit,
412
551
  });
413
552
  if (ev.hard) return reject(ev.hard.code, ev.hard.reason);
414
553
  warnings.push(...ev.warnings);
554
+ notices.push(...ev.notices);
415
555
  }
416
556
 
417
557
  // Deployment-wide ("global") spend cap — same reserve-then-settle model and
@@ -433,14 +573,19 @@ export const startRequest = mutation({
433
573
  label: "global",
434
574
  name: "deployment",
435
575
  enforcement: settings.globalEnforcement ?? "hard",
576
+ warnAtPct: defaultWarnAtPct,
436
577
  estCost: est.cost,
437
578
  estTokens: est.tokens,
438
579
  spendToday: await globalSpend.count(ctx, globalDayKey(today)),
439
580
  reservedSpendToday: 0, // sharded holder: no cross-request reservation
581
+ spendThisMonth: 0, // global tracks daily + lifetime only
582
+ reservedSpendMonth: 0,
440
583
  totalSpend: await globalSpend.count(ctx, GLOBAL_TOTAL),
441
584
  reservedSpendTotal: 0,
442
585
  tokensToday: 0,
443
586
  reservedTokensToday: 0,
587
+ tokensThisMonth: 0,
588
+ reservedTokensMonth: 0,
444
589
  totalTokens: 0,
445
590
  reservedTokensTotal: 0,
446
591
  dailySpendLimitNanos: withBump(
@@ -454,6 +599,7 @@ export const startRequest = mutation({
454
599
  });
455
600
  if (globalEval.hard) return reject(globalEval.hard.code, globalEval.hard.reason);
456
601
  warnings.push(...globalEval.warnings);
602
+ notices.push(...globalEval.notices);
457
603
  }
458
604
 
459
605
  // Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
@@ -462,15 +608,21 @@ export const startRequest = mutation({
462
608
  // there's no reserved amount to consult. Totals are still accrued later,
463
609
  // asynchronously, in foldOne — for every bucket, capped or not.
464
610
  for (const b of buckets) {
465
- if (!hasAnyCap(b)) continue;
611
+ if (!needsReserve(b)) continue;
466
612
  const sameDay = b.dayStamp === today;
613
+ const sameMonth = b.monthStamp === month;
467
614
  await ctx.db.patch(b._id, {
468
615
  dayStamp: today,
616
+ monthStamp: month,
469
617
  spendTodayNanos: sameDay ? b.spendTodayNanos : 0,
470
618
  tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
619
+ spendThisMonthNanos: sameMonth ? b.spendThisMonthNanos ?? 0 : 0,
620
+ tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
471
621
  reservedTodayNanos: (sameDay ? b.reservedTodayNanos ?? 0 : 0) + est.cost,
622
+ reservedMonthNanos: (sameMonth ? b.reservedMonthNanos ?? 0 : 0) + est.cost,
472
623
  reservedTotalNanos: (b.reservedTotalNanos ?? 0) + est.cost,
473
624
  reservedTodayTokens: (sameDay ? b.reservedTodayTokens ?? 0 : 0) + est.tokens,
625
+ reservedMonthTokens: (sameMonth ? b.reservedMonthTokens ?? 0 : 0) + est.tokens,
474
626
  reservedTotalTokens: (b.reservedTotalTokens ?? 0) + est.tokens,
475
627
  pendingCount: (b.pendingCount ?? 0) + 1,
476
628
  });
@@ -488,7 +640,16 @@ export const startRequest = mutation({
488
640
  ...(priceInfo.known ? {} : { unpricedModel: true }),
489
641
  ...(warnings.length > 0 ? { overBudget: true } : {}),
490
642
  });
491
- return { allowed: true as const, requestId, warnings };
643
+ // Reverse index so the request log can be filtered by any extra tag
644
+ // dimension (user/action are already indexed on the requests table).
645
+ for (const t of extraTags) {
646
+ await ctx.db.insert("requestTags", {
647
+ dimension: t.dimension,
648
+ value: t.value,
649
+ requestId,
650
+ });
651
+ }
652
+ return { allowed: true as const, requestId, warnings, notices };
492
653
  },
493
654
  });
494
655
 
@@ -500,6 +661,10 @@ export const finishRequest = mutation({
500
661
  promptTokens: v.optional(v.number()),
501
662
  completionTokens: v.optional(v.number()),
502
663
  cachedTokens: v.optional(v.number()),
664
+ // Authoritative cost from the gateway, if it ever reports one. When present
665
+ // it's recorded verbatim (no token-based estimate); when absent we price
666
+ // from tokens (cache-aware). Wired now so adopting a real cost is one line.
667
+ costNanos: v.optional(v.number()),
503
668
  latencyMs: v.optional(v.number()),
504
669
  },
505
670
  returns: v.object({ costNanos: v.number() }),
@@ -523,10 +688,17 @@ export const finishRequest = mutation({
523
688
  const promptTokens = Math.max(0, args.promptTokens ?? 0);
524
689
  const completionTokens = Math.max(0, args.completionTokens ?? 0);
525
690
  const cachedTokens = Math.min(promptTokens, Math.max(0, args.cachedTokens ?? 0));
526
- const costNanos = Math.max(
527
- 0,
528
- costOf(promptTokens, completionTokens, await getPrice(ctx, request.model))
529
- );
691
+ // Prefer an authoritative gateway cost when supplied; otherwise price from
692
+ // tokens, discounting the cached (prompt-cache-read) slice of the prompt.
693
+ const costNanos =
694
+ args.costNanos !== undefined && args.costNanos >= 0
695
+ ? Math.round(args.costNanos)
696
+ : settleCost(
697
+ promptTokens,
698
+ cachedTokens,
699
+ completionTokens,
700
+ await getPrice(ctx, request.model)
701
+ );
530
702
 
531
703
  // Durable write to the request's OWN row only — uncontended, so it always
532
704
  // lands. `settled: false` hands it to the fold step; the row is never left
@@ -562,26 +734,37 @@ async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
562
734
  const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
563
735
  const estTokens = req.estimatedTokens ?? 0;
564
736
  const today = dayStamp();
737
+ const month = monthStamp();
565
738
 
566
739
  // Accrue into every attributed bucket (user, action, and each tag) — capped
567
740
  // or not. Buckets that never held a reservation have their reserved fields
568
741
  // clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
742
+ // Also write the durable per-(bucket, day/month) usage rows that survive
743
+ // request retention, so spend history outlives the raw request log.
569
744
  for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
570
745
  const b = await getOrCreateBucket(ctx, t.dimension, t.value);
571
746
  const sameDay = b.dayStamp === today;
747
+ const sameMonth = b.monthStamp === month;
572
748
  await ctx.db.patch(b._id, {
573
749
  totalSpendNanos: b.totalSpendNanos + actual,
574
750
  totalRequests: b.totalRequests + 1,
575
751
  totalTokens: b.totalTokens + tokens,
576
752
  dayStamp: today,
753
+ monthStamp: month,
577
754
  spendTodayNanos: (sameDay ? b.spendTodayNanos : 0) + actual,
578
755
  tokensToday: (sameDay ? b.tokensToday ?? 0 : 0) + tokens,
756
+ spendThisMonthNanos: (sameMonth ? b.spendThisMonthNanos ?? 0 : 0) + actual,
757
+ tokensThisMonth: (sameMonth ? b.tokensThisMonth ?? 0 : 0) + tokens,
579
758
  reservedTodayNanos: Math.max(0, (sameDay ? b.reservedTodayNanos ?? 0 : 0) - estCost),
759
+ reservedMonthNanos: Math.max(0, (sameMonth ? b.reservedMonthNanos ?? 0 : 0) - estCost),
580
760
  reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
581
761
  reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
762
+ reservedMonthTokens: Math.max(0, (sameMonth ? b.reservedMonthTokens ?? 0 : 0) - estTokens),
582
763
  reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
583
764
  pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
584
765
  });
766
+ await addUsage(ctx, t.dimension, t.value, "day", today, actual, tokens, 1);
767
+ await addUsage(ctx, t.dimension, t.value, "month", month, actual, tokens, 1);
585
768
  }
586
769
 
587
770
  // Deployment-wide totals via the sharded counter (only when a global cap is
@@ -660,6 +843,7 @@ export const reconcile = internalMutation({
660
843
  // Only rows that are done and accounted: folded (settled === true) or a
661
844
  // blocked attempt (never needs folding). Never a pending/unfolded row.
662
845
  if (req.settled === true || req.status === "blocked") {
846
+ await deleteRequestTags(ctx, req._id);
663
847
  await ctx.db.delete(req._id);
664
848
  purged++;
665
849
  }
@@ -706,10 +890,19 @@ export const getRequest = query({
706
890
  });
707
891
 
708
892
  export const listRequests = query({
709
- args: { userId: v.optional(v.string()), limit: v.optional(v.number()) },
893
+ args: {
894
+ userId: v.optional(v.string()),
895
+ // Filter by any attribution dimension (user/action indexed on the request;
896
+ // custom tag dimensions resolved via the requestTags reverse index).
897
+ dimension: v.optional(v.string()),
898
+ value: v.optional(v.string()),
899
+ limit: v.optional(v.number()),
900
+ },
710
901
  handler: async (ctx, args) => {
711
902
  const limit = args.limit ?? 50;
712
- if (args.userId !== undefined) {
903
+ const dim = args.dimension;
904
+ const val = args.value ?? args.userId;
905
+ if (dim === undefined && args.userId !== undefined) {
713
906
  const userId = args.userId;
714
907
  return await ctx.db
715
908
  .query("requests")
@@ -717,6 +910,30 @@ export const listRequests = query({
717
910
  .order("desc")
718
911
  .take(limit);
719
912
  }
913
+ if (dim !== undefined && val !== undefined) {
914
+ if (dim === USER_DIM) {
915
+ return await ctx.db
916
+ .query("requests")
917
+ .withIndex("userId", (q) => q.eq("userId", val))
918
+ .order("desc")
919
+ .take(limit);
920
+ }
921
+ if (dim === ACTION_DIM) {
922
+ return await ctx.db
923
+ .query("requests")
924
+ .withIndex("actionName", (q) => q.eq("actionName", val))
925
+ .order("desc")
926
+ .take(limit);
927
+ }
928
+ // Custom tag dimension: walk the reverse index, then fetch each request.
929
+ const tagRows = await ctx.db
930
+ .query("requestTags")
931
+ .withIndex("dim_value", (q) => q.eq("dimension", dim).eq("value", val))
932
+ .order("desc")
933
+ .take(limit);
934
+ const rows = await Promise.all(tagRows.map((t) => ctx.db.get(t.requestId)));
935
+ return rows.filter((r): r is Doc<"requests"> => r !== null);
936
+ }
720
937
  return await ctx.db.query("requests").order("desc").take(limit);
721
938
  },
722
939
  });
@@ -738,9 +955,11 @@ export const listBuckets = query({
738
955
  .take(ADMIN_LIST_CAP)
739
956
  : await ctx.db.query("buckets").take(ADMIN_LIST_CAP);
740
957
  const today = dayStamp();
958
+ const month = monthStamp();
741
959
  return rows.map((b) => ({
742
960
  ...b,
743
961
  spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
962
+ spendThisMonthNanos: b.monthStamp === month ? b.spendThisMonthNanos ?? 0 : 0,
744
963
  }));
745
964
  },
746
965
  });
@@ -751,7 +970,12 @@ export const getBucket = query({
751
970
  const b = await getBucketDoc(ctx as any, args.dimension, args.value);
752
971
  if (!b) return null;
753
972
  const today = dayStamp();
754
- return { ...b, spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0 };
973
+ const month = monthStamp();
974
+ return {
975
+ ...b,
976
+ spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
977
+ spendThisMonthNanos: b.monthStamp === month ? b.spendThisMonthNanos ?? 0 : 0,
978
+ };
755
979
  },
756
980
  });
757
981
 
@@ -762,10 +986,14 @@ export const setBucketLimits = mutation({
762
986
  dimension: v.string(),
763
987
  value: v.string(),
764
988
  requestsPerMinute: v.optional(v.number()),
989
+ maxConcurrent: v.optional(v.number()),
765
990
  dailySpendLimitNanos: v.optional(v.number()),
991
+ monthlySpendLimitNanos: v.optional(v.number()),
766
992
  lifetimeSpendLimitNanos: v.optional(v.number()),
767
993
  dailyTokenLimit: v.optional(v.number()),
994
+ monthlyTokenLimit: v.optional(v.number()),
768
995
  lifetimeTokenLimit: v.optional(v.number()),
996
+ warnAtPct: v.optional(v.number()),
769
997
  enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
770
998
  blocked: v.optional(v.boolean()),
771
999
  },
@@ -780,28 +1008,114 @@ export const setBucketLimits = mutation({
780
1008
 
781
1009
  const vBumpArgs = {
782
1010
  dailyNanos: v.optional(v.number()),
1011
+ monthlyNanos: v.optional(v.number()),
783
1012
  lifetimeNanos: v.optional(v.number()),
784
1013
  };
785
1014
 
786
1015
  // One-time "approve another $X" bumps, added on top of a bucket's standing cap
787
- // without changing it. Daily bumps apply to today only; lifetime bumps persist.
1016
+ // without changing it. Daily/monthly bumps apply to the current window only;
1017
+ // lifetime bumps persist.
788
1018
  export const bumpBucket = mutation({
789
1019
  args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
790
1020
  returns: v.null(),
791
- handler: async (ctx, { dimension, value, dailyNanos, lifetimeNanos }) => {
1021
+ handler: async (ctx, { dimension, value, dailyNanos, monthlyNanos, lifetimeNanos }) => {
792
1022
  const bucket = await getOrCreateBucket(ctx, dimension, value);
793
1023
  const today = dayStamp();
1024
+ const month = monthStamp();
794
1025
  const curDaily =
795
1026
  bucket.bumpDayStamp === today ? bucket.dailyBumpNanos ?? 0 : 0;
1027
+ const curMonthly =
1028
+ bucket.bumpMonthStamp === month ? bucket.monthlyBumpNanos ?? 0 : 0;
796
1029
  await ctx.db.patch(bucket._id, {
797
1030
  bumpDayStamp: today,
798
1031
  dailyBumpNanos: curDaily + (dailyNanos ?? 0),
1032
+ bumpMonthStamp: month,
1033
+ monthlyBumpNanos: curMonthly + (monthlyNanos ?? 0),
799
1034
  lifetimeBumpNanos: (bucket.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
800
1035
  });
801
1036
  return null;
802
1037
  },
803
1038
  });
804
1039
 
1040
+ // Manually credit or debit a bucket (comp a user, correct an overcharge).
1041
+ // Negative deltaNanos = credit/refund, positive = extra charge. Adjusts the
1042
+ // live day/month/lifetime windows AND the durable usage history, and records an
1043
+ // audit row. Does not touch the global sharded total or reservations.
1044
+ export const adjustBucket = mutation({
1045
+ args: {
1046
+ dimension: v.string(),
1047
+ value: v.string(),
1048
+ deltaNanos: v.number(),
1049
+ tokens: v.optional(v.number()),
1050
+ reason: v.optional(v.string()),
1051
+ },
1052
+ returns: v.null(),
1053
+ handler: async (ctx, { dimension, value, deltaNanos, tokens, reason }) => {
1054
+ const b = await getOrCreateBucket(ctx, dimension, value);
1055
+ const today = dayStamp();
1056
+ const month = monthStamp();
1057
+ const dSame = b.dayStamp === today;
1058
+ const mSame = b.monthStamp === month;
1059
+ const dt = tokens ?? 0;
1060
+ await ctx.db.patch(b._id, {
1061
+ totalSpendNanos: Math.max(0, b.totalSpendNanos + deltaNanos),
1062
+ totalTokens: Math.max(0, b.totalTokens + dt),
1063
+ dayStamp: today,
1064
+ monthStamp: month,
1065
+ spendTodayNanos: Math.max(0, (dSame ? b.spendTodayNanos : 0) + deltaNanos),
1066
+ tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
1067
+ spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
1068
+ tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
1069
+ });
1070
+ await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
1071
+ await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
1072
+ await addUsage(ctx, dimension, value, "month", month, deltaNanos, dt, 0);
1073
+ return null;
1074
+ },
1075
+ });
1076
+
1077
+ export const listAdjustments = query({
1078
+ args: { dimension: v.string(), value: v.string(), limit: v.optional(v.number()) },
1079
+ handler: async (ctx, { dimension, value, limit }) =>
1080
+ ctx.db
1081
+ .query("adjustments")
1082
+ .withIndex("dim_value", (q) => q.eq("dimension", dimension).eq("value", value))
1083
+ .order("desc")
1084
+ .take(limit ?? 50),
1085
+ });
1086
+
1087
+ // Durable spend history for a bucket: per-day or per-month rows, newest first.
1088
+ // Survives request retention.
1089
+ export const usageHistory = query({
1090
+ args: {
1091
+ dimension: v.string(),
1092
+ value: v.string(),
1093
+ period: v.union(v.literal("day"), v.literal("month")),
1094
+ limit: v.optional(v.number()),
1095
+ },
1096
+ handler: async (ctx, { dimension, value, period, limit }) =>
1097
+ ctx.db
1098
+ .query("usage")
1099
+ .withIndex("bucket_period_stamp", (q) =>
1100
+ q.eq("dimension", dimension).eq("value", value).eq("period", period)
1101
+ )
1102
+ .order("desc")
1103
+ .take(limit ?? 90),
1104
+ });
1105
+
1106
+ // Deployment-wide default threshold for approaching-limit alerts (fraction of a
1107
+ // cap, e.g. 0.8). Buckets can override with their own warnAtPct.
1108
+ export const setAlertDefaults = mutation({
1109
+ args: { warnAtPct: v.optional(v.number()) },
1110
+ returns: v.null(),
1111
+ handler: async (ctx, { warnAtPct }) => {
1112
+ const existing = await getSettings(ctx);
1113
+ if (existing) await ctx.db.patch(existing._id, { defaultWarnAtPct: warnAtPct });
1114
+ else await ctx.db.insert("settings", { key: "singleton", defaultWarnAtPct: warnAtPct });
1115
+ return null;
1116
+ },
1117
+ });
1118
+
805
1119
  // Delete a bucket and (for the `user` dimension) all of that user's request
806
1120
  // rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
807
1121
  // self-reschedules so it never exceeds the per-transaction document limit.
@@ -817,7 +1131,10 @@ export const deleteBucket = mutation({
817
1131
  .query("requests")
818
1132
  .withIndex("userId", (q) => q.eq("userId", value))
819
1133
  .take(DELETE_BATCH);
820
- for (const r of rows) await ctx.db.delete(r._id);
1134
+ for (const r of rows) {
1135
+ await deleteRequestTags(ctx, r._id);
1136
+ await ctx.db.delete(r._id);
1137
+ }
821
1138
  if (rows.length === DELETE_BATCH) {
822
1139
  await ctx.scheduler.runAfter(0, api.lib.deleteBucket, {
823
1140
  dimension,
@@ -862,6 +1179,9 @@ export const getGlobalStatus = query({
862
1179
  enforcement: v.union(v.literal("hard"), v.literal("soft")),
863
1180
  spentTodayNanos: v.number(),
864
1181
  spentTotalNanos: v.number(),
1182
+ // deployment-wide config (surfaced for the admin dashboard)
1183
+ retentionMs: v.union(v.number(), v.null()),
1184
+ defaultWarnAtPct: v.union(v.number(), v.null()),
865
1185
  }),
866
1186
  handler: async (ctx) => {
867
1187
  const s = await ctx.db
@@ -874,6 +1194,8 @@ export const getGlobalStatus = query({
874
1194
  enforcement: s?.globalEnforcement ?? "hard",
875
1195
  spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
876
1196
  spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
1197
+ retentionMs: s?.retentionMs ?? null,
1198
+ defaultWarnAtPct: s?.defaultWarnAtPct ?? null,
877
1199
  };
878
1200
  },
879
1201
  });
@@ -953,12 +1275,18 @@ export const setPrice = mutation({
953
1275
  model: v.string(),
954
1276
  inputNanosPerMTok: v.number(),
955
1277
  outputNanosPerMTok: v.number(),
1278
+ // optional cache-read rate; if omitted, a default discount off input applies
1279
+ cachedNanosPerMTok: v.optional(v.number()),
956
1280
  },
957
1281
  returns: v.null(),
958
1282
  handler: async (ctx, args) => {
959
1283
  // Negative prices would make costOf return a negative cost, which folds
960
1284
  // into totals as a spend *refund* — pushing a user back under their cap.
961
- if (args.inputNanosPerMTok < 0 || args.outputNanosPerMTok < 0) {
1285
+ if (
1286
+ args.inputNanosPerMTok < 0 ||
1287
+ args.outputNanosPerMTok < 0 ||
1288
+ (args.cachedNanosPerMTok ?? 0) < 0
1289
+ ) {
962
1290
  throw new Error("Prices must be non-negative");
963
1291
  }
964
1292
  const existing = await ctx.db
@@ -978,7 +1306,10 @@ export const listPrices = query({
978
1306
  args: {},
979
1307
  handler: async (ctx) => {
980
1308
  const overrides = await ctx.db.query("prices").collect();
981
- const merged: Record<string, { input: number; output: number; overridden: boolean }> = {};
1309
+ const merged: Record<
1310
+ string,
1311
+ { input: number; output: number; cached?: number; overridden: boolean }
1312
+ > = {};
982
1313
  for (const [model, p] of Object.entries(DEFAULT_PRICES)) {
983
1314
  merged[model] = { ...p, overridden: false };
984
1315
  }
@@ -986,6 +1317,7 @@ export const listPrices = query({
986
1317
  merged[o.model] = {
987
1318
  input: o.inputNanosPerMTok,
988
1319
  output: o.outputNanosPerMTok,
1320
+ cached: o.cachedNanosPerMTok,
989
1321
  overridden: true,
990
1322
  };
991
1323
  }