@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.
- package/README.md +98 -6
- package/dist/client/dashboard.d.ts +1 -0
- package/dist/client/dashboard.js +215 -0
- package/dist/client/index.d.ts +347 -44
- package/dist/client/index.js +222 -68
- package/dist/component/_generated/component.d.ts +34 -0
- package/dist/component/lib.d.ts +75 -0
- package/dist/component/lib.js +309 -32
- package/dist/component/schema.d.ts +76 -3
- package/dist/component/schema.js +56 -2
- package/package.json +1 -1
- package/src/client/dashboard.ts +215 -0
- package/src/client/index.ts +334 -132
- package/src/component/_generated/component.ts +56 -3
- package/src/component/lib.test.ts +118 -0
- package/src/component/lib.ts +380 -48
- package/src/component/schema.ts +59 -2
package/dist/component/lib.js
CHANGED
|
@@ -48,7 +48,12 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
|
|
|
48
48
|
// audit table — and the sensitive content in it — from growing without bound.
|
|
49
49
|
// Override per-deployment via setRetention.
|
|
50
50
|
const DEFAULT_RETENTION_MS = 60 * 60 * 1000; // 1 hour
|
|
51
|
-
const dayStamp = () => new Date().toISOString().slice(0, 10);
|
|
51
|
+
const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
|
|
52
|
+
const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
|
|
53
|
+
// Cached (prompt-cache-read) input tokens are billed far below the normal input
|
|
54
|
+
// rate. When a model's price has no explicit cachedNanosPerMTok, charge this
|
|
55
|
+
// fraction of its input rate (providers commonly discount ~90%).
|
|
56
|
+
const CACHE_DISCOUNT = 0.1;
|
|
52
57
|
// Conservative fallback for any model not in the price table: the max of every
|
|
53
58
|
// known price dimension. Falling back to 0 would be fail-open — an unpriced
|
|
54
59
|
// model would reserve 0, pass every cap, and log 0¢ while the AI Gateway still
|
|
@@ -68,17 +73,62 @@ async function getPrice(ctx, model) {
|
|
|
68
73
|
return {
|
|
69
74
|
input: override.inputNanosPerMTok,
|
|
70
75
|
output: override.outputNanosPerMTok,
|
|
76
|
+
cached: override.cachedNanosPerMTok,
|
|
71
77
|
known: true,
|
|
72
78
|
};
|
|
73
79
|
}
|
|
74
80
|
const known = DEFAULT_PRICES[model];
|
|
75
81
|
if (known)
|
|
76
|
-
return { ...known, known: true };
|
|
77
|
-
return { ...CONSERVATIVE_PRICE, known: false };
|
|
82
|
+
return { ...known, cached: undefined, known: true };
|
|
83
|
+
return { ...CONSERVATIVE_PRICE, cached: undefined, known: false };
|
|
78
84
|
}
|
|
85
|
+
// The per-Mtok rate for cached (prompt-cache-read) tokens: an explicit override,
|
|
86
|
+
// else a discount off the input rate.
|
|
87
|
+
const cachedRate = (p) => p.cached ?? Math.round(p.input * CACHE_DISCOUNT);
|
|
79
88
|
// Integer nanodollars. Divide-before-multiply keeps the intermediate product
|
|
80
89
|
// within 2^53 even for large token counts × large per-Mtok prices.
|
|
81
90
|
const costOf = (inputTokens, outputTokens, price) => Math.round((inputTokens / 1e6) * price.input + (outputTokens / 1e6) * price.output);
|
|
91
|
+
// Cache-aware settle cost: the cached slice of the prompt is billed at the
|
|
92
|
+
// (discounted) cache rate, the rest of the prompt at the input rate, and
|
|
93
|
+
// completions at the output rate. `cachedTokens` is the gateway's real
|
|
94
|
+
// prompt-cache-read count (usage.inputTokenDetails.cacheReadTokens).
|
|
95
|
+
const settleCost = (promptTokens, cachedTokens, completionTokens, price) => {
|
|
96
|
+
const cached = Math.min(Math.max(0, cachedTokens), Math.max(0, promptTokens));
|
|
97
|
+
const fresh = Math.max(0, promptTokens - cached);
|
|
98
|
+
return Math.round((fresh / 1e6) * price.input +
|
|
99
|
+
(cached / 1e6) * cachedRate(price) +
|
|
100
|
+
(completionTokens / 1e6) * price.output);
|
|
101
|
+
};
|
|
102
|
+
// Upsert-add a settled amount into the durable per-(bucket, period) usage row.
|
|
103
|
+
// These rows are never swept by request retention, so spend history survives.
|
|
104
|
+
async function addUsage(ctx, dimension, value, period, stamp, spendNanos, tokens, requests) {
|
|
105
|
+
const existing = await ctx.db
|
|
106
|
+
.query("usage")
|
|
107
|
+
.withIndex("bucket_period_stamp", (q) => q
|
|
108
|
+
.eq("dimension", dimension)
|
|
109
|
+
.eq("value", value)
|
|
110
|
+
.eq("period", period)
|
|
111
|
+
.eq("stamp", stamp))
|
|
112
|
+
.unique();
|
|
113
|
+
if (existing) {
|
|
114
|
+
await ctx.db.patch(existing._id, {
|
|
115
|
+
spendNanos: existing.spendNanos + spendNanos,
|
|
116
|
+
tokens: existing.tokens + tokens,
|
|
117
|
+
requests: existing.requests + requests,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
await ctx.db.insert("usage", {
|
|
122
|
+
dimension,
|
|
123
|
+
value,
|
|
124
|
+
period,
|
|
125
|
+
stamp,
|
|
126
|
+
spendNanos,
|
|
127
|
+
tokens,
|
|
128
|
+
requests,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
82
132
|
// Up-front estimate of a request's cost and token count, reserved before the
|
|
83
133
|
// call so concurrent in-flight requests are visible to each other's caps.
|
|
84
134
|
function estimateUsage(messages, price) {
|
|
@@ -125,36 +175,56 @@ function sanitizeExtraTags(extra) {
|
|
|
125
175
|
return out;
|
|
126
176
|
}
|
|
127
177
|
// Evaluate a bucket's spend + token budgets against committed + reserved + this
|
|
128
|
-
// request's estimate. Returns a hard rejection (block)
|
|
129
|
-
//
|
|
178
|
+
// request's estimate. Returns a hard rejection (block), soft warnings (allow),
|
|
179
|
+
// and threshold notices (approaching a cap — see warnAtPct). Each window (daily,
|
|
180
|
+
// monthly, lifetime) × kind (spend, token) is one check.
|
|
130
181
|
function evaluateCaps(o) {
|
|
182
|
+
// { code, projected usage (incl. this estimate), cap, human window label,
|
|
183
|
+
// whether it's a money cap (formatted as $), spend? for notices }
|
|
184
|
+
const checks = [
|
|
185
|
+
{ w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost, cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
|
|
186
|
+
{ w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost, cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
|
|
187
|
+
{ w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost, cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
|
|
188
|
+
{ w: "daily_token_limit", used: o.tokensToday + o.reservedTokensToday + o.estTokens, cap: o.dailyTokenLimit, label: "daily token limit", money: false },
|
|
189
|
+
{ w: "monthly_token_limit", used: o.tokensThisMonth + o.reservedTokensMonth + o.estTokens, cap: o.monthlyTokenLimit, label: "monthly token limit", money: false },
|
|
190
|
+
{ w: "lifetime_token_limit", used: o.totalTokens + o.reservedTokensTotal + o.estTokens, cap: o.lifetimeTokenLimit, label: "lifetime token limit", money: false },
|
|
191
|
+
];
|
|
131
192
|
const violations = [];
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
193
|
+
const notices = [];
|
|
194
|
+
const pct = o.warnAtPct;
|
|
195
|
+
for (const c of checks) {
|
|
196
|
+
if (c.cap === undefined)
|
|
197
|
+
continue;
|
|
198
|
+
const capStr = c.money ? `${fmtUsd(c.cap)}` : `${c.cap} tokens`;
|
|
199
|
+
if (c.used > c.cap) {
|
|
200
|
+
violations.push({
|
|
201
|
+
code: `${o.label}_${c.w}`,
|
|
202
|
+
reason: `${cap(c.label)} reached for ${o.label} "${o.name}" (${capStr})`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
else if (pct !== undefined && pct > 0 && pct < 1 && c.used >= pct * c.cap) {
|
|
206
|
+
notices.push(`${o.label} "${o.name}" at ${Math.round((c.used / c.cap) * 100)}% of ${c.label} (${capStr})`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
145
209
|
if (violations.length === 0)
|
|
146
|
-
return { warnings: [] };
|
|
210
|
+
return { warnings: [], notices };
|
|
147
211
|
if (o.enforcement === "soft")
|
|
148
|
-
return { warnings: violations.map((v) => v.reason) };
|
|
149
|
-
return { hard: violations[0], warnings: [] };
|
|
212
|
+
return { warnings: violations.map((v) => v.reason), notices };
|
|
213
|
+
return { hard: violations[0], warnings: [], notices };
|
|
150
214
|
}
|
|
215
|
+
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
151
216
|
// A cap plus any one-time bump. Returns undefined when there's no base cap
|
|
152
217
|
// (a bump alone never creates a cap).
|
|
153
218
|
const withBump = (base, bump) => base === undefined ? undefined : base + (bump ?? 0);
|
|
154
219
|
const hasAnyCap = (e) => e.dailySpendLimitNanos !== undefined ||
|
|
220
|
+
e.monthlySpendLimitNanos !== undefined ||
|
|
155
221
|
e.lifetimeSpendLimitNanos !== undefined ||
|
|
156
222
|
e.dailyTokenLimit !== undefined ||
|
|
223
|
+
e.monthlyTokenLimit !== undefined ||
|
|
157
224
|
e.lifetimeTokenLimit !== undefined;
|
|
225
|
+
// A bucket needs a reservation row-write if it has any spend/token cap OR a
|
|
226
|
+
// concurrency cap (which reads pendingCount, incremented at reserve time).
|
|
227
|
+
const needsReserve = (e) => hasAnyCap(e) || e.maxConcurrent !== undefined;
|
|
158
228
|
async function getBucketDoc(ctx, dimension, value) {
|
|
159
229
|
return await ctx.db
|
|
160
230
|
.query("buckets")
|
|
@@ -182,10 +252,21 @@ async function getSettings(ctx) {
|
|
|
182
252
|
.withIndex("key", (q) => q.eq("key", "singleton"))
|
|
183
253
|
.unique();
|
|
184
254
|
}
|
|
255
|
+
// Delete the reverse-index rows for a request (called when the request row is
|
|
256
|
+
// deleted, so the tag index never outlives the request it points at).
|
|
257
|
+
async function deleteRequestTags(ctx, requestId) {
|
|
258
|
+
const tags = await ctx.db
|
|
259
|
+
.query("requestTags")
|
|
260
|
+
.withIndex("requestId", (q) => q.eq("requestId", requestId))
|
|
261
|
+
.collect();
|
|
262
|
+
for (const t of tags)
|
|
263
|
+
await ctx.db.delete(t._id);
|
|
264
|
+
}
|
|
185
265
|
const vStartResult = v.union(v.object({
|
|
186
266
|
allowed: v.literal(true),
|
|
187
267
|
requestId: v.id("requests"),
|
|
188
|
-
warnings: v.array(v.string()),
|
|
268
|
+
warnings: v.array(v.string()), // soft caps exceeded (allowed with a warning)
|
|
269
|
+
notices: v.array(v.string()), // approaching a cap (warnAtPct threshold)
|
|
189
270
|
}), v.object({
|
|
190
271
|
allowed: v.literal(false),
|
|
191
272
|
code: v.string(),
|
|
@@ -226,11 +307,14 @@ export const startRequest = mutation({
|
|
|
226
307
|
return { allowed: false, code, reason };
|
|
227
308
|
};
|
|
228
309
|
const today = dayStamp();
|
|
310
|
+
const month = monthStamp();
|
|
229
311
|
const priceInfo = await getPrice(ctx, args.model);
|
|
230
312
|
const est = estimateUsage(args.messages, priceInfo);
|
|
231
313
|
const warnings = [];
|
|
314
|
+
const notices = [];
|
|
232
315
|
// Model allow/deny policy (component-wide).
|
|
233
316
|
const settings = await getSettings(ctx);
|
|
317
|
+
const defaultWarnAtPct = settings?.defaultWarnAtPct;
|
|
234
318
|
if (settings) {
|
|
235
319
|
const mode = settings.modelMode ?? "open";
|
|
236
320
|
const list = settings.models ?? [];
|
|
@@ -257,6 +341,14 @@ export const startRequest = mutation({
|
|
|
257
341
|
return reject(`${b.dimension}_blocked`, `${label} "${b.value}" is blocked`, b.dimension !== USER_DIM);
|
|
258
342
|
}
|
|
259
343
|
}
|
|
344
|
+
// Concurrency cap: reject when a bucket already has maxConcurrent requests
|
|
345
|
+
// in flight (pendingCount). A transient limit like rate-limiting, so it
|
|
346
|
+
// isn't persisted (the caller retries once something settles).
|
|
347
|
+
for (const b of buckets) {
|
|
348
|
+
if (b.maxConcurrent !== undefined && (b.pendingCount ?? 0) >= b.maxConcurrent) {
|
|
349
|
+
return reject(`${b.dimension}_max_concurrent`, `Too many concurrent requests for ${b.dimension} "${b.value}" (max ${b.maxConcurrent})`, false);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
260
352
|
// Rate limit is enforced on the `user` dimension (the requests table is
|
|
261
353
|
// indexed by userId, so the 60s window is a bounded index read).
|
|
262
354
|
const userBucket = buckets.find((b) => b.dimension === USER_DIM);
|
|
@@ -282,28 +374,37 @@ export const startRequest = mutation({
|
|
|
282
374
|
// warning instead of a block.
|
|
283
375
|
for (const b of buckets) {
|
|
284
376
|
const sameDay = b.dayStamp === today;
|
|
377
|
+
const sameMonth = b.monthStamp === month;
|
|
285
378
|
const ev = evaluateCaps({
|
|
286
379
|
label: b.dimension,
|
|
287
380
|
name: b.value,
|
|
288
381
|
enforcement: b.enforcement ?? "hard",
|
|
382
|
+
warnAtPct: b.warnAtPct ?? defaultWarnAtPct,
|
|
289
383
|
estCost: est.cost,
|
|
290
384
|
estTokens: est.tokens,
|
|
291
385
|
spendToday: sameDay ? b.spendTodayNanos : 0,
|
|
292
386
|
reservedSpendToday: sameDay ? b.reservedTodayNanos ?? 0 : 0,
|
|
387
|
+
spendThisMonth: sameMonth ? b.spendThisMonthNanos ?? 0 : 0,
|
|
388
|
+
reservedSpendMonth: sameMonth ? b.reservedMonthNanos ?? 0 : 0,
|
|
293
389
|
totalSpend: b.totalSpendNanos,
|
|
294
390
|
reservedSpendTotal: b.reservedTotalNanos ?? 0,
|
|
295
391
|
tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
|
|
296
392
|
reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
|
|
393
|
+
tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
|
|
394
|
+
reservedTokensMonth: sameMonth ? b.reservedMonthTokens ?? 0 : 0,
|
|
297
395
|
totalTokens: b.totalTokens,
|
|
298
396
|
reservedTokensTotal: b.reservedTotalTokens ?? 0,
|
|
299
397
|
dailySpendLimitNanos: withBump(b.dailySpendLimitNanos, b.bumpDayStamp === today ? b.dailyBumpNanos : 0),
|
|
398
|
+
monthlySpendLimitNanos: withBump(b.monthlySpendLimitNanos, b.bumpMonthStamp === month ? b.monthlyBumpNanos : 0),
|
|
300
399
|
lifetimeSpendLimitNanos: withBump(b.lifetimeSpendLimitNanos, b.lifetimeBumpNanos),
|
|
301
400
|
dailyTokenLimit: b.dailyTokenLimit,
|
|
401
|
+
monthlyTokenLimit: b.monthlyTokenLimit,
|
|
302
402
|
lifetimeTokenLimit: b.lifetimeTokenLimit,
|
|
303
403
|
});
|
|
304
404
|
if (ev.hard)
|
|
305
405
|
return reject(ev.hard.code, ev.hard.reason);
|
|
306
406
|
warnings.push(...ev.warnings);
|
|
407
|
+
notices.push(...ev.notices);
|
|
307
408
|
}
|
|
308
409
|
// Deployment-wide ("global") spend cap — same reserve-then-settle model and
|
|
309
410
|
// the SAME evaluateCaps logic as the per-bucket caps above, so the guarantee
|
|
@@ -322,14 +423,19 @@ export const startRequest = mutation({
|
|
|
322
423
|
label: "global",
|
|
323
424
|
name: "deployment",
|
|
324
425
|
enforcement: settings.globalEnforcement ?? "hard",
|
|
426
|
+
warnAtPct: defaultWarnAtPct,
|
|
325
427
|
estCost: est.cost,
|
|
326
428
|
estTokens: est.tokens,
|
|
327
429
|
spendToday: await globalSpend.count(ctx, globalDayKey(today)),
|
|
328
430
|
reservedSpendToday: 0, // sharded holder: no cross-request reservation
|
|
431
|
+
spendThisMonth: 0, // global tracks daily + lifetime only
|
|
432
|
+
reservedSpendMonth: 0,
|
|
329
433
|
totalSpend: await globalSpend.count(ctx, GLOBAL_TOTAL),
|
|
330
434
|
reservedSpendTotal: 0,
|
|
331
435
|
tokensToday: 0,
|
|
332
436
|
reservedTokensToday: 0,
|
|
437
|
+
tokensThisMonth: 0,
|
|
438
|
+
reservedTokensMonth: 0,
|
|
333
439
|
totalTokens: 0,
|
|
334
440
|
reservedTokensTotal: 0,
|
|
335
441
|
dailySpendLimitNanos: withBump(settings.globalDailySpendLimitNanos, settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0),
|
|
@@ -338,6 +444,7 @@ export const startRequest = mutation({
|
|
|
338
444
|
if (globalEval.hard)
|
|
339
445
|
return reject(globalEval.hard.code, globalEval.hard.reason);
|
|
340
446
|
warnings.push(...globalEval.warnings);
|
|
447
|
+
notices.push(...globalEval.notices);
|
|
341
448
|
}
|
|
342
449
|
// Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
|
|
343
450
|
// uncapped bucket's row here would serialize every request that shares it
|
|
@@ -345,16 +452,22 @@ export const startRequest = mutation({
|
|
|
345
452
|
// there's no reserved amount to consult. Totals are still accrued later,
|
|
346
453
|
// asynchronously, in foldOne — for every bucket, capped or not.
|
|
347
454
|
for (const b of buckets) {
|
|
348
|
-
if (!
|
|
455
|
+
if (!needsReserve(b))
|
|
349
456
|
continue;
|
|
350
457
|
const sameDay = b.dayStamp === today;
|
|
458
|
+
const sameMonth = b.monthStamp === month;
|
|
351
459
|
await ctx.db.patch(b._id, {
|
|
352
460
|
dayStamp: today,
|
|
461
|
+
monthStamp: month,
|
|
353
462
|
spendTodayNanos: sameDay ? b.spendTodayNanos : 0,
|
|
354
463
|
tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
|
|
464
|
+
spendThisMonthNanos: sameMonth ? b.spendThisMonthNanos ?? 0 : 0,
|
|
465
|
+
tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
|
|
355
466
|
reservedTodayNanos: (sameDay ? b.reservedTodayNanos ?? 0 : 0) + est.cost,
|
|
467
|
+
reservedMonthNanos: (sameMonth ? b.reservedMonthNanos ?? 0 : 0) + est.cost,
|
|
356
468
|
reservedTotalNanos: (b.reservedTotalNanos ?? 0) + est.cost,
|
|
357
469
|
reservedTodayTokens: (sameDay ? b.reservedTodayTokens ?? 0 : 0) + est.tokens,
|
|
470
|
+
reservedMonthTokens: (sameMonth ? b.reservedMonthTokens ?? 0 : 0) + est.tokens,
|
|
358
471
|
reservedTotalTokens: (b.reservedTotalTokens ?? 0) + est.tokens,
|
|
359
472
|
pendingCount: (b.pendingCount ?? 0) + 1,
|
|
360
473
|
});
|
|
@@ -372,7 +485,16 @@ export const startRequest = mutation({
|
|
|
372
485
|
...(priceInfo.known ? {} : { unpricedModel: true }),
|
|
373
486
|
...(warnings.length > 0 ? { overBudget: true } : {}),
|
|
374
487
|
});
|
|
375
|
-
|
|
488
|
+
// Reverse index so the request log can be filtered by any extra tag
|
|
489
|
+
// dimension (user/action are already indexed on the requests table).
|
|
490
|
+
for (const t of extraTags) {
|
|
491
|
+
await ctx.db.insert("requestTags", {
|
|
492
|
+
dimension: t.dimension,
|
|
493
|
+
value: t.value,
|
|
494
|
+
requestId,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
return { allowed: true, requestId, warnings, notices };
|
|
376
498
|
},
|
|
377
499
|
});
|
|
378
500
|
export const finishRequest = mutation({
|
|
@@ -383,6 +505,10 @@ export const finishRequest = mutation({
|
|
|
383
505
|
promptTokens: v.optional(v.number()),
|
|
384
506
|
completionTokens: v.optional(v.number()),
|
|
385
507
|
cachedTokens: v.optional(v.number()),
|
|
508
|
+
// Authoritative cost from the gateway, if it ever reports one. When present
|
|
509
|
+
// it's recorded verbatim (no token-based estimate); when absent we price
|
|
510
|
+
// from tokens (cache-aware). Wired now so adopting a real cost is one line.
|
|
511
|
+
costNanos: v.optional(v.number()),
|
|
386
512
|
latencyMs: v.optional(v.number()),
|
|
387
513
|
},
|
|
388
514
|
returns: v.object({ costNanos: v.number() }),
|
|
@@ -405,7 +531,11 @@ export const finishRequest = mutation({
|
|
|
405
531
|
const promptTokens = Math.max(0, args.promptTokens ?? 0);
|
|
406
532
|
const completionTokens = Math.max(0, args.completionTokens ?? 0);
|
|
407
533
|
const cachedTokens = Math.min(promptTokens, Math.max(0, args.cachedTokens ?? 0));
|
|
408
|
-
|
|
534
|
+
// Prefer an authoritative gateway cost when supplied; otherwise price from
|
|
535
|
+
// tokens, discounting the cached (prompt-cache-read) slice of the prompt.
|
|
536
|
+
const costNanos = args.costNanos !== undefined && args.costNanos >= 0
|
|
537
|
+
? Math.round(args.costNanos)
|
|
538
|
+
: settleCost(promptTokens, cachedTokens, completionTokens, await getPrice(ctx, request.model));
|
|
409
539
|
// Durable write to the request's OWN row only — uncontended, so it always
|
|
410
540
|
// lands. `settled: false` hands it to the fold step; the row is never left
|
|
411
541
|
// orphaned in "pending" even if the totals update below fails and retries.
|
|
@@ -439,25 +569,36 @@ async function foldOne(ctx, req) {
|
|
|
439
569
|
const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
|
|
440
570
|
const estTokens = req.estimatedTokens ?? 0;
|
|
441
571
|
const today = dayStamp();
|
|
572
|
+
const month = monthStamp();
|
|
442
573
|
// Accrue into every attributed bucket (user, action, and each tag) — capped
|
|
443
574
|
// or not. Buckets that never held a reservation have their reserved fields
|
|
444
575
|
// clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
|
|
576
|
+
// Also write the durable per-(bucket, day/month) usage rows that survive
|
|
577
|
+
// request retention, so spend history outlives the raw request log.
|
|
445
578
|
for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
|
|
446
579
|
const b = await getOrCreateBucket(ctx, t.dimension, t.value);
|
|
447
580
|
const sameDay = b.dayStamp === today;
|
|
581
|
+
const sameMonth = b.monthStamp === month;
|
|
448
582
|
await ctx.db.patch(b._id, {
|
|
449
583
|
totalSpendNanos: b.totalSpendNanos + actual,
|
|
450
584
|
totalRequests: b.totalRequests + 1,
|
|
451
585
|
totalTokens: b.totalTokens + tokens,
|
|
452
586
|
dayStamp: today,
|
|
587
|
+
monthStamp: month,
|
|
453
588
|
spendTodayNanos: (sameDay ? b.spendTodayNanos : 0) + actual,
|
|
454
589
|
tokensToday: (sameDay ? b.tokensToday ?? 0 : 0) + tokens,
|
|
590
|
+
spendThisMonthNanos: (sameMonth ? b.spendThisMonthNanos ?? 0 : 0) + actual,
|
|
591
|
+
tokensThisMonth: (sameMonth ? b.tokensThisMonth ?? 0 : 0) + tokens,
|
|
455
592
|
reservedTodayNanos: Math.max(0, (sameDay ? b.reservedTodayNanos ?? 0 : 0) - estCost),
|
|
593
|
+
reservedMonthNanos: Math.max(0, (sameMonth ? b.reservedMonthNanos ?? 0 : 0) - estCost),
|
|
456
594
|
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
|
|
457
595
|
reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
|
|
596
|
+
reservedMonthTokens: Math.max(0, (sameMonth ? b.reservedMonthTokens ?? 0 : 0) - estTokens),
|
|
458
597
|
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
|
|
459
598
|
pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
|
|
460
599
|
});
|
|
600
|
+
await addUsage(ctx, t.dimension, t.value, "day", today, actual, tokens, 1);
|
|
601
|
+
await addUsage(ctx, t.dimension, t.value, "month", month, actual, tokens, 1);
|
|
461
602
|
}
|
|
462
603
|
// Deployment-wide totals via the sharded counter (only when a global cap is
|
|
463
604
|
// configured — otherwise skip the writes entirely). Distributed across shards,
|
|
@@ -526,6 +667,7 @@ export const reconcile = internalMutation({
|
|
|
526
667
|
// Only rows that are done and accounted: folded (settled === true) or a
|
|
527
668
|
// blocked attempt (never needs folding). Never a pending/unfolded row.
|
|
528
669
|
if (req.settled === true || req.status === "blocked") {
|
|
670
|
+
await deleteRequestTags(ctx, req._id);
|
|
529
671
|
await ctx.db.delete(req._id);
|
|
530
672
|
purged++;
|
|
531
673
|
}
|
|
@@ -571,10 +713,19 @@ export const getRequest = query({
|
|
|
571
713
|
handler: async (ctx, args) => ctx.db.get(args.requestId),
|
|
572
714
|
});
|
|
573
715
|
export const listRequests = query({
|
|
574
|
-
args: {
|
|
716
|
+
args: {
|
|
717
|
+
userId: v.optional(v.string()),
|
|
718
|
+
// Filter by any attribution dimension (user/action indexed on the request;
|
|
719
|
+
// custom tag dimensions resolved via the requestTags reverse index).
|
|
720
|
+
dimension: v.optional(v.string()),
|
|
721
|
+
value: v.optional(v.string()),
|
|
722
|
+
limit: v.optional(v.number()),
|
|
723
|
+
},
|
|
575
724
|
handler: async (ctx, args) => {
|
|
576
725
|
const limit = args.limit ?? 50;
|
|
577
|
-
|
|
726
|
+
const dim = args.dimension;
|
|
727
|
+
const val = args.value ?? args.userId;
|
|
728
|
+
if (dim === undefined && args.userId !== undefined) {
|
|
578
729
|
const userId = args.userId;
|
|
579
730
|
return await ctx.db
|
|
580
731
|
.query("requests")
|
|
@@ -582,6 +733,30 @@ export const listRequests = query({
|
|
|
582
733
|
.order("desc")
|
|
583
734
|
.take(limit);
|
|
584
735
|
}
|
|
736
|
+
if (dim !== undefined && val !== undefined) {
|
|
737
|
+
if (dim === USER_DIM) {
|
|
738
|
+
return await ctx.db
|
|
739
|
+
.query("requests")
|
|
740
|
+
.withIndex("userId", (q) => q.eq("userId", val))
|
|
741
|
+
.order("desc")
|
|
742
|
+
.take(limit);
|
|
743
|
+
}
|
|
744
|
+
if (dim === ACTION_DIM) {
|
|
745
|
+
return await ctx.db
|
|
746
|
+
.query("requests")
|
|
747
|
+
.withIndex("actionName", (q) => q.eq("actionName", val))
|
|
748
|
+
.order("desc")
|
|
749
|
+
.take(limit);
|
|
750
|
+
}
|
|
751
|
+
// Custom tag dimension: walk the reverse index, then fetch each request.
|
|
752
|
+
const tagRows = await ctx.db
|
|
753
|
+
.query("requestTags")
|
|
754
|
+
.withIndex("dim_value", (q) => q.eq("dimension", dim).eq("value", val))
|
|
755
|
+
.order("desc")
|
|
756
|
+
.take(limit);
|
|
757
|
+
const rows = await Promise.all(tagRows.map((t) => ctx.db.get(t.requestId)));
|
|
758
|
+
return rows.filter((r) => r !== null);
|
|
759
|
+
}
|
|
585
760
|
return await ctx.db.query("requests").order("desc").take(limit);
|
|
586
761
|
},
|
|
587
762
|
});
|
|
@@ -600,9 +775,11 @@ export const listBuckets = query({
|
|
|
600
775
|
.take(ADMIN_LIST_CAP)
|
|
601
776
|
: await ctx.db.query("buckets").take(ADMIN_LIST_CAP);
|
|
602
777
|
const today = dayStamp();
|
|
778
|
+
const month = monthStamp();
|
|
603
779
|
return rows.map((b) => ({
|
|
604
780
|
...b,
|
|
605
781
|
spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
|
|
782
|
+
spendThisMonthNanos: b.monthStamp === month ? b.spendThisMonthNanos ?? 0 : 0,
|
|
606
783
|
}));
|
|
607
784
|
},
|
|
608
785
|
});
|
|
@@ -613,7 +790,12 @@ export const getBucket = query({
|
|
|
613
790
|
if (!b)
|
|
614
791
|
return null;
|
|
615
792
|
const today = dayStamp();
|
|
616
|
-
|
|
793
|
+
const month = monthStamp();
|
|
794
|
+
return {
|
|
795
|
+
...b,
|
|
796
|
+
spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
|
|
797
|
+
spendThisMonthNanos: b.monthStamp === month ? b.spendThisMonthNanos ?? 0 : 0,
|
|
798
|
+
};
|
|
617
799
|
},
|
|
618
800
|
});
|
|
619
801
|
// Set a bucket's limits/controls. `user` and `action` are just dimensions here;
|
|
@@ -623,10 +805,14 @@ export const setBucketLimits = mutation({
|
|
|
623
805
|
dimension: v.string(),
|
|
624
806
|
value: v.string(),
|
|
625
807
|
requestsPerMinute: v.optional(v.number()),
|
|
808
|
+
maxConcurrent: v.optional(v.number()),
|
|
626
809
|
dailySpendLimitNanos: v.optional(v.number()),
|
|
810
|
+
monthlySpendLimitNanos: v.optional(v.number()),
|
|
627
811
|
lifetimeSpendLimitNanos: v.optional(v.number()),
|
|
628
812
|
dailyTokenLimit: v.optional(v.number()),
|
|
813
|
+
monthlyTokenLimit: v.optional(v.number()),
|
|
629
814
|
lifetimeTokenLimit: v.optional(v.number()),
|
|
815
|
+
warnAtPct: v.optional(v.number()),
|
|
630
816
|
enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
|
|
631
817
|
blocked: v.optional(v.boolean()),
|
|
632
818
|
},
|
|
@@ -640,25 +826,104 @@ export const setBucketLimits = mutation({
|
|
|
640
826
|
});
|
|
641
827
|
const vBumpArgs = {
|
|
642
828
|
dailyNanos: v.optional(v.number()),
|
|
829
|
+
monthlyNanos: v.optional(v.number()),
|
|
643
830
|
lifetimeNanos: v.optional(v.number()),
|
|
644
831
|
};
|
|
645
832
|
// One-time "approve another $X" bumps, added on top of a bucket's standing cap
|
|
646
|
-
// without changing it. Daily bumps apply to
|
|
833
|
+
// without changing it. Daily/monthly bumps apply to the current window only;
|
|
834
|
+
// lifetime bumps persist.
|
|
647
835
|
export const bumpBucket = mutation({
|
|
648
836
|
args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
|
|
649
837
|
returns: v.null(),
|
|
650
|
-
handler: async (ctx, { dimension, value, dailyNanos, lifetimeNanos }) => {
|
|
838
|
+
handler: async (ctx, { dimension, value, dailyNanos, monthlyNanos, lifetimeNanos }) => {
|
|
651
839
|
const bucket = await getOrCreateBucket(ctx, dimension, value);
|
|
652
840
|
const today = dayStamp();
|
|
841
|
+
const month = monthStamp();
|
|
653
842
|
const curDaily = bucket.bumpDayStamp === today ? bucket.dailyBumpNanos ?? 0 : 0;
|
|
843
|
+
const curMonthly = bucket.bumpMonthStamp === month ? bucket.monthlyBumpNanos ?? 0 : 0;
|
|
654
844
|
await ctx.db.patch(bucket._id, {
|
|
655
845
|
bumpDayStamp: today,
|
|
656
846
|
dailyBumpNanos: curDaily + (dailyNanos ?? 0),
|
|
847
|
+
bumpMonthStamp: month,
|
|
848
|
+
monthlyBumpNanos: curMonthly + (monthlyNanos ?? 0),
|
|
657
849
|
lifetimeBumpNanos: (bucket.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
|
|
658
850
|
});
|
|
659
851
|
return null;
|
|
660
852
|
},
|
|
661
853
|
});
|
|
854
|
+
// Manually credit or debit a bucket (comp a user, correct an overcharge).
|
|
855
|
+
// Negative deltaNanos = credit/refund, positive = extra charge. Adjusts the
|
|
856
|
+
// live day/month/lifetime windows AND the durable usage history, and records an
|
|
857
|
+
// audit row. Does not touch the global sharded total or reservations.
|
|
858
|
+
export const adjustBucket = mutation({
|
|
859
|
+
args: {
|
|
860
|
+
dimension: v.string(),
|
|
861
|
+
value: v.string(),
|
|
862
|
+
deltaNanos: v.number(),
|
|
863
|
+
tokens: v.optional(v.number()),
|
|
864
|
+
reason: v.optional(v.string()),
|
|
865
|
+
},
|
|
866
|
+
returns: v.null(),
|
|
867
|
+
handler: async (ctx, { dimension, value, deltaNanos, tokens, reason }) => {
|
|
868
|
+
const b = await getOrCreateBucket(ctx, dimension, value);
|
|
869
|
+
const today = dayStamp();
|
|
870
|
+
const month = monthStamp();
|
|
871
|
+
const dSame = b.dayStamp === today;
|
|
872
|
+
const mSame = b.monthStamp === month;
|
|
873
|
+
const dt = tokens ?? 0;
|
|
874
|
+
await ctx.db.patch(b._id, {
|
|
875
|
+
totalSpendNanos: Math.max(0, b.totalSpendNanos + deltaNanos),
|
|
876
|
+
totalTokens: Math.max(0, b.totalTokens + dt),
|
|
877
|
+
dayStamp: today,
|
|
878
|
+
monthStamp: month,
|
|
879
|
+
spendTodayNanos: Math.max(0, (dSame ? b.spendTodayNanos : 0) + deltaNanos),
|
|
880
|
+
tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
|
|
881
|
+
spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
|
|
882
|
+
tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
|
|
883
|
+
});
|
|
884
|
+
await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
|
|
885
|
+
await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
|
|
886
|
+
await addUsage(ctx, dimension, value, "month", month, deltaNanos, dt, 0);
|
|
887
|
+
return null;
|
|
888
|
+
},
|
|
889
|
+
});
|
|
890
|
+
export const listAdjustments = query({
|
|
891
|
+
args: { dimension: v.string(), value: v.string(), limit: v.optional(v.number()) },
|
|
892
|
+
handler: async (ctx, { dimension, value, limit }) => ctx.db
|
|
893
|
+
.query("adjustments")
|
|
894
|
+
.withIndex("dim_value", (q) => q.eq("dimension", dimension).eq("value", value))
|
|
895
|
+
.order("desc")
|
|
896
|
+
.take(limit ?? 50),
|
|
897
|
+
});
|
|
898
|
+
// Durable spend history for a bucket: per-day or per-month rows, newest first.
|
|
899
|
+
// Survives request retention.
|
|
900
|
+
export const usageHistory = query({
|
|
901
|
+
args: {
|
|
902
|
+
dimension: v.string(),
|
|
903
|
+
value: v.string(),
|
|
904
|
+
period: v.union(v.literal("day"), v.literal("month")),
|
|
905
|
+
limit: v.optional(v.number()),
|
|
906
|
+
},
|
|
907
|
+
handler: async (ctx, { dimension, value, period, limit }) => ctx.db
|
|
908
|
+
.query("usage")
|
|
909
|
+
.withIndex("bucket_period_stamp", (q) => q.eq("dimension", dimension).eq("value", value).eq("period", period))
|
|
910
|
+
.order("desc")
|
|
911
|
+
.take(limit ?? 90),
|
|
912
|
+
});
|
|
913
|
+
// Deployment-wide default threshold for approaching-limit alerts (fraction of a
|
|
914
|
+
// cap, e.g. 0.8). Buckets can override with their own warnAtPct.
|
|
915
|
+
export const setAlertDefaults = mutation({
|
|
916
|
+
args: { warnAtPct: v.optional(v.number()) },
|
|
917
|
+
returns: v.null(),
|
|
918
|
+
handler: async (ctx, { warnAtPct }) => {
|
|
919
|
+
const existing = await getSettings(ctx);
|
|
920
|
+
if (existing)
|
|
921
|
+
await ctx.db.patch(existing._id, { defaultWarnAtPct: warnAtPct });
|
|
922
|
+
else
|
|
923
|
+
await ctx.db.insert("settings", { key: "singleton", defaultWarnAtPct: warnAtPct });
|
|
924
|
+
return null;
|
|
925
|
+
},
|
|
926
|
+
});
|
|
662
927
|
// Delete a bucket and (for the `user` dimension) all of that user's request
|
|
663
928
|
// rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
|
|
664
929
|
// self-reschedules so it never exceeds the per-transaction document limit.
|
|
@@ -674,8 +939,10 @@ export const deleteBucket = mutation({
|
|
|
674
939
|
.query("requests")
|
|
675
940
|
.withIndex("userId", (q) => q.eq("userId", value))
|
|
676
941
|
.take(DELETE_BATCH);
|
|
677
|
-
for (const r of rows)
|
|
942
|
+
for (const r of rows) {
|
|
943
|
+
await deleteRequestTags(ctx, r._id);
|
|
678
944
|
await ctx.db.delete(r._id);
|
|
945
|
+
}
|
|
679
946
|
if (rows.length === DELETE_BATCH) {
|
|
680
947
|
await ctx.scheduler.runAfter(0, api.lib.deleteBucket, {
|
|
681
948
|
dimension,
|
|
@@ -716,6 +983,9 @@ export const getGlobalStatus = query({
|
|
|
716
983
|
enforcement: v.union(v.literal("hard"), v.literal("soft")),
|
|
717
984
|
spentTodayNanos: v.number(),
|
|
718
985
|
spentTotalNanos: v.number(),
|
|
986
|
+
// deployment-wide config (surfaced for the admin dashboard)
|
|
987
|
+
retentionMs: v.union(v.number(), v.null()),
|
|
988
|
+
defaultWarnAtPct: v.union(v.number(), v.null()),
|
|
719
989
|
}),
|
|
720
990
|
handler: async (ctx) => {
|
|
721
991
|
const s = await ctx.db
|
|
@@ -728,6 +998,8 @@ export const getGlobalStatus = query({
|
|
|
728
998
|
enforcement: s?.globalEnforcement ?? "hard",
|
|
729
999
|
spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
|
|
730
1000
|
spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
|
|
1001
|
+
retentionMs: s?.retentionMs ?? null,
|
|
1002
|
+
defaultWarnAtPct: s?.defaultWarnAtPct ?? null,
|
|
731
1003
|
};
|
|
732
1004
|
},
|
|
733
1005
|
});
|
|
@@ -803,12 +1075,16 @@ export const setPrice = mutation({
|
|
|
803
1075
|
model: v.string(),
|
|
804
1076
|
inputNanosPerMTok: v.number(),
|
|
805
1077
|
outputNanosPerMTok: v.number(),
|
|
1078
|
+
// optional cache-read rate; if omitted, a default discount off input applies
|
|
1079
|
+
cachedNanosPerMTok: v.optional(v.number()),
|
|
806
1080
|
},
|
|
807
1081
|
returns: v.null(),
|
|
808
1082
|
handler: async (ctx, args) => {
|
|
809
1083
|
// Negative prices would make costOf return a negative cost, which folds
|
|
810
1084
|
// into totals as a spend *refund* — pushing a user back under their cap.
|
|
811
|
-
if (args.inputNanosPerMTok < 0 ||
|
|
1085
|
+
if (args.inputNanosPerMTok < 0 ||
|
|
1086
|
+
args.outputNanosPerMTok < 0 ||
|
|
1087
|
+
(args.cachedNanosPerMTok ?? 0) < 0) {
|
|
812
1088
|
throw new Error("Prices must be non-negative");
|
|
813
1089
|
}
|
|
814
1090
|
const existing = await ctx.db
|
|
@@ -836,6 +1112,7 @@ export const listPrices = query({
|
|
|
836
1112
|
merged[o.model] = {
|
|
837
1113
|
input: o.inputNanosPerMTok,
|
|
838
1114
|
output: o.outputNanosPerMTok,
|
|
1115
|
+
cached: o.cachedNanosPerMTok,
|
|
839
1116
|
overridden: true,
|
|
840
1117
|
};
|
|
841
1118
|
}
|