@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/dist/component/lib.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { mutation, internalMutation, query, } from "./_generated/server";
|
|
3
3
|
import { api, internal, components } from "./_generated/api";
|
|
4
|
-
import { vMessage } from "./schema";
|
|
4
|
+
import { vMessage, vTag } from "./schema";
|
|
5
5
|
import { ShardedCounter } from "@convex-dev/sharded-counter";
|
|
6
6
|
// All money is integer **nanodollars** (1 USD = 1e9 nano). Integers avoid the
|
|
7
7
|
// rounding drift that floating-point cents accumulate over millions of
|
|
@@ -11,6 +11,12 @@ import { ShardedCounter } from "@convex-dev/sharded-counter";
|
|
|
11
11
|
// currency code alongside these amounts and convert here, at the one boundary.
|
|
12
12
|
const NANOS_PER_DOLLAR = 1e9;
|
|
13
13
|
const fmtUsd = (nanos) => `$${(nanos / NANOS_PER_DOLLAR).toFixed(4)}`;
|
|
14
|
+
// Built-in attribution dimensions. `user` and `action` are always populated
|
|
15
|
+
// from a request's userId/actionName; apps can add any other dimensions
|
|
16
|
+
// (team, project, customer, env, …) as tags. These two names are reserved —
|
|
17
|
+
// tags carrying them are ignored in favor of the first-class fields.
|
|
18
|
+
const USER_DIM = "user";
|
|
19
|
+
const ACTION_DIM = "action";
|
|
14
20
|
// Deployment-wide spend totals (nanodollars), sharded for high write throughput.
|
|
15
21
|
// Keyed "total" (lifetime) and "day:<UTC date>" (natural daily reset).
|
|
16
22
|
const globalSpend = new ShardedCounter(components.shardedCounter);
|
|
@@ -83,9 +89,44 @@ function estimateUsage(messages, price) {
|
|
|
83
89
|
tokens: inputTokens + ESTIMATED_OUTPUT_TOKENS,
|
|
84
90
|
};
|
|
85
91
|
}
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
92
|
+
// The full set of attribution buckets a request touches: the built-in `user`
|
|
93
|
+
// and `action` dimensions plus any extra tags. Reserved dimensions in `extra`
|
|
94
|
+
// are dropped (userId/actionName own them), and (dimension, value) pairs are
|
|
95
|
+
// de-duplicated. Used identically at reserve time (startRequest) and settle
|
|
96
|
+
// time (foldOne), so a request always settles exactly the buckets it reserved.
|
|
97
|
+
function requestBuckets(userId, actionName, extra) {
|
|
98
|
+
const out = [{ dimension: USER_DIM, value: userId }];
|
|
99
|
+
if (actionName !== undefined)
|
|
100
|
+
out.push({ dimension: ACTION_DIM, value: actionName });
|
|
101
|
+
for (const t of extra ?? []) {
|
|
102
|
+
if (t.dimension === USER_DIM || t.dimension === ACTION_DIM)
|
|
103
|
+
continue;
|
|
104
|
+
if (!t.dimension || !t.value)
|
|
105
|
+
continue;
|
|
106
|
+
if (out.some((x) => x.dimension === t.dimension && x.value === t.value))
|
|
107
|
+
continue;
|
|
108
|
+
out.push({ dimension: t.dimension, value: t.value });
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
// Drop reserved/empty/duplicate tags from a caller-supplied list, leaving the
|
|
113
|
+
// "extra" dimensions stored on the request row.
|
|
114
|
+
function sanitizeExtraTags(extra) {
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const t of extra ?? []) {
|
|
117
|
+
if (t.dimension === USER_DIM || t.dimension === ACTION_DIM)
|
|
118
|
+
continue;
|
|
119
|
+
if (!t.dimension || !t.value)
|
|
120
|
+
continue;
|
|
121
|
+
if (out.some((x) => x.dimension === t.dimension && x.value === t.value))
|
|
122
|
+
continue;
|
|
123
|
+
out.push({ dimension: t.dimension, value: t.value });
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
// Evaluate a bucket's spend + token budgets against committed + reserved + this
|
|
128
|
+
// request's estimate. Returns a hard rejection (block) or a list of soft
|
|
129
|
+
// warnings (allow), per the bucket's enforcement.
|
|
89
130
|
function evaluateCaps(o) {
|
|
90
131
|
const violations = [];
|
|
91
132
|
const push = (code, reason) => violations.push({ code: `${o.label}_${code}`, reason });
|
|
@@ -114,32 +155,19 @@ const hasAnyCap = (e) => e.dailySpendLimitNanos !== undefined ||
|
|
|
114
155
|
e.lifetimeSpendLimitNanos !== undefined ||
|
|
115
156
|
e.dailyTokenLimit !== undefined ||
|
|
116
157
|
e.lifetimeTokenLimit !== undefined;
|
|
117
|
-
async function
|
|
118
|
-
|
|
119
|
-
.query("
|
|
120
|
-
.withIndex("
|
|
158
|
+
async function getBucketDoc(ctx, dimension, value) {
|
|
159
|
+
return await ctx.db
|
|
160
|
+
.query("buckets")
|
|
161
|
+
.withIndex("dim_value", (q) => q.eq("dimension", dimension).eq("value", value))
|
|
121
162
|
.unique();
|
|
122
|
-
if (existing)
|
|
123
|
-
return existing;
|
|
124
|
-
const id = await ctx.db.insert("users", {
|
|
125
|
-
userId,
|
|
126
|
-
totalSpendNanos: 0,
|
|
127
|
-
totalRequests: 0,
|
|
128
|
-
totalTokens: 0,
|
|
129
|
-
dayStamp: dayStamp(),
|
|
130
|
-
spendTodayNanos: 0,
|
|
131
|
-
});
|
|
132
|
-
return (await ctx.db.get(id));
|
|
133
163
|
}
|
|
134
|
-
async function
|
|
135
|
-
const existing = await ctx
|
|
136
|
-
.query("actions")
|
|
137
|
-
.withIndex("name", (q) => q.eq("name", name))
|
|
138
|
-
.unique();
|
|
164
|
+
async function getOrCreateBucket(ctx, dimension, value) {
|
|
165
|
+
const existing = await getBucketDoc(ctx, dimension, value);
|
|
139
166
|
if (existing)
|
|
140
167
|
return existing;
|
|
141
|
-
const id = await ctx.db.insert("
|
|
142
|
-
|
|
168
|
+
const id = await ctx.db.insert("buckets", {
|
|
169
|
+
dimension,
|
|
170
|
+
value,
|
|
143
171
|
totalSpendNanos: 0,
|
|
144
172
|
totalRequests: 0,
|
|
145
173
|
totalTokens: 0,
|
|
@@ -167,13 +195,16 @@ export const startRequest = mutation({
|
|
|
167
195
|
args: {
|
|
168
196
|
userId: v.string(),
|
|
169
197
|
actionName: v.optional(v.string()),
|
|
198
|
+
// Extra attribution dimensions to bill/limit (team, customer, env, …).
|
|
199
|
+
// `user` and `action` are reserved (owned by userId/actionName).
|
|
200
|
+
tags: v.optional(v.array(vTag)),
|
|
170
201
|
model: v.string(),
|
|
171
202
|
messages: v.array(vMessage),
|
|
172
203
|
rerunOf: v.optional(v.id("requests")),
|
|
173
204
|
},
|
|
174
205
|
returns: vStartResult,
|
|
175
206
|
handler: async (ctx, args) => {
|
|
176
|
-
const
|
|
207
|
+
const extraTags = sanitizeExtraTags(args.tags);
|
|
177
208
|
// Record the blocked attempt and return a rejection (throwing would roll
|
|
178
209
|
// back the record). `persist` is false for the high-frequency-by-design
|
|
179
210
|
// rejections (rate limit, blocked user) that a client retries in a tight
|
|
@@ -182,7 +213,12 @@ export const startRequest = mutation({
|
|
|
182
213
|
const reject = async (code, reason, persist = true) => {
|
|
183
214
|
if (persist) {
|
|
184
215
|
await ctx.db.insert("requests", {
|
|
185
|
-
|
|
216
|
+
userId: args.userId,
|
|
217
|
+
actionName: args.actionName,
|
|
218
|
+
...(extraTags.length ? { tags: extraTags } : {}),
|
|
219
|
+
model: args.model,
|
|
220
|
+
messages: args.messages,
|
|
221
|
+
rerunOf: args.rerunOf,
|
|
186
222
|
status: "blocked",
|
|
187
223
|
error: reason,
|
|
188
224
|
});
|
|
@@ -193,14 +229,11 @@ export const startRequest = mutation({
|
|
|
193
229
|
const priceInfo = await getPrice(ctx, args.model);
|
|
194
230
|
const est = estimateUsage(args.messages, priceInfo);
|
|
195
231
|
const warnings = [];
|
|
196
|
-
if (user.blocked) {
|
|
197
|
-
return reject("blocked", `User "${args.userId}" is blocked`, false);
|
|
198
|
-
}
|
|
199
232
|
// Model allow/deny policy (component-wide).
|
|
200
|
-
const
|
|
201
|
-
if (
|
|
202
|
-
const mode =
|
|
203
|
-
const list =
|
|
233
|
+
const settings = await getSettings(ctx);
|
|
234
|
+
if (settings) {
|
|
235
|
+
const mode = settings.modelMode ?? "open";
|
|
236
|
+
const list = settings.models ?? [];
|
|
204
237
|
if (mode === "allowlist" && !list.includes(args.model)) {
|
|
205
238
|
return reject("model_not_allowed", `Model "${args.model}" is not on the allowlist`);
|
|
206
239
|
}
|
|
@@ -208,95 +241,87 @@ export const startRequest = mutation({
|
|
|
208
241
|
return reject("model_denied", `Model "${args.model}" is denied`);
|
|
209
242
|
}
|
|
210
243
|
}
|
|
211
|
-
|
|
244
|
+
// Fetch/create every bucket this request is attributed to (user, action,
|
|
245
|
+
// and any extra tags). Each may carry its own budget.
|
|
246
|
+
const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
|
|
247
|
+
const buckets = [];
|
|
248
|
+
for (const t of bucketTags) {
|
|
249
|
+
buckets.push(await getOrCreateBucket(ctx, t.dimension, t.value));
|
|
250
|
+
}
|
|
251
|
+
// A hard block on ANY bucket rejects the request. The user dimension's block
|
|
252
|
+
// isn't persisted (retried in a loop); config-level blocks on other
|
|
253
|
+
// dimensions are rarer, so they persist for the audit log.
|
|
254
|
+
for (const b of buckets) {
|
|
255
|
+
if (b.blocked) {
|
|
256
|
+
const label = b.dimension === USER_DIM ? "User" : b.dimension;
|
|
257
|
+
return reject(`${b.dimension}_blocked`, `${label} "${b.value}" is blocked`, b.dimension !== USER_DIM);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Rate limit is enforced on the `user` dimension (the requests table is
|
|
261
|
+
// indexed by userId, so the 60s window is a bounded index read).
|
|
262
|
+
const userBucket = buckets.find((b) => b.dimension === USER_DIM);
|
|
263
|
+
if (userBucket.requestsPerMinute !== undefined) {
|
|
212
264
|
// Bounded read: we only need to know whether non-blocked requests in the
|
|
213
|
-
// window have reached the limit. Reading limit+
|
|
265
|
+
// window have reached the limit. Reading limit+50 non-blocked rows is
|
|
214
266
|
// enough, and .take caps the scan even if the window is flooded. Blocked
|
|
215
267
|
// rows aren't persisted for rate-limit/blocked rejections (see reject),
|
|
216
268
|
// so the window stays small.
|
|
217
269
|
const recent = await ctx.db
|
|
218
270
|
.query("requests")
|
|
219
271
|
.withIndex("userId", (q) => q.eq("userId", args.userId).gt("_creationTime", Date.now() - 60_000))
|
|
220
|
-
.take(
|
|
272
|
+
.take(userBucket.requestsPerMinute + 50);
|
|
221
273
|
if (recent.filter((r) => r.status !== "blocked").length >=
|
|
222
|
-
|
|
223
|
-
return reject("rate_limit", `Rate limit exceeded for "${args.userId}" (${
|
|
274
|
+
userBucket.requestsPerMinute) {
|
|
275
|
+
return reject("rate_limit", `Rate limit exceeded for "${args.userId}" (${userBucket.requestsPerMinute}/min)`, false);
|
|
224
276
|
}
|
|
225
277
|
}
|
|
226
278
|
// Committed + already-reserved in-flight usage + this estimate must fit
|
|
227
|
-
// under
|
|
228
|
-
// serializable isolation makes it a true atomic check-and-reserve
|
|
229
|
-
// enforcement turns a violation into a
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
spendToday: userSameDay ? user.spendTodayNanos : 0,
|
|
238
|
-
reservedSpendToday: userSameDay ? user.reservedTodayNanos ?? 0 : 0,
|
|
239
|
-
totalSpend: user.totalSpendNanos,
|
|
240
|
-
reservedSpendTotal: user.reservedTotalNanos ?? 0,
|
|
241
|
-
tokensToday: userSameDay ? user.tokensToday ?? 0 : 0,
|
|
242
|
-
reservedTokensToday: userSameDay ? user.reservedTodayTokens ?? 0 : 0,
|
|
243
|
-
totalTokens: user.totalTokens,
|
|
244
|
-
reservedTokensTotal: user.reservedTotalTokens ?? 0,
|
|
245
|
-
dailySpendLimitNanos: withBump(user.dailySpendLimitNanos, user.bumpDayStamp === today ? user.dailyBumpNanos : 0),
|
|
246
|
-
lifetimeSpendLimitNanos: withBump(user.lifetimeSpendLimitNanos, user.lifetimeBumpNanos),
|
|
247
|
-
dailyTokenLimit: user.dailyTokenLimit,
|
|
248
|
-
lifetimeTokenLimit: user.lifetimeTokenLimit,
|
|
249
|
-
});
|
|
250
|
-
if (userEval.hard)
|
|
251
|
-
return reject(userEval.hard.code, userEval.hard.reason);
|
|
252
|
-
warnings.push(...userEval.warnings);
|
|
253
|
-
let action = null;
|
|
254
|
-
if (args.actionName !== undefined) {
|
|
255
|
-
action = await getOrCreateAction(ctx, args.actionName);
|
|
256
|
-
if (action.disabled) {
|
|
257
|
-
return reject("action_disabled", `Action "${action.name}" is disabled`);
|
|
258
|
-
}
|
|
259
|
-
const aSameDay = action.dayStamp === today;
|
|
260
|
-
const actionEval = evaluateCaps({
|
|
261
|
-
label: "action",
|
|
262
|
-
name: action.name,
|
|
263
|
-
enforcement: action.enforcement ?? "hard",
|
|
279
|
+
// under EACH bucket's cap. Decided and reserved in one transaction, so
|
|
280
|
+
// Convex's serializable isolation makes it a true atomic check-and-reserve
|
|
281
|
+
// across every capped bucket. Soft enforcement turns a violation into a
|
|
282
|
+
// warning instead of a block.
|
|
283
|
+
for (const b of buckets) {
|
|
284
|
+
const sameDay = b.dayStamp === today;
|
|
285
|
+
const ev = evaluateCaps({
|
|
286
|
+
label: b.dimension,
|
|
287
|
+
name: b.value,
|
|
288
|
+
enforcement: b.enforcement ?? "hard",
|
|
264
289
|
estCost: est.cost,
|
|
265
290
|
estTokens: est.tokens,
|
|
266
|
-
spendToday:
|
|
267
|
-
reservedSpendToday:
|
|
268
|
-
totalSpend:
|
|
269
|
-
reservedSpendTotal:
|
|
270
|
-
tokensToday:
|
|
271
|
-
reservedTokensToday:
|
|
272
|
-
totalTokens:
|
|
273
|
-
reservedTokensTotal:
|
|
274
|
-
dailySpendLimitNanos: withBump(
|
|
275
|
-
lifetimeSpendLimitNanos: withBump(
|
|
276
|
-
dailyTokenLimit:
|
|
277
|
-
lifetimeTokenLimit:
|
|
291
|
+
spendToday: sameDay ? b.spendTodayNanos : 0,
|
|
292
|
+
reservedSpendToday: sameDay ? b.reservedTodayNanos ?? 0 : 0,
|
|
293
|
+
totalSpend: b.totalSpendNanos,
|
|
294
|
+
reservedSpendTotal: b.reservedTotalNanos ?? 0,
|
|
295
|
+
tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
|
|
296
|
+
reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
|
|
297
|
+
totalTokens: b.totalTokens,
|
|
298
|
+
reservedTokensTotal: b.reservedTotalTokens ?? 0,
|
|
299
|
+
dailySpendLimitNanos: withBump(b.dailySpendLimitNanos, b.bumpDayStamp === today ? b.dailyBumpNanos : 0),
|
|
300
|
+
lifetimeSpendLimitNanos: withBump(b.lifetimeSpendLimitNanos, b.lifetimeBumpNanos),
|
|
301
|
+
dailyTokenLimit: b.dailyTokenLimit,
|
|
302
|
+
lifetimeTokenLimit: b.lifetimeTokenLimit,
|
|
278
303
|
});
|
|
279
|
-
if (
|
|
280
|
-
return reject(
|
|
281
|
-
warnings.push(...
|
|
304
|
+
if (ev.hard)
|
|
305
|
+
return reject(ev.hard.code, ev.hard.reason);
|
|
306
|
+
warnings.push(...ev.warnings);
|
|
282
307
|
}
|
|
283
308
|
// Deployment-wide ("global") spend cap — same reserve-then-settle model and
|
|
284
|
-
// the SAME evaluateCaps logic as the per-
|
|
309
|
+
// the SAME evaluateCaps logic as the per-bucket caps above, so the guarantee
|
|
285
310
|
// statement is uniform: a request is admitted only if
|
|
286
311
|
// committed + reserved + estimate <= cap. The ONE difference is the holder:
|
|
287
|
-
// per-
|
|
312
|
+
// per-bucket caps reserve on a single row (an exact atomic
|
|
288
313
|
// check-and-reserve), while the global holder is a sharded counter for
|
|
289
314
|
// throughput — its committed total is read as an eventually-consistent sum
|
|
290
315
|
// with no cross-request reservation, so a hard global cap can overshoot by a
|
|
291
316
|
// bounded amount under burst. That's the deliberate exactness/throughput
|
|
292
317
|
// trade for a deployment-wide killswitch; it's the only approximate scope.
|
|
293
|
-
if (
|
|
294
|
-
(
|
|
295
|
-
|
|
318
|
+
if (settings &&
|
|
319
|
+
(settings.globalDailySpendLimitNanos !== undefined ||
|
|
320
|
+
settings.globalLifetimeSpendLimitNanos !== undefined)) {
|
|
296
321
|
const globalEval = evaluateCaps({
|
|
297
322
|
label: "global",
|
|
298
323
|
name: "deployment",
|
|
299
|
-
enforcement:
|
|
324
|
+
enforcement: settings.globalEnforcement ?? "hard",
|
|
300
325
|
estCost: est.cost,
|
|
301
326
|
estTokens: est.tokens,
|
|
302
327
|
spendToday: await globalSpend.count(ctx, globalDayKey(today)),
|
|
@@ -307,44 +332,40 @@ export const startRequest = mutation({
|
|
|
307
332
|
reservedTokensToday: 0,
|
|
308
333
|
totalTokens: 0,
|
|
309
334
|
reservedTokensTotal: 0,
|
|
310
|
-
dailySpendLimitNanos: withBump(
|
|
311
|
-
lifetimeSpendLimitNanos: withBump(
|
|
335
|
+
dailySpendLimitNanos: withBump(settings.globalDailySpendLimitNanos, settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0),
|
|
336
|
+
lifetimeSpendLimitNanos: withBump(settings.globalLifetimeSpendLimitNanos, settings.globalLifetimeBumpNanos),
|
|
312
337
|
});
|
|
313
338
|
if (globalEval.hard)
|
|
314
339
|
return reject(globalEval.hard.code, globalEval.hard.reason);
|
|
315
340
|
warnings.push(...globalEval.warnings);
|
|
316
341
|
}
|
|
317
|
-
// Passed — reserve, but ONLY on
|
|
318
|
-
//
|
|
319
|
-
// (e.g. all callers of one action); with no cap
|
|
320
|
-
// to consult. Totals are still accrued later,
|
|
321
|
-
|
|
322
|
-
|
|
342
|
+
// Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
|
|
343
|
+
// uncapped bucket's row here would serialize every request that shares it
|
|
344
|
+
// (e.g. all callers of one action, or every request in one env); with no cap
|
|
345
|
+
// there's no reserved amount to consult. Totals are still accrued later,
|
|
346
|
+
// asynchronously, in foldOne — for every bucket, capped or not.
|
|
347
|
+
for (const b of buckets) {
|
|
348
|
+
if (!hasAnyCap(b))
|
|
349
|
+
continue;
|
|
350
|
+
const sameDay = b.dayStamp === today;
|
|
351
|
+
await ctx.db.patch(b._id, {
|
|
323
352
|
dayStamp: today,
|
|
324
|
-
spendTodayNanos:
|
|
325
|
-
tokensToday:
|
|
326
|
-
reservedTodayNanos: (
|
|
327
|
-
reservedTotalNanos: (
|
|
328
|
-
reservedTodayTokens: (
|
|
329
|
-
reservedTotalTokens: (
|
|
330
|
-
pendingCount: (
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
if (action && hasAnyCap(action)) {
|
|
334
|
-
const aSameDay = action.dayStamp === today;
|
|
335
|
-
await ctx.db.patch(action._id, {
|
|
336
|
-
dayStamp: today,
|
|
337
|
-
spendTodayNanos: aSameDay ? action.spendTodayNanos : 0,
|
|
338
|
-
tokensToday: aSameDay ? action.tokensToday ?? 0 : 0,
|
|
339
|
-
reservedTodayNanos: (aSameDay ? action.reservedTodayNanos ?? 0 : 0) + est.cost,
|
|
340
|
-
reservedTotalNanos: (action.reservedTotalNanos ?? 0) + est.cost,
|
|
341
|
-
reservedTodayTokens: (aSameDay ? action.reservedTodayTokens ?? 0 : 0) + est.tokens,
|
|
342
|
-
reservedTotalTokens: (action.reservedTotalTokens ?? 0) + est.tokens,
|
|
343
|
-
pendingCount: (action.pendingCount ?? 0) + 1,
|
|
353
|
+
spendTodayNanos: sameDay ? b.spendTodayNanos : 0,
|
|
354
|
+
tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
|
|
355
|
+
reservedTodayNanos: (sameDay ? b.reservedTodayNanos ?? 0 : 0) + est.cost,
|
|
356
|
+
reservedTotalNanos: (b.reservedTotalNanos ?? 0) + est.cost,
|
|
357
|
+
reservedTodayTokens: (sameDay ? b.reservedTodayTokens ?? 0 : 0) + est.tokens,
|
|
358
|
+
reservedTotalTokens: (b.reservedTotalTokens ?? 0) + est.tokens,
|
|
359
|
+
pendingCount: (b.pendingCount ?? 0) + 1,
|
|
344
360
|
});
|
|
345
361
|
}
|
|
346
362
|
const requestId = await ctx.db.insert("requests", {
|
|
347
|
-
|
|
363
|
+
userId: args.userId,
|
|
364
|
+
actionName: args.actionName,
|
|
365
|
+
...(extraTags.length ? { tags: extraTags } : {}),
|
|
366
|
+
model: args.model,
|
|
367
|
+
messages: args.messages,
|
|
368
|
+
rerunOf: args.rerunOf,
|
|
348
369
|
status: "pending",
|
|
349
370
|
estimatedNanos: est.cost,
|
|
350
371
|
estimatedTokens: est.tokens,
|
|
@@ -399,7 +420,7 @@ export const finishRequest = mutation({
|
|
|
399
420
|
latencyMs: args.latencyMs,
|
|
400
421
|
settled: false,
|
|
401
422
|
});
|
|
402
|
-
// Fold into the (hot)
|
|
423
|
+
// Fold into the (hot) per-bucket counters in a separate mutation. If it
|
|
403
424
|
// exhausts retries under contention, the cron reconciler picks it up.
|
|
404
425
|
await ctx.scheduler.runAfter(0, internal.lib.foldTotals, {
|
|
405
426
|
requestId: args.requestId,
|
|
@@ -407,9 +428,9 @@ export const finishRequest = mutation({
|
|
|
407
428
|
return { costNanos };
|
|
408
429
|
},
|
|
409
430
|
});
|
|
410
|
-
// Fold one finished request into
|
|
411
|
-
// reservation. Idempotent: guarded by `settled` so the scheduler
|
|
412
|
-
// reconciler can never double-count.
|
|
431
|
+
// Fold one finished request into every attributed bucket's running totals,
|
|
432
|
+
// releasing its reservation. Idempotent: guarded by `settled` so the scheduler
|
|
433
|
+
// and the cron reconciler can never double-count.
|
|
413
434
|
async function foldOne(ctx, req) {
|
|
414
435
|
if (!req || req.settled !== false)
|
|
415
436
|
return;
|
|
@@ -418,46 +439,31 @@ async function foldOne(ctx, req) {
|
|
|
418
439
|
const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
|
|
419
440
|
const estTokens = req.estimatedTokens ?? 0;
|
|
420
441
|
const today = dayStamp();
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
reservedTotalNanos: Math.max(0, (user.reservedTotalNanos ?? 0) - estCost),
|
|
432
|
-
reservedTodayTokens: Math.max(0, (uSameDay ? user.reservedTodayTokens ?? 0 : 0) - estTokens),
|
|
433
|
-
reservedTotalTokens: Math.max(0, (user.reservedTotalTokens ?? 0) - estTokens),
|
|
434
|
-
pendingCount: Math.max(0, (user.pendingCount ?? 0) - 1),
|
|
435
|
-
});
|
|
436
|
-
if (req.actionName !== undefined) {
|
|
437
|
-
const action = await getOrCreateAction(ctx, req.actionName);
|
|
438
|
-
const aSameDay = action.dayStamp === today;
|
|
439
|
-
await ctx.db.patch(action._id, {
|
|
440
|
-
totalSpendNanos: action.totalSpendNanos + actual,
|
|
441
|
-
totalRequests: action.totalRequests + 1,
|
|
442
|
-
totalTokens: action.totalTokens + tokens,
|
|
442
|
+
// Accrue into every attributed bucket (user, action, and each tag) — capped
|
|
443
|
+
// or not. Buckets that never held a reservation have their reserved fields
|
|
444
|
+
// clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
|
|
445
|
+
for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
|
|
446
|
+
const b = await getOrCreateBucket(ctx, t.dimension, t.value);
|
|
447
|
+
const sameDay = b.dayStamp === today;
|
|
448
|
+
await ctx.db.patch(b._id, {
|
|
449
|
+
totalSpendNanos: b.totalSpendNanos + actual,
|
|
450
|
+
totalRequests: b.totalRequests + 1,
|
|
451
|
+
totalTokens: b.totalTokens + tokens,
|
|
443
452
|
dayStamp: today,
|
|
444
|
-
spendTodayNanos: (
|
|
445
|
-
tokensToday: (
|
|
446
|
-
reservedTodayNanos: Math.max(0, (
|
|
447
|
-
reservedTotalNanos: Math.max(0, (
|
|
448
|
-
reservedTodayTokens: Math.max(0, (
|
|
449
|
-
reservedTotalTokens: Math.max(0, (
|
|
450
|
-
pendingCount: Math.max(0, (
|
|
453
|
+
spendTodayNanos: (sameDay ? b.spendTodayNanos : 0) + actual,
|
|
454
|
+
tokensToday: (sameDay ? b.tokensToday ?? 0 : 0) + tokens,
|
|
455
|
+
reservedTodayNanos: Math.max(0, (sameDay ? b.reservedTodayNanos ?? 0 : 0) - estCost),
|
|
456
|
+
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
|
|
457
|
+
reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
|
|
458
|
+
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
|
|
459
|
+
pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
|
|
451
460
|
});
|
|
452
461
|
}
|
|
453
462
|
// Deployment-wide totals via the sharded counter (only when a global cap is
|
|
454
463
|
// configured — otherwise skip the writes entirely). Distributed across shards,
|
|
455
464
|
// so this does not serialize on a single row.
|
|
456
465
|
if (actual > 0) {
|
|
457
|
-
const settings = await ctx
|
|
458
|
-
.query("settings")
|
|
459
|
-
.withIndex("key", (q) => q.eq("key", "singleton"))
|
|
460
|
-
.unique();
|
|
466
|
+
const settings = await getSettings(ctx);
|
|
461
467
|
if (settings &&
|
|
462
468
|
(settings.globalDailySpendLimitNanos !== undefined ||
|
|
463
469
|
settings.globalLifetimeSpendLimitNanos !== undefined)) {
|
|
@@ -580,22 +586,42 @@ export const listRequests = query({
|
|
|
580
586
|
},
|
|
581
587
|
});
|
|
582
588
|
const ADMIN_LIST_CAP = 2000;
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
589
|
+
// List budget buckets, optionally filtered to one dimension ("user", "action",
|
|
590
|
+
// or any custom tag dimension). Today's spend is zeroed for stale day windows.
|
|
591
|
+
export const listBuckets = query({
|
|
592
|
+
args: { dimension: v.optional(v.string()) },
|
|
593
|
+
handler: async (ctx, args) => {
|
|
586
594
|
// Bounded to avoid an unbounded full-table scan on this reactive query.
|
|
587
|
-
// Paginate (ctx.db.query("
|
|
588
|
-
const
|
|
595
|
+
// Paginate (ctx.db.query("buckets").paginate(...)) for larger deployments.
|
|
596
|
+
const rows = args.dimension !== undefined
|
|
597
|
+
? await ctx.db
|
|
598
|
+
.query("buckets")
|
|
599
|
+
.withIndex("dimension", (q) => q.eq("dimension", args.dimension))
|
|
600
|
+
.take(ADMIN_LIST_CAP)
|
|
601
|
+
: await ctx.db.query("buckets").take(ADMIN_LIST_CAP);
|
|
589
602
|
const today = dayStamp();
|
|
590
|
-
return
|
|
591
|
-
...
|
|
592
|
-
spendTodayNanos:
|
|
603
|
+
return rows.map((b) => ({
|
|
604
|
+
...b,
|
|
605
|
+
spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
|
|
593
606
|
}));
|
|
594
607
|
},
|
|
595
608
|
});
|
|
596
|
-
export const
|
|
609
|
+
export const getBucket = query({
|
|
610
|
+
args: { dimension: v.string(), value: v.string() },
|
|
611
|
+
handler: async (ctx, args) => {
|
|
612
|
+
const b = await getBucketDoc(ctx, args.dimension, args.value);
|
|
613
|
+
if (!b)
|
|
614
|
+
return null;
|
|
615
|
+
const today = dayStamp();
|
|
616
|
+
return { ...b, spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0 };
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
// Set a bucket's limits/controls. `user` and `action` are just dimensions here;
|
|
620
|
+
// the client's ai.users / ai.actions namespaces are thin wrappers over this.
|
|
621
|
+
export const setBucketLimits = mutation({
|
|
597
622
|
args: {
|
|
598
|
-
|
|
623
|
+
dimension: v.string(),
|
|
624
|
+
value: v.string(),
|
|
599
625
|
requestsPerMinute: v.optional(v.number()),
|
|
600
626
|
dailySpendLimitNanos: v.optional(v.number()),
|
|
601
627
|
lifetimeSpendLimitNanos: v.optional(v.number()),
|
|
@@ -606,123 +632,66 @@ export const setLimits = mutation({
|
|
|
606
632
|
},
|
|
607
633
|
returns: v.null(),
|
|
608
634
|
handler: async (ctx, args) => {
|
|
609
|
-
const
|
|
610
|
-
const {
|
|
611
|
-
await ctx.db.patch(
|
|
635
|
+
const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
|
|
636
|
+
const { dimension: _d, value: _v, ...limits } = args;
|
|
637
|
+
await ctx.db.patch(bucket._id, limits);
|
|
612
638
|
return null;
|
|
613
639
|
},
|
|
614
640
|
});
|
|
615
|
-
// Delete a user and all their request rows (e.g. account deletion / GDPR).
|
|
616
|
-
// Deletes in bounded batches and self-reschedules so it never exceeds the
|
|
617
|
-
// per-transaction document limit — a user with millions of rows still deletes.
|
|
618
|
-
const DELETE_BATCH = 500;
|
|
619
|
-
export const deleteUser = mutation({
|
|
620
|
-
args: { userId: v.string() },
|
|
621
|
-
returns: v.object({ deletedThisBatch: v.number(), done: v.boolean() }),
|
|
622
|
-
handler: async (ctx, { userId }) => {
|
|
623
|
-
const rows = await ctx.db
|
|
624
|
-
.query("requests")
|
|
625
|
-
.withIndex("userId", (q) => q.eq("userId", userId))
|
|
626
|
-
.take(DELETE_BATCH);
|
|
627
|
-
for (const r of rows)
|
|
628
|
-
await ctx.db.delete(r._id);
|
|
629
|
-
if (rows.length === DELETE_BATCH) {
|
|
630
|
-
// More to go — continue in a fresh transaction.
|
|
631
|
-
await ctx.scheduler.runAfter(0, api.lib.deleteUser, { userId });
|
|
632
|
-
return { deletedThisBatch: rows.length, done: false };
|
|
633
|
-
}
|
|
634
|
-
// Last batch: remove the user row itself.
|
|
635
|
-
const user = await ctx.db
|
|
636
|
-
.query("users")
|
|
637
|
-
.withIndex("userId", (q) => q.eq("userId", userId))
|
|
638
|
-
.unique();
|
|
639
|
-
if (user)
|
|
640
|
-
await ctx.db.delete(user._id);
|
|
641
|
-
return { deletedThisBatch: rows.length + (user ? 1 : 0), done: true };
|
|
642
|
-
},
|
|
643
|
-
});
|
|
644
|
-
export const listActions = query({
|
|
645
|
-
args: {},
|
|
646
|
-
handler: async (ctx) => {
|
|
647
|
-
const actions = await ctx.db.query("actions").take(ADMIN_LIST_CAP);
|
|
648
|
-
const today = dayStamp();
|
|
649
|
-
return actions.map((a) => ({
|
|
650
|
-
...a,
|
|
651
|
-
spendTodayNanos: a.dayStamp === today ? a.spendTodayNanos : 0,
|
|
652
|
-
}));
|
|
653
|
-
},
|
|
654
|
-
});
|
|
655
641
|
const vBumpArgs = {
|
|
656
642
|
dailyNanos: v.optional(v.number()),
|
|
657
643
|
lifetimeNanos: v.optional(v.number()),
|
|
658
644
|
};
|
|
659
|
-
// One-time "approve another $X" bumps, added on top of
|
|
660
|
-
// changing it. Daily bumps apply to today only; lifetime bumps
|
|
661
|
-
export const
|
|
662
|
-
args: {
|
|
663
|
-
returns: v.null(),
|
|
664
|
-
handler: async (ctx, { userId, dailyNanos, lifetimeNanos }) => {
|
|
665
|
-
const user = await getOrCreateUser(ctx, userId);
|
|
666
|
-
const today = dayStamp();
|
|
667
|
-
const curDaily = user.bumpDayStamp === today ? user.dailyBumpNanos ?? 0 : 0;
|
|
668
|
-
await ctx.db.patch(user._id, {
|
|
669
|
-
bumpDayStamp: today,
|
|
670
|
-
dailyBumpNanos: curDaily + (dailyNanos ?? 0),
|
|
671
|
-
lifetimeBumpNanos: (user.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
|
|
672
|
-
});
|
|
673
|
-
return null;
|
|
674
|
-
},
|
|
675
|
-
});
|
|
676
|
-
export const bumpAction = mutation({
|
|
677
|
-
args: { name: v.string(), ...vBumpArgs },
|
|
645
|
+
// One-time "approve another $X" bumps, added on top of a bucket's standing cap
|
|
646
|
+
// without changing it. Daily bumps apply to today only; lifetime bumps persist.
|
|
647
|
+
export const bumpBucket = mutation({
|
|
648
|
+
args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
|
|
678
649
|
returns: v.null(),
|
|
679
|
-
handler: async (ctx, {
|
|
680
|
-
const
|
|
650
|
+
handler: async (ctx, { dimension, value, dailyNanos, lifetimeNanos }) => {
|
|
651
|
+
const bucket = await getOrCreateBucket(ctx, dimension, value);
|
|
681
652
|
const today = dayStamp();
|
|
682
|
-
const curDaily =
|
|
683
|
-
await ctx.db.patch(
|
|
653
|
+
const curDaily = bucket.bumpDayStamp === today ? bucket.dailyBumpNanos ?? 0 : 0;
|
|
654
|
+
await ctx.db.patch(bucket._id, {
|
|
684
655
|
bumpDayStamp: today,
|
|
685
656
|
dailyBumpNanos: curDaily + (dailyNanos ?? 0),
|
|
686
|
-
lifetimeBumpNanos: (
|
|
657
|
+
lifetimeBumpNanos: (bucket.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
|
|
687
658
|
});
|
|
688
659
|
return null;
|
|
689
660
|
},
|
|
690
661
|
});
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
await ctx.db.patch(action._id, limits);
|
|
725
|
-
return null;
|
|
662
|
+
// Delete a bucket and (for the `user` dimension) all of that user's request
|
|
663
|
+
// rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
|
|
664
|
+
// self-reschedules so it never exceeds the per-transaction document limit.
|
|
665
|
+
const DELETE_BATCH = 500;
|
|
666
|
+
export const deleteBucket = mutation({
|
|
667
|
+
args: { dimension: v.string(), value: v.string() },
|
|
668
|
+
returns: v.object({ deletedThisBatch: v.number(), done: v.boolean() }),
|
|
669
|
+
handler: async (ctx, { dimension, value }) => {
|
|
670
|
+
// Only the user dimension owns request rows (indexed by userId). Other
|
|
671
|
+
// dimensions just drop their budget-holder row.
|
|
672
|
+
if (dimension === USER_DIM) {
|
|
673
|
+
const rows = await ctx.db
|
|
674
|
+
.query("requests")
|
|
675
|
+
.withIndex("userId", (q) => q.eq("userId", value))
|
|
676
|
+
.take(DELETE_BATCH);
|
|
677
|
+
for (const r of rows)
|
|
678
|
+
await ctx.db.delete(r._id);
|
|
679
|
+
if (rows.length === DELETE_BATCH) {
|
|
680
|
+
await ctx.scheduler.runAfter(0, api.lib.deleteBucket, {
|
|
681
|
+
dimension,
|
|
682
|
+
value,
|
|
683
|
+
});
|
|
684
|
+
return { deletedThisBatch: rows.length, done: false };
|
|
685
|
+
}
|
|
686
|
+
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
687
|
+
if (bucket)
|
|
688
|
+
await ctx.db.delete(bucket._id);
|
|
689
|
+
return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
|
|
690
|
+
}
|
|
691
|
+
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
692
|
+
if (bucket)
|
|
693
|
+
await ctx.db.delete(bucket._id);
|
|
694
|
+
return { deletedThisBatch: bucket ? 1 : 0, done: true };
|
|
726
695
|
},
|
|
727
696
|
});
|
|
728
697
|
export const getModelPolicy = query({
|
|
@@ -786,6 +755,25 @@ export const setGlobalLimits = mutation({
|
|
|
786
755
|
return null;
|
|
787
756
|
},
|
|
788
757
|
});
|
|
758
|
+
export const bumpGlobal = mutation({
|
|
759
|
+
args: vBumpArgs,
|
|
760
|
+
returns: v.null(),
|
|
761
|
+
handler: async (ctx, { dailyNanos, lifetimeNanos }) => {
|
|
762
|
+
const today = dayStamp();
|
|
763
|
+
const s = await getSettings(ctx);
|
|
764
|
+
const curDaily = s?.globalBumpDayStamp === today ? s?.globalDailyBumpNanos ?? 0 : 0;
|
|
765
|
+
const patch = {
|
|
766
|
+
globalBumpDayStamp: today,
|
|
767
|
+
globalDailyBumpNanos: curDaily + (dailyNanos ?? 0),
|
|
768
|
+
globalLifetimeBumpNanos: (s?.globalLifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
|
|
769
|
+
};
|
|
770
|
+
if (s)
|
|
771
|
+
await ctx.db.patch(s._id, patch);
|
|
772
|
+
else
|
|
773
|
+
await ctx.db.insert("settings", { key: "singleton", ...patch });
|
|
774
|
+
return null;
|
|
775
|
+
},
|
|
776
|
+
});
|
|
789
777
|
export const setModelPolicy = mutation({
|
|
790
778
|
args: {
|
|
791
779
|
mode: v.union(v.literal("open"), v.literal("allowlist"), v.literal("denylist")),
|