@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/src/component/lib.ts
CHANGED
|
@@ -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)
|
|
162
|
-
//
|
|
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
|
-
}): {
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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 (
|
|
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<
|
|
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
|
}
|