@convex-dev/ai-budget 0.0.2-alpha.3 → 0.0.2-alpha.4
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 +49 -11
- package/dist/client/index.d.ts +130 -6
- package/dist/client/index.js +71 -10
- package/dist/component/_generated/component.d.ts +22 -24
- package/dist/component/lib.d.ts +57 -40
- package/dist/component/lib.js +264 -276
- package/dist/component/schema.d.ts +30 -53
- package/dist/component/schema.js +27 -38
- package/package.json +1 -1
- package/src/client/index.ts +114 -12
- package/src/component/_generated/component.ts +29 -32
- package/src/component/lib.test.ts +49 -15
- package/src/component/lib.ts +285 -285
- package/src/component/schema.ts +28 -39
package/src/component/lib.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type MutationCtx,
|
|
7
7
|
} from "./_generated/server";
|
|
8
8
|
import { api, internal, components } from "./_generated/api";
|
|
9
|
-
import { vMessage } from "./schema";
|
|
9
|
+
import { vMessage, vTag } from "./schema";
|
|
10
10
|
import type { Doc } from "./_generated/dataModel";
|
|
11
11
|
import { ShardedCounter } from "@convex-dev/sharded-counter";
|
|
12
12
|
|
|
@@ -19,6 +19,13 @@ import { ShardedCounter } from "@convex-dev/sharded-counter";
|
|
|
19
19
|
const NANOS_PER_DOLLAR = 1e9;
|
|
20
20
|
const fmtUsd = (nanos: number) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
|
|
21
21
|
|
|
22
|
+
// Built-in attribution dimensions. `user` and `action` are always populated
|
|
23
|
+
// from a request's userId/actionName; apps can add any other dimensions
|
|
24
|
+
// (team, project, customer, env, …) as tags. These two names are reserved —
|
|
25
|
+
// tags carrying them are ignored in favor of the first-class fields.
|
|
26
|
+
const USER_DIM = "user";
|
|
27
|
+
const ACTION_DIM = "action";
|
|
28
|
+
|
|
22
29
|
// Deployment-wide spend totals (nanodollars), sharded for high write throughput.
|
|
23
30
|
// Keyed "total" (lifetime) and "day:<UTC date>" (natural daily reset).
|
|
24
31
|
const globalSpend = new ShardedCounter(components.shardedCounter);
|
|
@@ -111,9 +118,48 @@ function estimateUsage(
|
|
|
111
118
|
};
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
121
|
+
// The full set of attribution buckets a request touches: the built-in `user`
|
|
122
|
+
// and `action` dimensions plus any extra tags. Reserved dimensions in `extra`
|
|
123
|
+
// are dropped (userId/actionName own them), and (dimension, value) pairs are
|
|
124
|
+
// de-duplicated. Used identically at reserve time (startRequest) and settle
|
|
125
|
+
// time (foldOne), so a request always settles exactly the buckets it reserved.
|
|
126
|
+
function requestBuckets(
|
|
127
|
+
userId: string,
|
|
128
|
+
actionName: string | undefined,
|
|
129
|
+
extra: { dimension: string; value: string }[] | undefined
|
|
130
|
+
): { dimension: string; value: string }[] {
|
|
131
|
+
const out = [{ dimension: USER_DIM, value: userId }];
|
|
132
|
+
if (actionName !== undefined)
|
|
133
|
+
out.push({ dimension: ACTION_DIM, value: actionName });
|
|
134
|
+
for (const t of extra ?? []) {
|
|
135
|
+
if (t.dimension === USER_DIM || t.dimension === ACTION_DIM) continue;
|
|
136
|
+
if (!t.dimension || !t.value) continue;
|
|
137
|
+
if (out.some((x) => x.dimension === t.dimension && x.value === t.value))
|
|
138
|
+
continue;
|
|
139
|
+
out.push({ dimension: t.dimension, value: t.value });
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Drop reserved/empty/duplicate tags from a caller-supplied list, leaving the
|
|
145
|
+
// "extra" dimensions stored on the request row.
|
|
146
|
+
function sanitizeExtraTags(
|
|
147
|
+
extra: { dimension: string; value: string }[] | undefined
|
|
148
|
+
): { dimension: string; value: string }[] {
|
|
149
|
+
const out: { dimension: string; value: string }[] = [];
|
|
150
|
+
for (const t of extra ?? []) {
|
|
151
|
+
if (t.dimension === USER_DIM || t.dimension === ACTION_DIM) continue;
|
|
152
|
+
if (!t.dimension || !t.value) continue;
|
|
153
|
+
if (out.some((x) => x.dimension === t.dimension && x.value === t.value))
|
|
154
|
+
continue;
|
|
155
|
+
out.push({ dimension: t.dimension, value: t.value });
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 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.
|
|
117
163
|
function evaluateCaps(o: {
|
|
118
164
|
label: string;
|
|
119
165
|
name: string;
|
|
@@ -179,31 +225,25 @@ const hasAnyCap = (e: {
|
|
|
179
225
|
e.dailyTokenLimit !== undefined ||
|
|
180
226
|
e.lifetimeTokenLimit !== undefined;
|
|
181
227
|
|
|
182
|
-
async function
|
|
183
|
-
|
|
184
|
-
.query("
|
|
185
|
-
.withIndex("
|
|
228
|
+
async function getBucketDoc(ctx: MutationCtx, dimension: string, value: string) {
|
|
229
|
+
return await ctx.db
|
|
230
|
+
.query("buckets")
|
|
231
|
+
.withIndex("dim_value", (q) =>
|
|
232
|
+
q.eq("dimension", dimension).eq("value", value)
|
|
233
|
+
)
|
|
186
234
|
.unique();
|
|
187
|
-
if (existing) return existing;
|
|
188
|
-
const id = await ctx.db.insert("users", {
|
|
189
|
-
userId,
|
|
190
|
-
totalSpendNanos: 0,
|
|
191
|
-
totalRequests: 0,
|
|
192
|
-
totalTokens: 0,
|
|
193
|
-
dayStamp: dayStamp(),
|
|
194
|
-
spendTodayNanos: 0,
|
|
195
|
-
});
|
|
196
|
-
return (await ctx.db.get(id))!;
|
|
197
235
|
}
|
|
198
236
|
|
|
199
|
-
async function
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
237
|
+
async function getOrCreateBucket(
|
|
238
|
+
ctx: MutationCtx,
|
|
239
|
+
dimension: string,
|
|
240
|
+
value: string
|
|
241
|
+
) {
|
|
242
|
+
const existing = await getBucketDoc(ctx, dimension, value);
|
|
204
243
|
if (existing) return existing;
|
|
205
|
-
const id = await ctx.db.insert("
|
|
206
|
-
|
|
244
|
+
const id = await ctx.db.insert("buckets", {
|
|
245
|
+
dimension,
|
|
246
|
+
value,
|
|
207
247
|
totalSpendNanos: 0,
|
|
208
248
|
totalRequests: 0,
|
|
209
249
|
totalTokens: 0,
|
|
@@ -237,13 +277,16 @@ export const startRequest = mutation({
|
|
|
237
277
|
args: {
|
|
238
278
|
userId: v.string(),
|
|
239
279
|
actionName: v.optional(v.string()),
|
|
280
|
+
// Extra attribution dimensions to bill/limit (team, customer, env, …).
|
|
281
|
+
// `user` and `action` are reserved (owned by userId/actionName).
|
|
282
|
+
tags: v.optional(v.array(vTag)),
|
|
240
283
|
model: v.string(),
|
|
241
284
|
messages: v.array(vMessage),
|
|
242
285
|
rerunOf: v.optional(v.id("requests")),
|
|
243
286
|
},
|
|
244
287
|
returns: vStartResult,
|
|
245
288
|
handler: async (ctx, args) => {
|
|
246
|
-
const
|
|
289
|
+
const extraTags = sanitizeExtraTags(args.tags);
|
|
247
290
|
// Record the blocked attempt and return a rejection (throwing would roll
|
|
248
291
|
// back the record). `persist` is false for the high-frequency-by-design
|
|
249
292
|
// rejections (rate limit, blocked user) that a client retries in a tight
|
|
@@ -252,7 +295,12 @@ export const startRequest = mutation({
|
|
|
252
295
|
const reject = async (code: string, reason: string, persist = true) => {
|
|
253
296
|
if (persist) {
|
|
254
297
|
await ctx.db.insert("requests", {
|
|
255
|
-
|
|
298
|
+
userId: args.userId,
|
|
299
|
+
actionName: args.actionName,
|
|
300
|
+
...(extraTags.length ? { tags: extraTags } : {}),
|
|
301
|
+
model: args.model,
|
|
302
|
+
messages: args.messages,
|
|
303
|
+
rerunOf: args.rerunOf,
|
|
256
304
|
status: "blocked" as const,
|
|
257
305
|
error: reason,
|
|
258
306
|
});
|
|
@@ -265,14 +313,11 @@ export const startRequest = mutation({
|
|
|
265
313
|
const est = estimateUsage(args.messages, priceInfo);
|
|
266
314
|
const warnings: string[] = [];
|
|
267
315
|
|
|
268
|
-
if (user.blocked) {
|
|
269
|
-
return reject("blocked", `User "${args.userId}" is blocked`, false);
|
|
270
|
-
}
|
|
271
316
|
// Model allow/deny policy (component-wide).
|
|
272
|
-
const
|
|
273
|
-
if (
|
|
274
|
-
const mode =
|
|
275
|
-
const list =
|
|
317
|
+
const settings = await getSettings(ctx);
|
|
318
|
+
if (settings) {
|
|
319
|
+
const mode = settings.modelMode ?? "open";
|
|
320
|
+
const list = settings.models ?? [];
|
|
276
321
|
if (mode === "allowlist" && !list.includes(args.model)) {
|
|
277
322
|
return reject(
|
|
278
323
|
"model_not_allowed",
|
|
@@ -283,9 +328,35 @@ export const startRequest = mutation({
|
|
|
283
328
|
return reject("model_denied", `Model "${args.model}" is denied`);
|
|
284
329
|
}
|
|
285
330
|
}
|
|
286
|
-
|
|
331
|
+
|
|
332
|
+
// Fetch/create every bucket this request is attributed to (user, action,
|
|
333
|
+
// and any extra tags). Each may carry its own budget.
|
|
334
|
+
const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
|
|
335
|
+
const buckets: Doc<"buckets">[] = [];
|
|
336
|
+
for (const t of bucketTags) {
|
|
337
|
+
buckets.push(await getOrCreateBucket(ctx, t.dimension, t.value));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// A hard block on ANY bucket rejects the request. The user dimension's block
|
|
341
|
+
// isn't persisted (retried in a loop); config-level blocks on other
|
|
342
|
+
// dimensions are rarer, so they persist for the audit log.
|
|
343
|
+
for (const b of buckets) {
|
|
344
|
+
if (b.blocked) {
|
|
345
|
+
const label = b.dimension === USER_DIM ? "User" : b.dimension;
|
|
346
|
+
return reject(
|
|
347
|
+
`${b.dimension}_blocked`,
|
|
348
|
+
`${label} "${b.value}" is blocked`,
|
|
349
|
+
b.dimension !== USER_DIM
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Rate limit is enforced on the `user` dimension (the requests table is
|
|
355
|
+
// indexed by userId, so the 60s window is a bounded index read).
|
|
356
|
+
const userBucket = buckets.find((b) => b.dimension === USER_DIM)!;
|
|
357
|
+
if (userBucket.requestsPerMinute !== undefined) {
|
|
287
358
|
// Bounded read: we only need to know whether non-blocked requests in the
|
|
288
|
-
// window have reached the limit. Reading limit+
|
|
359
|
+
// window have reached the limit. Reading limit+50 non-blocked rows is
|
|
289
360
|
// enough, and .take caps the scan even if the window is flooded. Blocked
|
|
290
361
|
// rows aren't persisted for rate-limit/blocked rejections (see reject),
|
|
291
362
|
// so the window stays small.
|
|
@@ -294,106 +365,74 @@ export const startRequest = mutation({
|
|
|
294
365
|
.withIndex("userId", (q) =>
|
|
295
366
|
q.eq("userId", args.userId).gt("_creationTime", Date.now() - 60_000)
|
|
296
367
|
)
|
|
297
|
-
.take(
|
|
368
|
+
.take(userBucket.requestsPerMinute + 50);
|
|
298
369
|
if (
|
|
299
370
|
recent.filter((r) => r.status !== "blocked").length >=
|
|
300
|
-
|
|
371
|
+
userBucket.requestsPerMinute
|
|
301
372
|
) {
|
|
302
373
|
return reject(
|
|
303
374
|
"rate_limit",
|
|
304
|
-
`Rate limit exceeded for "${args.userId}" (${
|
|
375
|
+
`Rate limit exceeded for "${args.userId}" (${userBucket.requestsPerMinute}/min)`,
|
|
305
376
|
false
|
|
306
377
|
);
|
|
307
378
|
}
|
|
308
379
|
}
|
|
380
|
+
|
|
309
381
|
// Committed + already-reserved in-flight usage + this estimate must fit
|
|
310
|
-
// under
|
|
311
|
-
// serializable isolation makes it a true atomic check-and-reserve
|
|
312
|
-
// enforcement turns a violation into a
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
spendToday: userSameDay ? user.spendTodayNanos : 0,
|
|
321
|
-
reservedSpendToday: userSameDay ? user.reservedTodayNanos ?? 0 : 0,
|
|
322
|
-
totalSpend: user.totalSpendNanos,
|
|
323
|
-
reservedSpendTotal: user.reservedTotalNanos ?? 0,
|
|
324
|
-
tokensToday: userSameDay ? user.tokensToday ?? 0 : 0,
|
|
325
|
-
reservedTokensToday: userSameDay ? user.reservedTodayTokens ?? 0 : 0,
|
|
326
|
-
totalTokens: user.totalTokens,
|
|
327
|
-
reservedTokensTotal: user.reservedTotalTokens ?? 0,
|
|
328
|
-
dailySpendLimitNanos: withBump(
|
|
329
|
-
user.dailySpendLimitNanos,
|
|
330
|
-
user.bumpDayStamp === today ? user.dailyBumpNanos : 0
|
|
331
|
-
),
|
|
332
|
-
lifetimeSpendLimitNanos: withBump(
|
|
333
|
-
user.lifetimeSpendLimitNanos,
|
|
334
|
-
user.lifetimeBumpNanos
|
|
335
|
-
),
|
|
336
|
-
dailyTokenLimit: user.dailyTokenLimit,
|
|
337
|
-
lifetimeTokenLimit: user.lifetimeTokenLimit,
|
|
338
|
-
});
|
|
339
|
-
if (userEval.hard) return reject(userEval.hard.code, userEval.hard.reason);
|
|
340
|
-
warnings.push(...userEval.warnings);
|
|
341
|
-
|
|
342
|
-
let action: Doc<"actions"> | null = null;
|
|
343
|
-
if (args.actionName !== undefined) {
|
|
344
|
-
action = await getOrCreateAction(ctx, args.actionName);
|
|
345
|
-
if (action.disabled) {
|
|
346
|
-
return reject("action_disabled", `Action "${action.name}" is disabled`);
|
|
347
|
-
}
|
|
348
|
-
const aSameDay = action.dayStamp === today;
|
|
349
|
-
const actionEval = evaluateCaps({
|
|
350
|
-
label: "action",
|
|
351
|
-
name: action.name,
|
|
352
|
-
enforcement: action.enforcement ?? "hard",
|
|
382
|
+
// under EACH bucket's cap. Decided and reserved in one transaction, so
|
|
383
|
+
// Convex's serializable isolation makes it a true atomic check-and-reserve
|
|
384
|
+
// across every capped bucket. Soft enforcement turns a violation into a
|
|
385
|
+
// warning instead of a block.
|
|
386
|
+
for (const b of buckets) {
|
|
387
|
+
const sameDay = b.dayStamp === today;
|
|
388
|
+
const ev = evaluateCaps({
|
|
389
|
+
label: b.dimension,
|
|
390
|
+
name: b.value,
|
|
391
|
+
enforcement: b.enforcement ?? "hard",
|
|
353
392
|
estCost: est.cost,
|
|
354
393
|
estTokens: est.tokens,
|
|
355
|
-
spendToday:
|
|
356
|
-
reservedSpendToday:
|
|
357
|
-
totalSpend:
|
|
358
|
-
reservedSpendTotal:
|
|
359
|
-
tokensToday:
|
|
360
|
-
reservedTokensToday:
|
|
361
|
-
totalTokens:
|
|
362
|
-
reservedTokensTotal:
|
|
394
|
+
spendToday: sameDay ? b.spendTodayNanos : 0,
|
|
395
|
+
reservedSpendToday: sameDay ? b.reservedTodayNanos ?? 0 : 0,
|
|
396
|
+
totalSpend: b.totalSpendNanos,
|
|
397
|
+
reservedSpendTotal: b.reservedTotalNanos ?? 0,
|
|
398
|
+
tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
|
|
399
|
+
reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
|
|
400
|
+
totalTokens: b.totalTokens,
|
|
401
|
+
reservedTokensTotal: b.reservedTotalTokens ?? 0,
|
|
363
402
|
dailySpendLimitNanos: withBump(
|
|
364
|
-
|
|
365
|
-
|
|
403
|
+
b.dailySpendLimitNanos,
|
|
404
|
+
b.bumpDayStamp === today ? b.dailyBumpNanos : 0
|
|
366
405
|
),
|
|
367
406
|
lifetimeSpendLimitNanos: withBump(
|
|
368
|
-
|
|
369
|
-
|
|
407
|
+
b.lifetimeSpendLimitNanos,
|
|
408
|
+
b.lifetimeBumpNanos
|
|
370
409
|
),
|
|
371
|
-
dailyTokenLimit:
|
|
372
|
-
lifetimeTokenLimit:
|
|
410
|
+
dailyTokenLimit: b.dailyTokenLimit,
|
|
411
|
+
lifetimeTokenLimit: b.lifetimeTokenLimit,
|
|
373
412
|
});
|
|
374
|
-
if (
|
|
375
|
-
warnings.push(...
|
|
413
|
+
if (ev.hard) return reject(ev.hard.code, ev.hard.reason);
|
|
414
|
+
warnings.push(...ev.warnings);
|
|
376
415
|
}
|
|
377
416
|
|
|
378
417
|
// Deployment-wide ("global") spend cap — same reserve-then-settle model and
|
|
379
|
-
// the SAME evaluateCaps logic as the per-
|
|
418
|
+
// the SAME evaluateCaps logic as the per-bucket caps above, so the guarantee
|
|
380
419
|
// statement is uniform: a request is admitted only if
|
|
381
420
|
// committed + reserved + estimate <= cap. The ONE difference is the holder:
|
|
382
|
-
// per-
|
|
421
|
+
// per-bucket caps reserve on a single row (an exact atomic
|
|
383
422
|
// check-and-reserve), while the global holder is a sharded counter for
|
|
384
423
|
// throughput — its committed total is read as an eventually-consistent sum
|
|
385
424
|
// with no cross-request reservation, so a hard global cap can overshoot by a
|
|
386
425
|
// bounded amount under burst. That's the deliberate exactness/throughput
|
|
387
426
|
// trade for a deployment-wide killswitch; it's the only approximate scope.
|
|
388
427
|
if (
|
|
389
|
-
|
|
390
|
-
(
|
|
391
|
-
|
|
428
|
+
settings &&
|
|
429
|
+
(settings.globalDailySpendLimitNanos !== undefined ||
|
|
430
|
+
settings.globalLifetimeSpendLimitNanos !== undefined)
|
|
392
431
|
) {
|
|
393
432
|
const globalEval = evaluateCaps({
|
|
394
433
|
label: "global",
|
|
395
434
|
name: "deployment",
|
|
396
|
-
enforcement:
|
|
435
|
+
enforcement: settings.globalEnforcement ?? "hard",
|
|
397
436
|
estCost: est.cost,
|
|
398
437
|
estTokens: est.tokens,
|
|
399
438
|
spendToday: await globalSpend.count(ctx, globalDayKey(today)),
|
|
@@ -405,49 +444,44 @@ export const startRequest = mutation({
|
|
|
405
444
|
totalTokens: 0,
|
|
406
445
|
reservedTokensTotal: 0,
|
|
407
446
|
dailySpendLimitNanos: withBump(
|
|
408
|
-
|
|
409
|
-
|
|
447
|
+
settings.globalDailySpendLimitNanos,
|
|
448
|
+
settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0
|
|
410
449
|
),
|
|
411
450
|
lifetimeSpendLimitNanos: withBump(
|
|
412
|
-
|
|
413
|
-
|
|
451
|
+
settings.globalLifetimeSpendLimitNanos,
|
|
452
|
+
settings.globalLifetimeBumpNanos
|
|
414
453
|
),
|
|
415
454
|
});
|
|
416
455
|
if (globalEval.hard) return reject(globalEval.hard.code, globalEval.hard.reason);
|
|
417
456
|
warnings.push(...globalEval.warnings);
|
|
418
457
|
}
|
|
419
458
|
|
|
420
|
-
// Passed — reserve, but ONLY on
|
|
421
|
-
//
|
|
422
|
-
// (e.g. all callers of one action); with no cap
|
|
423
|
-
// to consult. Totals are still accrued later,
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
reservedTodayNanos: (userSameDay ? user.reservedTodayNanos ?? 0 : 0) + est.cost,
|
|
430
|
-
reservedTotalNanos: (user.reservedTotalNanos ?? 0) + est.cost,
|
|
431
|
-
reservedTodayTokens: (userSameDay ? user.reservedTodayTokens ?? 0 : 0) + est.tokens,
|
|
432
|
-
reservedTotalTokens: (user.reservedTotalTokens ?? 0) + est.tokens,
|
|
433
|
-
pendingCount: (user.pendingCount ?? 0) + 1,
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
if (action && hasAnyCap(action)) {
|
|
437
|
-
const aSameDay = action.dayStamp === today;
|
|
438
|
-
await ctx.db.patch(action._id, {
|
|
459
|
+
// Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
|
|
460
|
+
// uncapped bucket's row here would serialize every request that shares it
|
|
461
|
+
// (e.g. all callers of one action, or every request in one env); with no cap
|
|
462
|
+
// there's no reserved amount to consult. Totals are still accrued later,
|
|
463
|
+
// asynchronously, in foldOne — for every bucket, capped or not.
|
|
464
|
+
for (const b of buckets) {
|
|
465
|
+
if (!hasAnyCap(b)) continue;
|
|
466
|
+
const sameDay = b.dayStamp === today;
|
|
467
|
+
await ctx.db.patch(b._id, {
|
|
439
468
|
dayStamp: today,
|
|
440
|
-
spendTodayNanos:
|
|
441
|
-
tokensToday:
|
|
442
|
-
reservedTodayNanos: (
|
|
443
|
-
reservedTotalNanos: (
|
|
444
|
-
reservedTodayTokens: (
|
|
445
|
-
reservedTotalTokens: (
|
|
446
|
-
pendingCount: (
|
|
469
|
+
spendTodayNanos: sameDay ? b.spendTodayNanos : 0,
|
|
470
|
+
tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
|
|
471
|
+
reservedTodayNanos: (sameDay ? b.reservedTodayNanos ?? 0 : 0) + est.cost,
|
|
472
|
+
reservedTotalNanos: (b.reservedTotalNanos ?? 0) + est.cost,
|
|
473
|
+
reservedTodayTokens: (sameDay ? b.reservedTodayTokens ?? 0 : 0) + est.tokens,
|
|
474
|
+
reservedTotalTokens: (b.reservedTotalTokens ?? 0) + est.tokens,
|
|
475
|
+
pendingCount: (b.pendingCount ?? 0) + 1,
|
|
447
476
|
});
|
|
448
477
|
}
|
|
449
478
|
const requestId = await ctx.db.insert("requests", {
|
|
450
|
-
|
|
479
|
+
userId: args.userId,
|
|
480
|
+
actionName: args.actionName,
|
|
481
|
+
...(extraTags.length ? { tags: extraTags } : {}),
|
|
482
|
+
model: args.model,
|
|
483
|
+
messages: args.messages,
|
|
484
|
+
rerunOf: args.rerunOf,
|
|
451
485
|
status: "pending",
|
|
452
486
|
estimatedNanos: est.cost,
|
|
453
487
|
estimatedTokens: est.tokens,
|
|
@@ -509,7 +543,7 @@ export const finishRequest = mutation({
|
|
|
509
543
|
settled: false,
|
|
510
544
|
});
|
|
511
545
|
|
|
512
|
-
// Fold into the (hot)
|
|
546
|
+
// Fold into the (hot) per-bucket counters in a separate mutation. If it
|
|
513
547
|
// exhausts retries under contention, the cron reconciler picks it up.
|
|
514
548
|
await ctx.scheduler.runAfter(0, internal.lib.foldTotals, {
|
|
515
549
|
requestId: args.requestId,
|
|
@@ -518,9 +552,9 @@ export const finishRequest = mutation({
|
|
|
518
552
|
},
|
|
519
553
|
});
|
|
520
554
|
|
|
521
|
-
// Fold one finished request into
|
|
522
|
-
// reservation. Idempotent: guarded by `settled` so the scheduler
|
|
523
|
-
// reconciler can never double-count.
|
|
555
|
+
// Fold one finished request into every attributed bucket's running totals,
|
|
556
|
+
// releasing its reservation. Idempotent: guarded by `settled` so the scheduler
|
|
557
|
+
// and the cron reconciler can never double-count.
|
|
524
558
|
async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
|
|
525
559
|
if (!req || req.settled !== false) return;
|
|
526
560
|
const actual = req.costNanos ?? 0;
|
|
@@ -529,47 +563,32 @@ async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
|
|
|
529
563
|
const estTokens = req.estimatedTokens ?? 0;
|
|
530
564
|
const today = dayStamp();
|
|
531
565
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
reservedTotalNanos: Math.max(0, (user.reservedTotalNanos ?? 0) - estCost),
|
|
543
|
-
reservedTodayTokens: Math.max(0, (uSameDay ? user.reservedTodayTokens ?? 0 : 0) - estTokens),
|
|
544
|
-
reservedTotalTokens: Math.max(0, (user.reservedTotalTokens ?? 0) - estTokens),
|
|
545
|
-
pendingCount: Math.max(0, (user.pendingCount ?? 0) - 1),
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
if (req.actionName !== undefined) {
|
|
549
|
-
const action = await getOrCreateAction(ctx, req.actionName);
|
|
550
|
-
const aSameDay = action.dayStamp === today;
|
|
551
|
-
await ctx.db.patch(action._id, {
|
|
552
|
-
totalSpendNanos: action.totalSpendNanos + actual,
|
|
553
|
-
totalRequests: action.totalRequests + 1,
|
|
554
|
-
totalTokens: action.totalTokens + tokens,
|
|
566
|
+
// Accrue into every attributed bucket (user, action, and each tag) — capped
|
|
567
|
+
// or not. Buckets that never held a reservation have their reserved fields
|
|
568
|
+
// clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
|
|
569
|
+
for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
|
|
570
|
+
const b = await getOrCreateBucket(ctx, t.dimension, t.value);
|
|
571
|
+
const sameDay = b.dayStamp === today;
|
|
572
|
+
await ctx.db.patch(b._id, {
|
|
573
|
+
totalSpendNanos: b.totalSpendNanos + actual,
|
|
574
|
+
totalRequests: b.totalRequests + 1,
|
|
575
|
+
totalTokens: b.totalTokens + tokens,
|
|
555
576
|
dayStamp: today,
|
|
556
|
-
spendTodayNanos: (
|
|
557
|
-
tokensToday: (
|
|
558
|
-
reservedTodayNanos: Math.max(0, (
|
|
559
|
-
reservedTotalNanos: Math.max(0, (
|
|
560
|
-
reservedTodayTokens: Math.max(0, (
|
|
561
|
-
reservedTotalTokens: Math.max(0, (
|
|
562
|
-
pendingCount: Math.max(0, (
|
|
577
|
+
spendTodayNanos: (sameDay ? b.spendTodayNanos : 0) + actual,
|
|
578
|
+
tokensToday: (sameDay ? b.tokensToday ?? 0 : 0) + tokens,
|
|
579
|
+
reservedTodayNanos: Math.max(0, (sameDay ? b.reservedTodayNanos ?? 0 : 0) - estCost),
|
|
580
|
+
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
|
|
581
|
+
reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
|
|
582
|
+
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
|
|
583
|
+
pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
|
|
563
584
|
});
|
|
564
585
|
}
|
|
586
|
+
|
|
565
587
|
// Deployment-wide totals via the sharded counter (only when a global cap is
|
|
566
588
|
// configured — otherwise skip the writes entirely). Distributed across shards,
|
|
567
589
|
// so this does not serialize on a single row.
|
|
568
590
|
if (actual > 0) {
|
|
569
|
-
const settings = await ctx
|
|
570
|
-
.query("settings")
|
|
571
|
-
.withIndex("key", (q) => q.eq("key", "singleton"))
|
|
572
|
-
.unique();
|
|
591
|
+
const settings = await getSettings(ctx);
|
|
573
592
|
if (
|
|
574
593
|
settings &&
|
|
575
594
|
(settings.globalDailySpendLimitNanos !== undefined ||
|
|
@@ -703,23 +722,45 @@ export const listRequests = query({
|
|
|
703
722
|
});
|
|
704
723
|
|
|
705
724
|
const ADMIN_LIST_CAP = 2000;
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
725
|
+
|
|
726
|
+
// List budget buckets, optionally filtered to one dimension ("user", "action",
|
|
727
|
+
// or any custom tag dimension). Today's spend is zeroed for stale day windows.
|
|
728
|
+
export const listBuckets = query({
|
|
729
|
+
args: { dimension: v.optional(v.string()) },
|
|
730
|
+
handler: async (ctx, args) => {
|
|
709
731
|
// Bounded to avoid an unbounded full-table scan on this reactive query.
|
|
710
|
-
// Paginate (ctx.db.query("
|
|
711
|
-
const
|
|
732
|
+
// Paginate (ctx.db.query("buckets").paginate(...)) for larger deployments.
|
|
733
|
+
const rows =
|
|
734
|
+
args.dimension !== undefined
|
|
735
|
+
? await ctx.db
|
|
736
|
+
.query("buckets")
|
|
737
|
+
.withIndex("dimension", (q) => q.eq("dimension", args.dimension!))
|
|
738
|
+
.take(ADMIN_LIST_CAP)
|
|
739
|
+
: await ctx.db.query("buckets").take(ADMIN_LIST_CAP);
|
|
712
740
|
const today = dayStamp();
|
|
713
|
-
return
|
|
714
|
-
...
|
|
715
|
-
spendTodayNanos:
|
|
741
|
+
return rows.map((b) => ({
|
|
742
|
+
...b,
|
|
743
|
+
spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
|
|
716
744
|
}));
|
|
717
745
|
},
|
|
718
746
|
});
|
|
719
747
|
|
|
720
|
-
export const
|
|
748
|
+
export const getBucket = query({
|
|
749
|
+
args: { dimension: v.string(), value: v.string() },
|
|
750
|
+
handler: async (ctx, args) => {
|
|
751
|
+
const b = await getBucketDoc(ctx as any, args.dimension, args.value);
|
|
752
|
+
if (!b) return null;
|
|
753
|
+
const today = dayStamp();
|
|
754
|
+
return { ...b, spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0 };
|
|
755
|
+
},
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// Set a bucket's limits/controls. `user` and `action` are just dimensions here;
|
|
759
|
+
// the client's ai.users / ai.actions namespaces are thin wrappers over this.
|
|
760
|
+
export const setBucketLimits = mutation({
|
|
721
761
|
args: {
|
|
722
|
-
|
|
762
|
+
dimension: v.string(),
|
|
763
|
+
value: v.string(),
|
|
723
764
|
requestsPerMinute: v.optional(v.number()),
|
|
724
765
|
dailySpendLimitNanos: v.optional(v.number()),
|
|
725
766
|
lifetimeSpendLimitNanos: v.optional(v.number()),
|
|
@@ -730,126 +771,67 @@ export const setLimits = mutation({
|
|
|
730
771
|
},
|
|
731
772
|
returns: v.null(),
|
|
732
773
|
handler: async (ctx, args) => {
|
|
733
|
-
const
|
|
734
|
-
const {
|
|
735
|
-
await ctx.db.patch(
|
|
774
|
+
const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
|
|
775
|
+
const { dimension: _d, value: _v, ...limits } = args;
|
|
776
|
+
await ctx.db.patch(bucket._id, limits);
|
|
736
777
|
return null;
|
|
737
778
|
},
|
|
738
779
|
});
|
|
739
780
|
|
|
740
|
-
// Delete a user and all their request rows (e.g. account deletion / GDPR).
|
|
741
|
-
// Deletes in bounded batches and self-reschedules so it never exceeds the
|
|
742
|
-
// per-transaction document limit — a user with millions of rows still deletes.
|
|
743
|
-
const DELETE_BATCH = 500;
|
|
744
|
-
export const deleteUser = mutation({
|
|
745
|
-
args: { userId: v.string() },
|
|
746
|
-
returns: v.object({ deletedThisBatch: v.number(), done: v.boolean() }),
|
|
747
|
-
handler: async (ctx, { userId }) => {
|
|
748
|
-
const rows = await ctx.db
|
|
749
|
-
.query("requests")
|
|
750
|
-
.withIndex("userId", (q) => q.eq("userId", userId))
|
|
751
|
-
.take(DELETE_BATCH);
|
|
752
|
-
for (const r of rows) await ctx.db.delete(r._id);
|
|
753
|
-
if (rows.length === DELETE_BATCH) {
|
|
754
|
-
// More to go — continue in a fresh transaction.
|
|
755
|
-
await ctx.scheduler.runAfter(0, api.lib.deleteUser, { userId });
|
|
756
|
-
return { deletedThisBatch: rows.length, done: false };
|
|
757
|
-
}
|
|
758
|
-
// Last batch: remove the user row itself.
|
|
759
|
-
const user = await ctx.db
|
|
760
|
-
.query("users")
|
|
761
|
-
.withIndex("userId", (q) => q.eq("userId", userId))
|
|
762
|
-
.unique();
|
|
763
|
-
if (user) await ctx.db.delete(user._id);
|
|
764
|
-
return { deletedThisBatch: rows.length + (user ? 1 : 0), done: true };
|
|
765
|
-
},
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
export const listActions = query({
|
|
769
|
-
args: {},
|
|
770
|
-
handler: async (ctx) => {
|
|
771
|
-
const actions = await ctx.db.query("actions").take(ADMIN_LIST_CAP);
|
|
772
|
-
const today = dayStamp();
|
|
773
|
-
return actions.map((a) => ({
|
|
774
|
-
...a,
|
|
775
|
-
spendTodayNanos: a.dayStamp === today ? a.spendTodayNanos : 0,
|
|
776
|
-
}));
|
|
777
|
-
},
|
|
778
|
-
});
|
|
779
|
-
|
|
780
781
|
const vBumpArgs = {
|
|
781
782
|
dailyNanos: v.optional(v.number()),
|
|
782
783
|
lifetimeNanos: v.optional(v.number()),
|
|
783
784
|
};
|
|
784
785
|
|
|
785
|
-
// One-time "approve another $X" bumps, added on top of
|
|
786
|
-
// changing it. Daily bumps apply to today only; lifetime bumps
|
|
787
|
-
export const
|
|
788
|
-
args: {
|
|
786
|
+
// 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.
|
|
788
|
+
export const bumpBucket = mutation({
|
|
789
|
+
args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
|
|
789
790
|
returns: v.null(),
|
|
790
|
-
handler: async (ctx, {
|
|
791
|
-
const
|
|
791
|
+
handler: async (ctx, { dimension, value, dailyNanos, lifetimeNanos }) => {
|
|
792
|
+
const bucket = await getOrCreateBucket(ctx, dimension, value);
|
|
792
793
|
const today = dayStamp();
|
|
793
|
-
const curDaily =
|
|
794
|
-
|
|
794
|
+
const curDaily =
|
|
795
|
+
bucket.bumpDayStamp === today ? bucket.dailyBumpNanos ?? 0 : 0;
|
|
796
|
+
await ctx.db.patch(bucket._id, {
|
|
795
797
|
bumpDayStamp: today,
|
|
796
798
|
dailyBumpNanos: curDaily + (dailyNanos ?? 0),
|
|
797
|
-
lifetimeBumpNanos: (
|
|
799
|
+
lifetimeBumpNanos: (bucket.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
|
|
798
800
|
});
|
|
799
801
|
return null;
|
|
800
802
|
},
|
|
801
803
|
});
|
|
802
804
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
if (
|
|
832
|
-
|
|
833
|
-
return null;
|
|
834
|
-
},
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
export const setActionLimits = mutation({
|
|
838
|
-
args: {
|
|
839
|
-
name: v.string(),
|
|
840
|
-
dailySpendLimitNanos: v.optional(v.number()),
|
|
841
|
-
lifetimeSpendLimitNanos: v.optional(v.number()),
|
|
842
|
-
dailyTokenLimit: v.optional(v.number()),
|
|
843
|
-
lifetimeTokenLimit: v.optional(v.number()),
|
|
844
|
-
enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
|
|
845
|
-
disabled: v.optional(v.boolean()),
|
|
846
|
-
},
|
|
847
|
-
returns: v.null(),
|
|
848
|
-
handler: async (ctx, args) => {
|
|
849
|
-
const action = await getOrCreateAction(ctx, args.name);
|
|
850
|
-
const { name: _name, ...limits } = args;
|
|
851
|
-
await ctx.db.patch(action._id, limits);
|
|
852
|
-
return null;
|
|
805
|
+
// Delete a bucket and (for the `user` dimension) all of that user's request
|
|
806
|
+
// rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
|
|
807
|
+
// self-reschedules so it never exceeds the per-transaction document limit.
|
|
808
|
+
const DELETE_BATCH = 500;
|
|
809
|
+
export const deleteBucket = mutation({
|
|
810
|
+
args: { dimension: v.string(), value: v.string() },
|
|
811
|
+
returns: v.object({ deletedThisBatch: v.number(), done: v.boolean() }),
|
|
812
|
+
handler: async (ctx, { dimension, value }) => {
|
|
813
|
+
// Only the user dimension owns request rows (indexed by userId). Other
|
|
814
|
+
// dimensions just drop their budget-holder row.
|
|
815
|
+
if (dimension === USER_DIM) {
|
|
816
|
+
const rows = await ctx.db
|
|
817
|
+
.query("requests")
|
|
818
|
+
.withIndex("userId", (q) => q.eq("userId", value))
|
|
819
|
+
.take(DELETE_BATCH);
|
|
820
|
+
for (const r of rows) await ctx.db.delete(r._id);
|
|
821
|
+
if (rows.length === DELETE_BATCH) {
|
|
822
|
+
await ctx.scheduler.runAfter(0, api.lib.deleteBucket, {
|
|
823
|
+
dimension,
|
|
824
|
+
value,
|
|
825
|
+
});
|
|
826
|
+
return { deletedThisBatch: rows.length, done: false };
|
|
827
|
+
}
|
|
828
|
+
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
829
|
+
if (bucket) await ctx.db.delete(bucket._id);
|
|
830
|
+
return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
|
|
831
|
+
}
|
|
832
|
+
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
833
|
+
if (bucket) await ctx.db.delete(bucket._id);
|
|
834
|
+
return { deletedThisBatch: bucket ? 1 : 0, done: true };
|
|
853
835
|
},
|
|
854
836
|
});
|
|
855
837
|
|
|
@@ -920,6 +902,24 @@ export const setGlobalLimits = mutation({
|
|
|
920
902
|
},
|
|
921
903
|
});
|
|
922
904
|
|
|
905
|
+
export const bumpGlobal = mutation({
|
|
906
|
+
args: vBumpArgs,
|
|
907
|
+
returns: v.null(),
|
|
908
|
+
handler: async (ctx, { dailyNanos, lifetimeNanos }) => {
|
|
909
|
+
const today = dayStamp();
|
|
910
|
+
const s = await getSettings(ctx);
|
|
911
|
+
const curDaily = s?.globalBumpDayStamp === today ? s?.globalDailyBumpNanos ?? 0 : 0;
|
|
912
|
+
const patch = {
|
|
913
|
+
globalBumpDayStamp: today,
|
|
914
|
+
globalDailyBumpNanos: curDaily + (dailyNanos ?? 0),
|
|
915
|
+
globalLifetimeBumpNanos: (s?.globalLifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
|
|
916
|
+
};
|
|
917
|
+
if (s) await ctx.db.patch(s._id, patch);
|
|
918
|
+
else await ctx.db.insert("settings", { key: "singleton", ...patch });
|
|
919
|
+
return null;
|
|
920
|
+
},
|
|
921
|
+
});
|
|
922
|
+
|
|
923
923
|
export const setModelPolicy = mutation({
|
|
924
924
|
args: {
|
|
925
925
|
mode: v.union(
|