@convex-dev/ai-budget 0.0.2-alpha.0 → 0.0.2-alpha.12
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 +388 -185
- package/dist/client/dashboard.d.ts +1 -0
- package/dist/client/dashboard.js +223 -0
- package/dist/client/index.d.ts +545 -38
- package/dist/client/index.js +343 -59
- package/dist/component/_generated/component.d.ts +62 -24
- package/dist/component/lib.d.ts +144 -40
- package/dist/component/lib.js +648 -305
- package/dist/component/schema.d.ts +107 -53
- package/dist/component/schema.js +87 -38
- package/package.json +5 -5
- package/src/client/dashboard.ts +223 -0
- package/src/client/index.ts +513 -109
- package/src/component/_generated/component.ts +94 -29
- package/src/component/lib.test.ts +253 -15
- package/src/component/lib.ts +746 -333
- package/src/component/schema.ts +90 -38
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);
|
|
@@ -36,9 +43,18 @@ const DEFAULT_PRICES: Record<string, { input: number; output: number }> = {
|
|
|
36
43
|
"openai/gpt-5-mini": { input: 250_000_000, output: 2_000_000_000 },
|
|
37
44
|
};
|
|
38
45
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
46
|
+
// Per-call price (nanodollars) for provider server-side tools that bill a fee on
|
|
47
|
+
// top of tokens — e.g. Anthropic web search at ~$0.01/call. Keyed by the tool
|
|
48
|
+
// name the caller reports in `serverToolUses` (e.g. { web_search: 3 }). Used
|
|
49
|
+
// only when a request settles WITHOUT an authoritative gateway cost; if you pass
|
|
50
|
+
// `costNanos`, that already includes tool fees. Override via setServerToolPrice.
|
|
51
|
+
const DEFAULT_SERVER_TOOL_PRICES: Record<string, number> = {
|
|
52
|
+
web_search: 10_000_000, // $0.01 per search
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Pessimistic assumed output length when reserving budget up front. This makes
|
|
56
|
+
// concurrent admission atomic against the estimate; a response that exceeds the
|
|
57
|
+
// estimate can still settle above the cap by the estimation delta.
|
|
42
58
|
const ESTIMATED_OUTPUT_TOKENS = 800;
|
|
43
59
|
// A request still "pending" after this long is presumed dead (its action
|
|
44
60
|
// crashed before settling); the reconciler releases its reservation. Set well
|
|
@@ -53,7 +69,13 @@ const STALE_PENDING_MS = 30 * 60 * 1000;
|
|
|
53
69
|
// Override per-deployment via setRetention.
|
|
54
70
|
const DEFAULT_RETENTION_MS = 60 * 60 * 1000; // 1 hour
|
|
55
71
|
|
|
56
|
-
const dayStamp = () => new Date().toISOString().slice(0, 10);
|
|
72
|
+
const dayStamp = () => new Date().toISOString().slice(0, 10); // "2026-09-04"
|
|
73
|
+
const monthStamp = () => new Date().toISOString().slice(0, 7); // "2026-09"
|
|
74
|
+
|
|
75
|
+
// Cached (prompt-cache-read) input tokens are billed far below the normal input
|
|
76
|
+
// rate. When a model's price has no explicit cachedNanosPerMTok, charge this
|
|
77
|
+
// fraction of its input rate (providers commonly discount ~90%).
|
|
78
|
+
const CACHE_DISCOUNT = 0.1;
|
|
57
79
|
|
|
58
80
|
// Conservative fallback for any model not in the price table: the max of every
|
|
59
81
|
// known price dimension. Falling back to 0 would be fail-open — an unpriced
|
|
@@ -78,14 +100,21 @@ async function getPrice(ctx: MutationCtx, model: string) {
|
|
|
78
100
|
return {
|
|
79
101
|
input: override.inputNanosPerMTok,
|
|
80
102
|
output: override.outputNanosPerMTok,
|
|
103
|
+
cached: override.cachedNanosPerMTok,
|
|
81
104
|
known: true,
|
|
82
105
|
};
|
|
83
106
|
}
|
|
84
107
|
const known = DEFAULT_PRICES[model];
|
|
85
|
-
if (known) return { ...known, known: true };
|
|
86
|
-
return { ...CONSERVATIVE_PRICE, known: false };
|
|
108
|
+
if (known) return { ...known, cached: undefined, known: true };
|
|
109
|
+
return { ...CONSERVATIVE_PRICE, cached: undefined, known: false };
|
|
87
110
|
}
|
|
88
111
|
|
|
112
|
+
type Price = { input: number; output: number; cached?: number };
|
|
113
|
+
// The per-Mtok rate for cached (prompt-cache-read) tokens: an explicit override,
|
|
114
|
+
// else a discount off the input rate.
|
|
115
|
+
const cachedRate = (p: Price) =>
|
|
116
|
+
p.cached ?? Math.round(p.input * CACHE_DISCOUNT);
|
|
117
|
+
|
|
89
118
|
// Integer nanodollars. Divide-before-multiply keeps the intermediate product
|
|
90
119
|
// within 2^53 even for large token counts × large per-Mtok prices.
|
|
91
120
|
const costOf = (
|
|
@@ -97,6 +126,82 @@ const costOf = (
|
|
|
97
126
|
(inputTokens / 1e6) * price.input + (outputTokens / 1e6) * price.output
|
|
98
127
|
);
|
|
99
128
|
|
|
129
|
+
// Cache-aware settle cost: the cached slice of the prompt is billed at the
|
|
130
|
+
// (discounted) cache rate, the rest of the prompt at the input rate, and
|
|
131
|
+
// completions at the output rate. `cachedTokens` is the gateway's real
|
|
132
|
+
// prompt-cache-read count (usage.inputTokenDetails.cacheReadTokens).
|
|
133
|
+
const settleCost = (
|
|
134
|
+
promptTokens: number,
|
|
135
|
+
cachedTokens: number,
|
|
136
|
+
completionTokens: number,
|
|
137
|
+
price: Price
|
|
138
|
+
) => {
|
|
139
|
+
const cached = Math.min(Math.max(0, cachedTokens), Math.max(0, promptTokens));
|
|
140
|
+
const fresh = Math.max(0, promptTokens - cached);
|
|
141
|
+
return Math.round(
|
|
142
|
+
(fresh / 1e6) * price.input +
|
|
143
|
+
(cached / 1e6) * cachedRate(price) +
|
|
144
|
+
(completionTokens / 1e6) * price.output
|
|
145
|
+
);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Per-call fees for provider server tools (web search, etc.), merging the
|
|
149
|
+
// defaults with any deployment overrides. Unknown tools price at 0 (recorded
|
|
150
|
+
// but not charged) rather than guessing.
|
|
151
|
+
const serverToolCost = (
|
|
152
|
+
uses: Record<string, number> | undefined,
|
|
153
|
+
overrides: Record<string, number> | undefined
|
|
154
|
+
) => {
|
|
155
|
+
if (!uses) return 0;
|
|
156
|
+
const prices = { ...DEFAULT_SERVER_TOOL_PRICES, ...(overrides ?? {}) };
|
|
157
|
+
let total = 0;
|
|
158
|
+
for (const [tool, count] of Object.entries(uses)) {
|
|
159
|
+
if (count > 0 && prices[tool] > 0) total += Math.round(count * prices[tool]);
|
|
160
|
+
}
|
|
161
|
+
return total;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Upsert-add a settled amount into the durable per-(bucket, period) usage row.
|
|
165
|
+
// These rows are never swept by request retention, so spend history survives.
|
|
166
|
+
async function addUsage(
|
|
167
|
+
ctx: MutationCtx,
|
|
168
|
+
dimension: string,
|
|
169
|
+
value: string,
|
|
170
|
+
period: "day" | "month",
|
|
171
|
+
stamp: string,
|
|
172
|
+
spendNanos: number,
|
|
173
|
+
tokens: number,
|
|
174
|
+
requests: number
|
|
175
|
+
) {
|
|
176
|
+
const existing = await ctx.db
|
|
177
|
+
.query("usage")
|
|
178
|
+
.withIndex("bucket_period_stamp", (q) =>
|
|
179
|
+
q
|
|
180
|
+
.eq("dimension", dimension)
|
|
181
|
+
.eq("value", value)
|
|
182
|
+
.eq("period", period)
|
|
183
|
+
.eq("stamp", stamp)
|
|
184
|
+
)
|
|
185
|
+
.unique();
|
|
186
|
+
if (existing) {
|
|
187
|
+
await ctx.db.patch(existing._id, {
|
|
188
|
+
spendNanos: existing.spendNanos + spendNanos,
|
|
189
|
+
tokens: existing.tokens + tokens,
|
|
190
|
+
requests: existing.requests + requests,
|
|
191
|
+
});
|
|
192
|
+
} else {
|
|
193
|
+
await ctx.db.insert("usage", {
|
|
194
|
+
dimension,
|
|
195
|
+
value,
|
|
196
|
+
period,
|
|
197
|
+
stamp,
|
|
198
|
+
spendNanos,
|
|
199
|
+
tokens,
|
|
200
|
+
requests,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
100
205
|
// Up-front estimate of a request's cost and token count, reserved before the
|
|
101
206
|
// call so concurrent in-flight requests are visible to each other's caps.
|
|
102
207
|
function estimateUsage(
|
|
@@ -111,58 +216,116 @@ function estimateUsage(
|
|
|
111
216
|
};
|
|
112
217
|
}
|
|
113
218
|
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
219
|
+
// The full set of attribution buckets a request touches: the built-in `user`
|
|
220
|
+
// and `action` dimensions plus any extra tags. Reserved dimensions in `extra`
|
|
221
|
+
// are dropped (userId/actionName own them), and (dimension, value) pairs are
|
|
222
|
+
// de-duplicated. Used identically at reserve time (startRequest) and settle
|
|
223
|
+
// time (foldOne), so a request always settles exactly the buckets it reserved.
|
|
224
|
+
function requestBuckets(
|
|
225
|
+
userId: string,
|
|
226
|
+
actionName: string | undefined,
|
|
227
|
+
extra: { dimension: string; value: string }[] | undefined
|
|
228
|
+
): { dimension: string; value: string }[] {
|
|
229
|
+
const out = [{ dimension: USER_DIM, value: userId }];
|
|
230
|
+
if (actionName !== undefined)
|
|
231
|
+
out.push({ dimension: ACTION_DIM, value: actionName });
|
|
232
|
+
for (const t of extra ?? []) {
|
|
233
|
+
if (t.dimension === USER_DIM || t.dimension === ACTION_DIM) continue;
|
|
234
|
+
if (!t.dimension || !t.value) continue;
|
|
235
|
+
if (out.some((x) => x.dimension === t.dimension && x.value === t.value))
|
|
236
|
+
continue;
|
|
237
|
+
out.push({ dimension: t.dimension, value: t.value });
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Drop reserved/empty/duplicate tags from a caller-supplied list, leaving the
|
|
243
|
+
// "extra" dimensions stored on the request row.
|
|
244
|
+
function sanitizeExtraTags(
|
|
245
|
+
extra: { dimension: string; value: string }[] | undefined
|
|
246
|
+
): { dimension: string; value: string }[] {
|
|
247
|
+
const out: { dimension: string; value: string }[] = [];
|
|
248
|
+
for (const t of extra ?? []) {
|
|
249
|
+
if (t.dimension === USER_DIM || t.dimension === ACTION_DIM) continue;
|
|
250
|
+
if (!t.dimension || !t.value) continue;
|
|
251
|
+
if (out.some((x) => x.dimension === t.dimension && x.value === t.value))
|
|
252
|
+
continue;
|
|
253
|
+
out.push({ dimension: t.dimension, value: t.value });
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Evaluate a bucket's spend + token budgets against committed + reserved + this
|
|
259
|
+
// request's estimate. Returns a hard rejection (block), soft warnings (allow),
|
|
260
|
+
// and threshold notices (approaching a cap — see warnAtPct). Each window (daily,
|
|
261
|
+
// monthly, lifetime) × kind (spend, token) is one check.
|
|
117
262
|
function evaluateCaps(o: {
|
|
118
263
|
label: string;
|
|
119
264
|
name: string;
|
|
120
265
|
enforcement: "hard" | "soft";
|
|
266
|
+
warnAtPct?: number;
|
|
121
267
|
estCost: number;
|
|
122
268
|
estTokens: number;
|
|
123
269
|
spendToday: number;
|
|
124
270
|
reservedSpendToday: number;
|
|
271
|
+
spendThisMonth: number;
|
|
272
|
+
reservedSpendMonth: number;
|
|
125
273
|
totalSpend: number;
|
|
126
274
|
reservedSpendTotal: number;
|
|
127
275
|
tokensToday: number;
|
|
128
276
|
reservedTokensToday: number;
|
|
277
|
+
tokensThisMonth: number;
|
|
278
|
+
reservedTokensMonth: number;
|
|
129
279
|
totalTokens: number;
|
|
130
280
|
reservedTokensTotal: number;
|
|
131
281
|
dailySpendLimitNanos?: number;
|
|
282
|
+
monthlySpendLimitNanos?: number;
|
|
132
283
|
lifetimeSpendLimitNanos?: number;
|
|
133
284
|
dailyTokenLimit?: number;
|
|
285
|
+
monthlyTokenLimit?: number;
|
|
134
286
|
lifetimeTokenLimit?: number;
|
|
135
|
-
}): {
|
|
287
|
+
}): {
|
|
288
|
+
hard?: { code: string; reason: string };
|
|
289
|
+
warnings: string[];
|
|
290
|
+
notices: string[];
|
|
291
|
+
} {
|
|
292
|
+
// { code, projected usage (incl. this estimate), cap, human window label,
|
|
293
|
+
// whether it's a money cap (formatted as $), spend? for notices }
|
|
294
|
+
const checks = [
|
|
295
|
+
{ w: "daily_spend_limit", used: o.spendToday + o.reservedSpendToday + o.estCost, cap: o.dailySpendLimitNanos, label: "daily spend limit", money: true },
|
|
296
|
+
{ w: "monthly_spend_limit", used: o.spendThisMonth + o.reservedSpendMonth + o.estCost, cap: o.monthlySpendLimitNanos, label: "monthly spend limit", money: true },
|
|
297
|
+
{ w: "lifetime_spend_limit", used: o.totalSpend + o.reservedSpendTotal + o.estCost, cap: o.lifetimeSpendLimitNanos, label: "lifetime spend limit", money: true },
|
|
298
|
+
{ w: "daily_token_limit", used: o.tokensToday + o.reservedTokensToday + o.estTokens, cap: o.dailyTokenLimit, label: "daily token limit", money: false },
|
|
299
|
+
{ w: "monthly_token_limit", used: o.tokensThisMonth + o.reservedTokensMonth + o.estTokens, cap: o.monthlyTokenLimit, label: "monthly token limit", money: false },
|
|
300
|
+
{ w: "lifetime_token_limit", used: o.totalTokens + o.reservedTokensTotal + o.estTokens, cap: o.lifetimeTokenLimit, label: "lifetime token limit", money: false },
|
|
301
|
+
];
|
|
302
|
+
|
|
136
303
|
const violations: { code: string; reason: string }[] = [];
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
)
|
|
159
|
-
push("lifetime_token_limit", `Lifetime token limit reached for ${o.label} "${o.name}"`);
|
|
160
|
-
|
|
161
|
-
if (violations.length === 0) return { warnings: [] };
|
|
162
|
-
if (o.enforcement === "soft") return { warnings: violations.map((v) => v.reason) };
|
|
163
|
-
return { hard: violations[0], warnings: [] };
|
|
304
|
+
const notices: string[] = [];
|
|
305
|
+
const pct = o.warnAtPct;
|
|
306
|
+
for (const c of checks) {
|
|
307
|
+
if (c.cap === undefined) continue;
|
|
308
|
+
const capStr = c.money ? `${fmtUsd(c.cap)}` : `${c.cap} tokens`;
|
|
309
|
+
if (c.used > c.cap) {
|
|
310
|
+
violations.push({
|
|
311
|
+
code: `${o.label}_${c.w}`,
|
|
312
|
+
reason: `${cap(c.label)} reached for ${o.label} "${o.name}" (${capStr})`,
|
|
313
|
+
});
|
|
314
|
+
} else if (pct !== undefined && pct > 0 && pct < 1 && c.used >= pct * c.cap) {
|
|
315
|
+
notices.push(
|
|
316
|
+
`${o.label} "${o.name}" at ${Math.round((c.used / c.cap) * 100)}% of ${c.label} (${capStr})`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (violations.length === 0) return { warnings: [], notices };
|
|
322
|
+
if (o.enforcement === "soft")
|
|
323
|
+
return { warnings: violations.map((v) => v.reason), notices };
|
|
324
|
+
return { hard: violations[0], warnings: [], notices };
|
|
164
325
|
}
|
|
165
326
|
|
|
327
|
+
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
328
|
+
|
|
166
329
|
// A cap plus any one-time bump. Returns undefined when there's no base cap
|
|
167
330
|
// (a bump alone never creates a cap).
|
|
168
331
|
const withBump = (base: number | undefined, bump: number | undefined) =>
|
|
@@ -170,40 +333,43 @@ const withBump = (base: number | undefined, bump: number | undefined) =>
|
|
|
170
333
|
|
|
171
334
|
const hasAnyCap = (e: {
|
|
172
335
|
dailySpendLimitNanos?: number;
|
|
336
|
+
monthlySpendLimitNanos?: number;
|
|
173
337
|
lifetimeSpendLimitNanos?: number;
|
|
174
338
|
dailyTokenLimit?: number;
|
|
339
|
+
monthlyTokenLimit?: number;
|
|
175
340
|
lifetimeTokenLimit?: number;
|
|
176
341
|
}) =>
|
|
177
342
|
e.dailySpendLimitNanos !== undefined ||
|
|
343
|
+
e.monthlySpendLimitNanos !== undefined ||
|
|
178
344
|
e.lifetimeSpendLimitNanos !== undefined ||
|
|
179
345
|
e.dailyTokenLimit !== undefined ||
|
|
346
|
+
e.monthlyTokenLimit !== undefined ||
|
|
180
347
|
e.lifetimeTokenLimit !== undefined;
|
|
181
348
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
349
|
+
// A bucket needs a reservation row-write if it has any spend/token cap OR a
|
|
350
|
+
// concurrency cap (which reads pendingCount, incremented at reserve time).
|
|
351
|
+
const needsReserve = (e: Parameters<typeof hasAnyCap>[0] & { maxConcurrent?: number }) =>
|
|
352
|
+
hasAnyCap(e) || e.maxConcurrent !== undefined;
|
|
353
|
+
|
|
354
|
+
async function getBucketDoc(ctx: MutationCtx, dimension: string, value: string) {
|
|
355
|
+
return await ctx.db
|
|
356
|
+
.query("buckets")
|
|
357
|
+
.withIndex("dim_value", (q) =>
|
|
358
|
+
q.eq("dimension", dimension).eq("value", value)
|
|
359
|
+
)
|
|
186
360
|
.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
361
|
}
|
|
198
362
|
|
|
199
|
-
async function
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
363
|
+
async function getOrCreateBucket(
|
|
364
|
+
ctx: MutationCtx,
|
|
365
|
+
dimension: string,
|
|
366
|
+
value: string
|
|
367
|
+
) {
|
|
368
|
+
const existing = await getBucketDoc(ctx, dimension, value);
|
|
204
369
|
if (existing) return existing;
|
|
205
|
-
const id = await ctx.db.insert("
|
|
206
|
-
|
|
370
|
+
const id = await ctx.db.insert("buckets", {
|
|
371
|
+
dimension,
|
|
372
|
+
value,
|
|
207
373
|
totalSpendNanos: 0,
|
|
208
374
|
totalRequests: 0,
|
|
209
375
|
totalTokens: 0,
|
|
@@ -220,11 +386,22 @@ async function getSettings(ctx: MutationCtx) {
|
|
|
220
386
|
.unique();
|
|
221
387
|
}
|
|
222
388
|
|
|
389
|
+
// Delete the reverse-index rows for a request (called when the request row is
|
|
390
|
+
// deleted, so the tag index never outlives the request it points at).
|
|
391
|
+
async function deleteRequestTags(ctx: MutationCtx, requestId: Doc<"requests">["_id"]) {
|
|
392
|
+
const tags = await ctx.db
|
|
393
|
+
.query("requestTags")
|
|
394
|
+
.withIndex("requestId", (q) => q.eq("requestId", requestId))
|
|
395
|
+
.collect();
|
|
396
|
+
for (const t of tags) await ctx.db.delete(t._id);
|
|
397
|
+
}
|
|
398
|
+
|
|
223
399
|
const vStartResult = v.union(
|
|
224
400
|
v.object({
|
|
225
401
|
allowed: v.literal(true),
|
|
226
402
|
requestId: v.id("requests"),
|
|
227
|
-
warnings: v.array(v.string()),
|
|
403
|
+
warnings: v.array(v.string()), // soft caps exceeded (allowed with a warning)
|
|
404
|
+
notices: v.array(v.string()), // approaching a cap (warnAtPct threshold)
|
|
228
405
|
}),
|
|
229
406
|
v.object({
|
|
230
407
|
allowed: v.literal(false),
|
|
@@ -237,13 +414,16 @@ export const startRequest = mutation({
|
|
|
237
414
|
args: {
|
|
238
415
|
userId: v.string(),
|
|
239
416
|
actionName: v.optional(v.string()),
|
|
417
|
+
// Extra attribution dimensions to bill/limit (team, customer, env, …).
|
|
418
|
+
// `user` and `action` are reserved (owned by userId/actionName).
|
|
419
|
+
tags: v.optional(v.array(vTag)),
|
|
240
420
|
model: v.string(),
|
|
241
421
|
messages: v.array(vMessage),
|
|
242
422
|
rerunOf: v.optional(v.id("requests")),
|
|
243
423
|
},
|
|
244
424
|
returns: vStartResult,
|
|
245
425
|
handler: async (ctx, args) => {
|
|
246
|
-
const
|
|
426
|
+
const extraTags = sanitizeExtraTags(args.tags);
|
|
247
427
|
// Record the blocked attempt and return a rejection (throwing would roll
|
|
248
428
|
// back the record). `persist` is false for the high-frequency-by-design
|
|
249
429
|
// rejections (rate limit, blocked user) that a client retries in a tight
|
|
@@ -252,7 +432,12 @@ export const startRequest = mutation({
|
|
|
252
432
|
const reject = async (code: string, reason: string, persist = true) => {
|
|
253
433
|
if (persist) {
|
|
254
434
|
await ctx.db.insert("requests", {
|
|
255
|
-
|
|
435
|
+
userId: args.userId,
|
|
436
|
+
actionName: args.actionName,
|
|
437
|
+
...(extraTags.length ? { tags: extraTags } : {}),
|
|
438
|
+
model: args.model,
|
|
439
|
+
messages: args.messages,
|
|
440
|
+
rerunOf: args.rerunOf,
|
|
256
441
|
status: "blocked" as const,
|
|
257
442
|
error: reason,
|
|
258
443
|
});
|
|
@@ -261,18 +446,18 @@ export const startRequest = mutation({
|
|
|
261
446
|
};
|
|
262
447
|
|
|
263
448
|
const today = dayStamp();
|
|
449
|
+
const month = monthStamp();
|
|
264
450
|
const priceInfo = await getPrice(ctx, args.model);
|
|
265
451
|
const est = estimateUsage(args.messages, priceInfo);
|
|
266
452
|
const warnings: string[] = [];
|
|
453
|
+
const notices: string[] = [];
|
|
267
454
|
|
|
268
|
-
if (user.blocked) {
|
|
269
|
-
return reject("blocked", `User "${args.userId}" is blocked`, false);
|
|
270
|
-
}
|
|
271
455
|
// Model allow/deny policy (component-wide).
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
456
|
+
const settings = await getSettings(ctx);
|
|
457
|
+
const defaultWarnAtPct = settings?.defaultWarnAtPct;
|
|
458
|
+
if (settings) {
|
|
459
|
+
const mode = settings.modelMode ?? "open";
|
|
460
|
+
const list = settings.models ?? [];
|
|
276
461
|
if (mode === "allowlist" && !list.includes(args.model)) {
|
|
277
462
|
return reject(
|
|
278
463
|
"model_not_allowed",
|
|
@@ -283,178 +468,236 @@ export const startRequest = mutation({
|
|
|
283
468
|
return reject("model_denied", `Model "${args.model}" is denied`);
|
|
284
469
|
}
|
|
285
470
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
) {
|
|
471
|
+
|
|
472
|
+
// Fetch/create every bucket this request is attributed to (user, action,
|
|
473
|
+
// and any extra tags). Each may carry its own budget.
|
|
474
|
+
const bucketTags = requestBuckets(args.userId, args.actionName, extraTags);
|
|
475
|
+
const buckets: Doc<"buckets">[] = [];
|
|
476
|
+
for (const t of bucketTags) {
|
|
477
|
+
buckets.push(await getOrCreateBucket(ctx, t.dimension, t.value));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// A hard block on ANY bucket rejects the request. The user dimension's block
|
|
481
|
+
// isn't persisted (retried in a loop); config-level blocks on other
|
|
482
|
+
// dimensions are rarer, so they persist for the audit log.
|
|
483
|
+
for (const b of buckets) {
|
|
484
|
+
if (b.blocked) {
|
|
485
|
+
const label = b.dimension === USER_DIM ? "User" : b.dimension;
|
|
302
486
|
return reject(
|
|
303
|
-
|
|
304
|
-
|
|
487
|
+
`${b.dimension}_blocked`,
|
|
488
|
+
`${label} "${b.value}" is blocked`,
|
|
489
|
+
b.dimension !== USER_DIM
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Concurrency cap: reject when a bucket already has maxConcurrent requests
|
|
495
|
+
// in flight (pendingCount). A transient limit like rate-limiting, so it
|
|
496
|
+
// isn't persisted (the caller retries once something settles).
|
|
497
|
+
for (const b of buckets) {
|
|
498
|
+
if (b.maxConcurrent !== undefined && (b.pendingCount ?? 0) >= b.maxConcurrent) {
|
|
499
|
+
return reject(
|
|
500
|
+
`${b.dimension}_max_concurrent`,
|
|
501
|
+
`Too many concurrent requests for ${b.dimension} "${b.value}" (max ${b.maxConcurrent})`,
|
|
305
502
|
false
|
|
306
503
|
);
|
|
307
504
|
}
|
|
308
505
|
}
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
506
|
+
|
|
507
|
+
// Enforce the rolling 60-second rate limit on every configured dimension.
|
|
508
|
+
// User/action requests use their first-class indexes; custom dimensions use
|
|
509
|
+
// the requestTags reverse index. All reads are bounded by the configured
|
|
510
|
+
// limit (plus a small allowance for persisted blocked attempts).
|
|
511
|
+
const rateCutoff = Date.now() - 60_000;
|
|
512
|
+
for (const b of buckets) {
|
|
513
|
+
const limit = b.requestsPerMinute;
|
|
514
|
+
if (limit === undefined) continue;
|
|
515
|
+
|
|
516
|
+
let recentCount: number;
|
|
517
|
+
if (b.dimension === USER_DIM) {
|
|
518
|
+
const recent = await ctx.db
|
|
519
|
+
.query("requests")
|
|
520
|
+
.withIndex("userId", (q) =>
|
|
521
|
+
q.eq("userId", b.value).gt("_creationTime", rateCutoff)
|
|
522
|
+
)
|
|
523
|
+
.take(limit + 50);
|
|
524
|
+
recentCount = recent.filter((r) => r.status !== "blocked").length;
|
|
525
|
+
} else if (b.dimension === ACTION_DIM) {
|
|
526
|
+
const recent = await ctx.db
|
|
527
|
+
.query("requests")
|
|
528
|
+
.withIndex("actionName", (q) =>
|
|
529
|
+
q.eq("actionName", b.value).gt("_creationTime", rateCutoff)
|
|
530
|
+
)
|
|
531
|
+
.take(limit + 50);
|
|
532
|
+
recentCount = recent.filter((r) => r.status !== "blocked").length;
|
|
533
|
+
} else {
|
|
534
|
+
recentCount = (
|
|
535
|
+
await ctx.db
|
|
536
|
+
.query("requestTags")
|
|
537
|
+
.withIndex("dim_value", (q) =>
|
|
538
|
+
q
|
|
539
|
+
.eq("dimension", b.dimension)
|
|
540
|
+
.eq("value", b.value)
|
|
541
|
+
.gt("_creationTime", rateCutoff)
|
|
542
|
+
)
|
|
543
|
+
.take(limit)
|
|
544
|
+
).length;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (recentCount >= limit) {
|
|
548
|
+
const code = b.dimension === USER_DIM ? "rate_limit" : `${b.dimension}_rate_limit`;
|
|
549
|
+
return reject(
|
|
550
|
+
code,
|
|
551
|
+
`Rate limit exceeded for ${b.dimension} "${b.value}" (${limit}/min)`,
|
|
552
|
+
false
|
|
553
|
+
);
|
|
347
554
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Committed + already-reserved in-flight usage + this estimate must fit
|
|
558
|
+
// under EACH bucket's cap. Decided and reserved in one transaction, so
|
|
559
|
+
// Convex's serializable isolation makes concurrent admission atomic across
|
|
560
|
+
// every capped bucket. Final usage can exceed the estimate; settlement then
|
|
561
|
+
// records the actual amount. Soft enforcement turns a violation into a
|
|
562
|
+
// warning instead of a block.
|
|
563
|
+
for (const b of buckets) {
|
|
564
|
+
const sameDay = b.dayStamp === today;
|
|
565
|
+
const sameMonth = b.monthStamp === month;
|
|
566
|
+
const ev = evaluateCaps({
|
|
567
|
+
label: b.dimension,
|
|
568
|
+
name: b.value,
|
|
569
|
+
enforcement: b.enforcement ?? "hard",
|
|
570
|
+
warnAtPct: b.warnAtPct ?? defaultWarnAtPct,
|
|
353
571
|
estCost: est.cost,
|
|
354
572
|
estTokens: est.tokens,
|
|
355
|
-
spendToday:
|
|
356
|
-
reservedSpendToday:
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
573
|
+
spendToday: sameDay ? b.spendTodayNanos : 0,
|
|
574
|
+
reservedSpendToday: sameDay ? b.reservedTodayNanos ?? 0 : 0,
|
|
575
|
+
spendThisMonth: sameMonth ? b.spendThisMonthNanos ?? 0 : 0,
|
|
576
|
+
reservedSpendMonth: sameMonth ? b.reservedMonthNanos ?? 0 : 0,
|
|
577
|
+
totalSpend: b.totalSpendNanos,
|
|
578
|
+
reservedSpendTotal: b.reservedTotalNanos ?? 0,
|
|
579
|
+
tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
|
|
580
|
+
reservedTokensToday: sameDay ? b.reservedTodayTokens ?? 0 : 0,
|
|
581
|
+
tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
|
|
582
|
+
reservedTokensMonth: sameMonth ? b.reservedMonthTokens ?? 0 : 0,
|
|
583
|
+
totalTokens: b.totalTokens,
|
|
584
|
+
reservedTokensTotal: b.reservedTotalTokens ?? 0,
|
|
363
585
|
dailySpendLimitNanos: withBump(
|
|
364
|
-
|
|
365
|
-
|
|
586
|
+
b.dailySpendLimitNanos,
|
|
587
|
+
b.bumpDayStamp === today ? b.dailyBumpNanos : 0
|
|
588
|
+
),
|
|
589
|
+
monthlySpendLimitNanos: withBump(
|
|
590
|
+
b.monthlySpendLimitNanos,
|
|
591
|
+
b.bumpMonthStamp === month ? b.monthlyBumpNanos : 0
|
|
366
592
|
),
|
|
367
593
|
lifetimeSpendLimitNanos: withBump(
|
|
368
|
-
|
|
369
|
-
|
|
594
|
+
b.lifetimeSpendLimitNanos,
|
|
595
|
+
b.lifetimeBumpNanos
|
|
370
596
|
),
|
|
371
|
-
dailyTokenLimit:
|
|
372
|
-
|
|
597
|
+
dailyTokenLimit: b.dailyTokenLimit,
|
|
598
|
+
monthlyTokenLimit: b.monthlyTokenLimit,
|
|
599
|
+
lifetimeTokenLimit: b.lifetimeTokenLimit,
|
|
373
600
|
});
|
|
374
|
-
if (
|
|
375
|
-
warnings.push(...
|
|
601
|
+
if (ev.hard) return reject(ev.hard.code, ev.hard.reason);
|
|
602
|
+
warnings.push(...ev.warnings);
|
|
603
|
+
notices.push(...ev.notices);
|
|
376
604
|
}
|
|
377
605
|
|
|
378
|
-
// Deployment-wide ("global") spend cap — same
|
|
379
|
-
//
|
|
380
|
-
// statement is uniform: a request is admitted only if
|
|
606
|
+
// Deployment-wide ("global") spend cap — the same estimated-usage admission
|
|
607
|
+
// rule as the per-bucket caps above: a request is admitted only if
|
|
381
608
|
// committed + reserved + estimate <= cap. The ONE difference is the holder:
|
|
382
|
-
// per-
|
|
383
|
-
//
|
|
609
|
+
// per-bucket caps reserve on a single row (an atomic check-and-reserve),
|
|
610
|
+
// while the global holder is a sharded counter for
|
|
384
611
|
// throughput — its committed total is read as an eventually-consistent sum
|
|
385
612
|
// with no cross-request reservation, so a hard global cap can overshoot by a
|
|
386
|
-
// bounded amount under burst. That's the deliberate
|
|
613
|
+
// bounded amount under burst. That's the deliberate consistency/throughput
|
|
387
614
|
// trade for a deployment-wide killswitch; it's the only approximate scope.
|
|
388
615
|
if (
|
|
389
|
-
|
|
390
|
-
(
|
|
391
|
-
|
|
616
|
+
settings &&
|
|
617
|
+
(settings.globalDailySpendLimitNanos !== undefined ||
|
|
618
|
+
settings.globalLifetimeSpendLimitNanos !== undefined)
|
|
392
619
|
) {
|
|
393
620
|
const globalEval = evaluateCaps({
|
|
394
621
|
label: "global",
|
|
395
622
|
name: "deployment",
|
|
396
|
-
enforcement:
|
|
623
|
+
enforcement: settings.globalEnforcement ?? "hard",
|
|
624
|
+
warnAtPct: defaultWarnAtPct,
|
|
397
625
|
estCost: est.cost,
|
|
398
626
|
estTokens: est.tokens,
|
|
399
627
|
spendToday: await globalSpend.count(ctx, globalDayKey(today)),
|
|
400
628
|
reservedSpendToday: 0, // sharded holder: no cross-request reservation
|
|
629
|
+
spendThisMonth: 0, // global tracks daily + lifetime only
|
|
630
|
+
reservedSpendMonth: 0,
|
|
401
631
|
totalSpend: await globalSpend.count(ctx, GLOBAL_TOTAL),
|
|
402
632
|
reservedSpendTotal: 0,
|
|
403
633
|
tokensToday: 0,
|
|
404
634
|
reservedTokensToday: 0,
|
|
635
|
+
tokensThisMonth: 0,
|
|
636
|
+
reservedTokensMonth: 0,
|
|
405
637
|
totalTokens: 0,
|
|
406
638
|
reservedTokensTotal: 0,
|
|
407
639
|
dailySpendLimitNanos: withBump(
|
|
408
|
-
|
|
409
|
-
|
|
640
|
+
settings.globalDailySpendLimitNanos,
|
|
641
|
+
settings.globalBumpDayStamp === today ? settings.globalDailyBumpNanos : 0
|
|
410
642
|
),
|
|
411
643
|
lifetimeSpendLimitNanos: withBump(
|
|
412
|
-
|
|
413
|
-
|
|
644
|
+
settings.globalLifetimeSpendLimitNanos,
|
|
645
|
+
settings.globalLifetimeBumpNanos
|
|
414
646
|
),
|
|
415
647
|
});
|
|
416
648
|
if (globalEval.hard) return reject(globalEval.hard.code, globalEval.hard.reason);
|
|
417
649
|
warnings.push(...globalEval.warnings);
|
|
650
|
+
notices.push(...globalEval.notices);
|
|
418
651
|
}
|
|
419
652
|
|
|
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
|
-
|
|
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, {
|
|
653
|
+
// Passed — reserve, but ONLY on buckets that actually have a cap. Writing an
|
|
654
|
+
// uncapped bucket's row here would serialize every request that shares it
|
|
655
|
+
// (e.g. all callers of one action, or every request in one env); with no cap
|
|
656
|
+
// there's no reserved amount to consult. Totals are still accrued later,
|
|
657
|
+
// asynchronously, in foldOne — for every bucket, capped or not.
|
|
658
|
+
for (const b of buckets) {
|
|
659
|
+
if (!needsReserve(b)) continue;
|
|
660
|
+
const sameDay = b.dayStamp === today;
|
|
661
|
+
const sameMonth = b.monthStamp === month;
|
|
662
|
+
await ctx.db.patch(b._id, {
|
|
439
663
|
dayStamp: today,
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
664
|
+
monthStamp: month,
|
|
665
|
+
spendTodayNanos: sameDay ? b.spendTodayNanos : 0,
|
|
666
|
+
tokensToday: sameDay ? b.tokensToday ?? 0 : 0,
|
|
667
|
+
spendThisMonthNanos: sameMonth ? b.spendThisMonthNanos ?? 0 : 0,
|
|
668
|
+
tokensThisMonth: sameMonth ? b.tokensThisMonth ?? 0 : 0,
|
|
669
|
+
reservedTodayNanos: (sameDay ? b.reservedTodayNanos ?? 0 : 0) + est.cost,
|
|
670
|
+
reservedMonthNanos: (sameMonth ? b.reservedMonthNanos ?? 0 : 0) + est.cost,
|
|
671
|
+
reservedTotalNanos: (b.reservedTotalNanos ?? 0) + est.cost,
|
|
672
|
+
reservedTodayTokens: (sameDay ? b.reservedTodayTokens ?? 0 : 0) + est.tokens,
|
|
673
|
+
reservedMonthTokens: (sameMonth ? b.reservedMonthTokens ?? 0 : 0) + est.tokens,
|
|
674
|
+
reservedTotalTokens: (b.reservedTotalTokens ?? 0) + est.tokens,
|
|
675
|
+
pendingCount: (b.pendingCount ?? 0) + 1,
|
|
447
676
|
});
|
|
448
677
|
}
|
|
449
678
|
const requestId = await ctx.db.insert("requests", {
|
|
450
|
-
|
|
679
|
+
userId: args.userId,
|
|
680
|
+
actionName: args.actionName,
|
|
681
|
+
...(extraTags.length ? { tags: extraTags } : {}),
|
|
682
|
+
model: args.model,
|
|
683
|
+
messages: args.messages,
|
|
684
|
+
rerunOf: args.rerunOf,
|
|
451
685
|
status: "pending",
|
|
452
686
|
estimatedNanos: est.cost,
|
|
453
687
|
estimatedTokens: est.tokens,
|
|
454
688
|
...(priceInfo.known ? {} : { unpricedModel: true }),
|
|
455
689
|
...(warnings.length > 0 ? { overBudget: true } : {}),
|
|
456
690
|
});
|
|
457
|
-
|
|
691
|
+
// Reverse index so the request log can be filtered by any extra tag
|
|
692
|
+
// dimension (user/action are already indexed on the requests table).
|
|
693
|
+
for (const t of extraTags) {
|
|
694
|
+
await ctx.db.insert("requestTags", {
|
|
695
|
+
dimension: t.dimension,
|
|
696
|
+
value: t.value,
|
|
697
|
+
requestId,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
return { allowed: true as const, requestId, warnings, notices };
|
|
458
701
|
},
|
|
459
702
|
});
|
|
460
703
|
|
|
@@ -466,6 +709,14 @@ export const finishRequest = mutation({
|
|
|
466
709
|
promptTokens: v.optional(v.number()),
|
|
467
710
|
completionTokens: v.optional(v.number()),
|
|
468
711
|
cachedTokens: v.optional(v.number()),
|
|
712
|
+
// Provider server-tool invocations that bill a per-call fee (e.g.
|
|
713
|
+
// { web_search: 3 }). Added to the token cost when no authoritative cost is
|
|
714
|
+
// supplied; recorded either way.
|
|
715
|
+
serverToolUses: v.optional(v.record(v.string(), v.number())),
|
|
716
|
+
// Authoritative cost from the gateway/provider, if reported. When present
|
|
717
|
+
// it's recorded verbatim (already includes any tool fees); when absent we
|
|
718
|
+
// price from tokens (cache-aware) plus server-tool fees.
|
|
719
|
+
costNanos: v.optional(v.number()),
|
|
469
720
|
latencyMs: v.optional(v.number()),
|
|
470
721
|
},
|
|
471
722
|
returns: v.object({ costNanos: v.number() }),
|
|
@@ -489,10 +740,22 @@ export const finishRequest = mutation({
|
|
|
489
740
|
const promptTokens = Math.max(0, args.promptTokens ?? 0);
|
|
490
741
|
const completionTokens = Math.max(0, args.completionTokens ?? 0);
|
|
491
742
|
const cachedTokens = Math.min(promptTokens, Math.max(0, args.cachedTokens ?? 0));
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
743
|
+
// Prefer an authoritative gateway cost when supplied (it already includes
|
|
744
|
+
// tool fees); otherwise price from tokens — discounting the cached
|
|
745
|
+
// (prompt-cache-read) slice — plus any server-tool per-call fees.
|
|
746
|
+
let costNanos: number;
|
|
747
|
+
if (args.costNanos !== undefined && args.costNanos >= 0) {
|
|
748
|
+
costNanos = Math.round(args.costNanos);
|
|
749
|
+
} else {
|
|
750
|
+
const settings = await getSettings(ctx);
|
|
751
|
+
costNanos =
|
|
752
|
+
settleCost(
|
|
753
|
+
promptTokens,
|
|
754
|
+
cachedTokens,
|
|
755
|
+
completionTokens,
|
|
756
|
+
await getPrice(ctx, request.model)
|
|
757
|
+
) + serverToolCost(args.serverToolUses, settings?.serverToolPrices);
|
|
758
|
+
}
|
|
496
759
|
|
|
497
760
|
// Durable write to the request's OWN row only — uncontended, so it always
|
|
498
761
|
// lands. `settled: false` hands it to the fold step; the row is never left
|
|
@@ -504,12 +767,13 @@ export const finishRequest = mutation({
|
|
|
504
767
|
promptTokens,
|
|
505
768
|
completionTokens,
|
|
506
769
|
...(cachedTokens > 0 ? { cachedTokens } : {}),
|
|
770
|
+
...(args.serverToolUses ? { serverToolUses: args.serverToolUses } : {}),
|
|
507
771
|
costNanos,
|
|
508
772
|
latencyMs: args.latencyMs,
|
|
509
773
|
settled: false,
|
|
510
774
|
});
|
|
511
775
|
|
|
512
|
-
// Fold into the (hot)
|
|
776
|
+
// Fold into the (hot) per-bucket counters in a separate mutation. If it
|
|
513
777
|
// exhausts retries under contention, the cron reconciler picks it up.
|
|
514
778
|
await ctx.scheduler.runAfter(0, internal.lib.foldTotals, {
|
|
515
779
|
requestId: args.requestId,
|
|
@@ -518,9 +782,9 @@ export const finishRequest = mutation({
|
|
|
518
782
|
},
|
|
519
783
|
});
|
|
520
784
|
|
|
521
|
-
// Fold one finished request into
|
|
522
|
-
// reservation. Idempotent: guarded by `settled` so the scheduler
|
|
523
|
-
// reconciler can never double-count.
|
|
785
|
+
// Fold one finished request into every attributed bucket's running totals,
|
|
786
|
+
// releasing its reservation. Idempotent: guarded by `settled` so the scheduler
|
|
787
|
+
// and the cron reconciler can never double-count.
|
|
524
788
|
async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
|
|
525
789
|
if (!req || req.settled !== false) return;
|
|
526
790
|
const actual = req.costNanos ?? 0;
|
|
@@ -528,48 +792,44 @@ async function foldOne(ctx: MutationCtx, req: Doc<"requests"> | null) {
|
|
|
528
792
|
const tokens = (req.promptTokens ?? 0) + (req.completionTokens ?? 0);
|
|
529
793
|
const estTokens = req.estimatedTokens ?? 0;
|
|
530
794
|
const today = dayStamp();
|
|
795
|
+
const month = monthStamp();
|
|
531
796
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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,
|
|
797
|
+
// Accrue into every attributed bucket (user, action, and each tag) — capped
|
|
798
|
+
// or not. Buckets that never held a reservation have their reserved fields
|
|
799
|
+
// clamped at 0 by Math.max, so subtracting an estimate is a harmless no-op.
|
|
800
|
+
// Also write the durable per-(bucket, day/month) usage rows that survive
|
|
801
|
+
// request retention, so spend history outlives the raw request log.
|
|
802
|
+
for (const t of requestBuckets(req.userId, req.actionName, req.tags)) {
|
|
803
|
+
const b = await getOrCreateBucket(ctx, t.dimension, t.value);
|
|
804
|
+
const sameDay = b.dayStamp === today;
|
|
805
|
+
const sameMonth = b.monthStamp === month;
|
|
806
|
+
await ctx.db.patch(b._id, {
|
|
807
|
+
totalSpendNanos: b.totalSpendNanos + actual,
|
|
808
|
+
totalRequests: b.totalRequests + 1,
|
|
809
|
+
totalTokens: b.totalTokens + tokens,
|
|
555
810
|
dayStamp: today,
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
811
|
+
monthStamp: month,
|
|
812
|
+
spendTodayNanos: (sameDay ? b.spendTodayNanos : 0) + actual,
|
|
813
|
+
tokensToday: (sameDay ? b.tokensToday ?? 0 : 0) + tokens,
|
|
814
|
+
spendThisMonthNanos: (sameMonth ? b.spendThisMonthNanos ?? 0 : 0) + actual,
|
|
815
|
+
tokensThisMonth: (sameMonth ? b.tokensThisMonth ?? 0 : 0) + tokens,
|
|
816
|
+
reservedTodayNanos: Math.max(0, (sameDay ? b.reservedTodayNanos ?? 0 : 0) - estCost),
|
|
817
|
+
reservedMonthNanos: Math.max(0, (sameMonth ? b.reservedMonthNanos ?? 0 : 0) - estCost),
|
|
818
|
+
reservedTotalNanos: Math.max(0, (b.reservedTotalNanos ?? 0) - estCost),
|
|
819
|
+
reservedTodayTokens: Math.max(0, (sameDay ? b.reservedTodayTokens ?? 0 : 0) - estTokens),
|
|
820
|
+
reservedMonthTokens: Math.max(0, (sameMonth ? b.reservedMonthTokens ?? 0 : 0) - estTokens),
|
|
821
|
+
reservedTotalTokens: Math.max(0, (b.reservedTotalTokens ?? 0) - estTokens),
|
|
822
|
+
pendingCount: Math.max(0, (b.pendingCount ?? 0) - 1),
|
|
563
823
|
});
|
|
824
|
+
await addUsage(ctx, t.dimension, t.value, "day", today, actual, tokens, 1);
|
|
825
|
+
await addUsage(ctx, t.dimension, t.value, "month", month, actual, tokens, 1);
|
|
564
826
|
}
|
|
827
|
+
|
|
565
828
|
// Deployment-wide totals via the sharded counter (only when a global cap is
|
|
566
829
|
// configured — otherwise skip the writes entirely). Distributed across shards,
|
|
567
830
|
// so this does not serialize on a single row.
|
|
568
831
|
if (actual > 0) {
|
|
569
|
-
const settings = await ctx
|
|
570
|
-
.query("settings")
|
|
571
|
-
.withIndex("key", (q) => q.eq("key", "singleton"))
|
|
572
|
-
.unique();
|
|
832
|
+
const settings = await getSettings(ctx);
|
|
573
833
|
if (
|
|
574
834
|
settings &&
|
|
575
835
|
(settings.globalDailySpendLimitNanos !== undefined ||
|
|
@@ -641,6 +901,7 @@ export const reconcile = internalMutation({
|
|
|
641
901
|
// Only rows that are done and accounted: folded (settled === true) or a
|
|
642
902
|
// blocked attempt (never needs folding). Never a pending/unfolded row.
|
|
643
903
|
if (req.settled === true || req.status === "blocked") {
|
|
904
|
+
await deleteRequestTags(ctx, req._id);
|
|
644
905
|
await ctx.db.delete(req._id);
|
|
645
906
|
purged++;
|
|
646
907
|
}
|
|
@@ -687,10 +948,19 @@ export const getRequest = query({
|
|
|
687
948
|
});
|
|
688
949
|
|
|
689
950
|
export const listRequests = query({
|
|
690
|
-
args: {
|
|
951
|
+
args: {
|
|
952
|
+
userId: v.optional(v.string()),
|
|
953
|
+
// Filter by any attribution dimension (user/action indexed on the request;
|
|
954
|
+
// custom tag dimensions resolved via the requestTags reverse index).
|
|
955
|
+
dimension: v.optional(v.string()),
|
|
956
|
+
value: v.optional(v.string()),
|
|
957
|
+
limit: v.optional(v.number()),
|
|
958
|
+
},
|
|
691
959
|
handler: async (ctx, args) => {
|
|
692
960
|
const limit = args.limit ?? 50;
|
|
693
|
-
|
|
961
|
+
const dim = args.dimension;
|
|
962
|
+
const val = args.value ?? args.userId;
|
|
963
|
+
if (dim === undefined && args.userId !== undefined) {
|
|
694
964
|
const userId = args.userId;
|
|
695
965
|
return await ctx.db
|
|
696
966
|
.query("requests")
|
|
@@ -698,161 +968,248 @@ export const listRequests = query({
|
|
|
698
968
|
.order("desc")
|
|
699
969
|
.take(limit);
|
|
700
970
|
}
|
|
971
|
+
if (dim !== undefined && val !== undefined) {
|
|
972
|
+
if (dim === USER_DIM) {
|
|
973
|
+
return await ctx.db
|
|
974
|
+
.query("requests")
|
|
975
|
+
.withIndex("userId", (q) => q.eq("userId", val))
|
|
976
|
+
.order("desc")
|
|
977
|
+
.take(limit);
|
|
978
|
+
}
|
|
979
|
+
if (dim === ACTION_DIM) {
|
|
980
|
+
return await ctx.db
|
|
981
|
+
.query("requests")
|
|
982
|
+
.withIndex("actionName", (q) => q.eq("actionName", val))
|
|
983
|
+
.order("desc")
|
|
984
|
+
.take(limit);
|
|
985
|
+
}
|
|
986
|
+
// Custom tag dimension: walk the reverse index, then fetch each request.
|
|
987
|
+
const tagRows = await ctx.db
|
|
988
|
+
.query("requestTags")
|
|
989
|
+
.withIndex("dim_value", (q) => q.eq("dimension", dim).eq("value", val))
|
|
990
|
+
.order("desc")
|
|
991
|
+
.take(limit);
|
|
992
|
+
const rows = await Promise.all(tagRows.map((t) => ctx.db.get(t.requestId)));
|
|
993
|
+
return rows.filter((r): r is Doc<"requests"> => r !== null);
|
|
994
|
+
}
|
|
701
995
|
return await ctx.db.query("requests").order("desc").take(limit);
|
|
702
996
|
},
|
|
703
997
|
});
|
|
704
998
|
|
|
705
999
|
const ADMIN_LIST_CAP = 2000;
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
1000
|
+
|
|
1001
|
+
// List budget buckets, optionally filtered to one dimension ("user", "action",
|
|
1002
|
+
// or any custom tag dimension). Today's spend is zeroed for stale day windows.
|
|
1003
|
+
export const listBuckets = query({
|
|
1004
|
+
args: { dimension: v.optional(v.string()) },
|
|
1005
|
+
handler: async (ctx, args) => {
|
|
709
1006
|
// Bounded to avoid an unbounded full-table scan on this reactive query.
|
|
710
|
-
// Paginate (ctx.db.query("
|
|
711
|
-
const
|
|
1007
|
+
// Paginate (ctx.db.query("buckets").paginate(...)) for larger deployments.
|
|
1008
|
+
const rows =
|
|
1009
|
+
args.dimension !== undefined
|
|
1010
|
+
? await ctx.db
|
|
1011
|
+
.query("buckets")
|
|
1012
|
+
.withIndex("dimension", (q) => q.eq("dimension", args.dimension!))
|
|
1013
|
+
.take(ADMIN_LIST_CAP)
|
|
1014
|
+
: await ctx.db.query("buckets").take(ADMIN_LIST_CAP);
|
|
712
1015
|
const today = dayStamp();
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1016
|
+
const month = monthStamp();
|
|
1017
|
+
return rows.map((b) => ({
|
|
1018
|
+
...b,
|
|
1019
|
+
spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
|
|
1020
|
+
spendThisMonthNanos: b.monthStamp === month ? b.spendThisMonthNanos ?? 0 : 0,
|
|
716
1021
|
}));
|
|
717
1022
|
},
|
|
718
1023
|
});
|
|
719
1024
|
|
|
720
|
-
export const
|
|
1025
|
+
export const getBucket = query({
|
|
1026
|
+
args: { dimension: v.string(), value: v.string() },
|
|
1027
|
+
handler: async (ctx, args) => {
|
|
1028
|
+
const b = await getBucketDoc(ctx as any, args.dimension, args.value);
|
|
1029
|
+
if (!b) return null;
|
|
1030
|
+
const today = dayStamp();
|
|
1031
|
+
const month = monthStamp();
|
|
1032
|
+
return {
|
|
1033
|
+
...b,
|
|
1034
|
+
spendTodayNanos: b.dayStamp === today ? b.spendTodayNanos : 0,
|
|
1035
|
+
spendThisMonthNanos: b.monthStamp === month ? b.spendThisMonthNanos ?? 0 : 0,
|
|
1036
|
+
};
|
|
1037
|
+
},
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// Set a bucket's limits/controls. `user` and `action` are just dimensions here;
|
|
1041
|
+
// the client's ai.users / ai.actions namespaces are thin wrappers over this.
|
|
1042
|
+
export const setBucketLimits = mutation({
|
|
721
1043
|
args: {
|
|
722
|
-
|
|
1044
|
+
dimension: v.string(),
|
|
1045
|
+
value: v.string(),
|
|
723
1046
|
requestsPerMinute: v.optional(v.number()),
|
|
1047
|
+
maxConcurrent: v.optional(v.number()),
|
|
724
1048
|
dailySpendLimitNanos: v.optional(v.number()),
|
|
1049
|
+
monthlySpendLimitNanos: v.optional(v.number()),
|
|
725
1050
|
lifetimeSpendLimitNanos: v.optional(v.number()),
|
|
726
1051
|
dailyTokenLimit: v.optional(v.number()),
|
|
1052
|
+
monthlyTokenLimit: v.optional(v.number()),
|
|
727
1053
|
lifetimeTokenLimit: v.optional(v.number()),
|
|
1054
|
+
warnAtPct: v.optional(v.number()),
|
|
728
1055
|
enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
|
|
729
1056
|
blocked: v.optional(v.boolean()),
|
|
730
1057
|
},
|
|
731
1058
|
returns: v.null(),
|
|
732
1059
|
handler: async (ctx, args) => {
|
|
733
|
-
const
|
|
734
|
-
const {
|
|
735
|
-
await ctx.db.patch(
|
|
1060
|
+
const bucket = await getOrCreateBucket(ctx, args.dimension, args.value);
|
|
1061
|
+
const { dimension: _d, value: _v, ...limits } = args;
|
|
1062
|
+
await ctx.db.patch(bucket._id, limits);
|
|
736
1063
|
return null;
|
|
737
1064
|
},
|
|
738
1065
|
});
|
|
739
1066
|
|
|
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
1067
|
const vBumpArgs = {
|
|
781
1068
|
dailyNanos: v.optional(v.number()),
|
|
1069
|
+
monthlyNanos: v.optional(v.number()),
|
|
782
1070
|
lifetimeNanos: v.optional(v.number()),
|
|
783
1071
|
};
|
|
784
1072
|
|
|
785
|
-
// One-time "approve another $X" bumps, added on top of
|
|
786
|
-
// changing it. Daily bumps apply to
|
|
787
|
-
|
|
788
|
-
|
|
1073
|
+
// One-time "approve another $X" bumps, added on top of a bucket's standing cap
|
|
1074
|
+
// without changing it. Daily/monthly bumps apply to the current window only;
|
|
1075
|
+
// lifetime bumps persist.
|
|
1076
|
+
export const bumpBucket = mutation({
|
|
1077
|
+
args: { dimension: v.string(), value: v.string(), ...vBumpArgs },
|
|
789
1078
|
returns: v.null(),
|
|
790
|
-
handler: async (ctx, {
|
|
791
|
-
const
|
|
1079
|
+
handler: async (ctx, { dimension, value, dailyNanos, monthlyNanos, lifetimeNanos }) => {
|
|
1080
|
+
const bucket = await getOrCreateBucket(ctx, dimension, value);
|
|
792
1081
|
const today = dayStamp();
|
|
793
|
-
const
|
|
794
|
-
|
|
1082
|
+
const month = monthStamp();
|
|
1083
|
+
const curDaily =
|
|
1084
|
+
bucket.bumpDayStamp === today ? bucket.dailyBumpNanos ?? 0 : 0;
|
|
1085
|
+
const curMonthly =
|
|
1086
|
+
bucket.bumpMonthStamp === month ? bucket.monthlyBumpNanos ?? 0 : 0;
|
|
1087
|
+
await ctx.db.patch(bucket._id, {
|
|
795
1088
|
bumpDayStamp: today,
|
|
796
1089
|
dailyBumpNanos: curDaily + (dailyNanos ?? 0),
|
|
797
|
-
|
|
1090
|
+
bumpMonthStamp: month,
|
|
1091
|
+
monthlyBumpNanos: curMonthly + (monthlyNanos ?? 0),
|
|
1092
|
+
lifetimeBumpNanos: (bucket.lifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
|
|
798
1093
|
});
|
|
799
1094
|
return null;
|
|
800
1095
|
},
|
|
801
1096
|
});
|
|
802
1097
|
|
|
803
|
-
|
|
804
|
-
|
|
1098
|
+
// Manually credit or debit a bucket (comp a user, correct an overcharge).
|
|
1099
|
+
// Negative deltaNanos = credit/refund, positive = extra charge. Adjusts the
|
|
1100
|
+
// live day/month/lifetime windows AND the durable usage history, and records an
|
|
1101
|
+
// audit row. Does not touch the global sharded total or reservations.
|
|
1102
|
+
export const adjustBucket = mutation({
|
|
1103
|
+
args: {
|
|
1104
|
+
dimension: v.string(),
|
|
1105
|
+
value: v.string(),
|
|
1106
|
+
deltaNanos: v.number(),
|
|
1107
|
+
tokens: v.optional(v.number()),
|
|
1108
|
+
reason: v.optional(v.string()),
|
|
1109
|
+
},
|
|
805
1110
|
returns: v.null(),
|
|
806
|
-
handler: async (ctx, {
|
|
807
|
-
const
|
|
1111
|
+
handler: async (ctx, { dimension, value, deltaNanos, tokens, reason }) => {
|
|
1112
|
+
const b = await getOrCreateBucket(ctx, dimension, value);
|
|
808
1113
|
const today = dayStamp();
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1114
|
+
const month = monthStamp();
|
|
1115
|
+
const dSame = b.dayStamp === today;
|
|
1116
|
+
const mSame = b.monthStamp === month;
|
|
1117
|
+
const dt = tokens ?? 0;
|
|
1118
|
+
await ctx.db.patch(b._id, {
|
|
1119
|
+
totalSpendNanos: Math.max(0, b.totalSpendNanos + deltaNanos),
|
|
1120
|
+
totalTokens: Math.max(0, b.totalTokens + dt),
|
|
1121
|
+
dayStamp: today,
|
|
1122
|
+
monthStamp: month,
|
|
1123
|
+
spendTodayNanos: Math.max(0, (dSame ? b.spendTodayNanos : 0) + deltaNanos),
|
|
1124
|
+
tokensToday: Math.max(0, (dSame ? b.tokensToday ?? 0 : 0) + dt),
|
|
1125
|
+
spendThisMonthNanos: Math.max(0, (mSame ? b.spendThisMonthNanos ?? 0 : 0) + deltaNanos),
|
|
1126
|
+
tokensThisMonth: Math.max(0, (mSame ? b.tokensThisMonth ?? 0 : 0) + dt),
|
|
814
1127
|
});
|
|
1128
|
+
await ctx.db.insert("adjustments", { dimension, value, deltaNanos, tokens: dt, reason });
|
|
1129
|
+
await addUsage(ctx, dimension, value, "day", today, deltaNanos, dt, 0);
|
|
1130
|
+
await addUsage(ctx, dimension, value, "month", month, deltaNanos, dt, 0);
|
|
815
1131
|
return null;
|
|
816
1132
|
},
|
|
817
1133
|
});
|
|
818
1134
|
|
|
819
|
-
export const
|
|
820
|
-
args:
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
globalBumpDayStamp: today,
|
|
828
|
-
globalDailyBumpNanos: curDaily + (dailyNanos ?? 0),
|
|
829
|
-
globalLifetimeBumpNanos: (s?.globalLifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
|
|
830
|
-
};
|
|
831
|
-
if (s) await ctx.db.patch(s._id, patch);
|
|
832
|
-
else await ctx.db.insert("settings", { key: "singleton", ...patch });
|
|
833
|
-
return null;
|
|
834
|
-
},
|
|
1135
|
+
export const listAdjustments = query({
|
|
1136
|
+
args: { dimension: v.string(), value: v.string(), limit: v.optional(v.number()) },
|
|
1137
|
+
handler: async (ctx, { dimension, value, limit }) =>
|
|
1138
|
+
ctx.db
|
|
1139
|
+
.query("adjustments")
|
|
1140
|
+
.withIndex("dim_value", (q) => q.eq("dimension", dimension).eq("value", value))
|
|
1141
|
+
.order("desc")
|
|
1142
|
+
.take(limit ?? 50),
|
|
835
1143
|
});
|
|
836
1144
|
|
|
837
|
-
|
|
1145
|
+
// Durable spend history for a bucket: per-day or per-month rows, newest first.
|
|
1146
|
+
// Survives request retention.
|
|
1147
|
+
export const usageHistory = query({
|
|
838
1148
|
args: {
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
lifetimeTokenLimit: v.optional(v.number()),
|
|
844
|
-
enforcement: v.optional(v.union(v.literal("hard"), v.literal("soft"))),
|
|
845
|
-
disabled: v.optional(v.boolean()),
|
|
1149
|
+
dimension: v.string(),
|
|
1150
|
+
value: v.string(),
|
|
1151
|
+
period: v.union(v.literal("day"), v.literal("month")),
|
|
1152
|
+
limit: v.optional(v.number()),
|
|
846
1153
|
},
|
|
1154
|
+
handler: async (ctx, { dimension, value, period, limit }) =>
|
|
1155
|
+
ctx.db
|
|
1156
|
+
.query("usage")
|
|
1157
|
+
.withIndex("bucket_period_stamp", (q) =>
|
|
1158
|
+
q.eq("dimension", dimension).eq("value", value).eq("period", period)
|
|
1159
|
+
)
|
|
1160
|
+
.order("desc")
|
|
1161
|
+
.take(limit ?? 90),
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
// Deployment-wide default threshold for approaching-limit alerts (fraction of a
|
|
1165
|
+
// cap, e.g. 0.8). Buckets can override with their own warnAtPct.
|
|
1166
|
+
export const setAlertDefaults = mutation({
|
|
1167
|
+
args: { warnAtPct: v.optional(v.number()) },
|
|
847
1168
|
returns: v.null(),
|
|
848
|
-
handler: async (ctx,
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
await ctx.db.
|
|
1169
|
+
handler: async (ctx, { warnAtPct }) => {
|
|
1170
|
+
const existing = await getSettings(ctx);
|
|
1171
|
+
if (existing) await ctx.db.patch(existing._id, { defaultWarnAtPct: warnAtPct });
|
|
1172
|
+
else await ctx.db.insert("settings", { key: "singleton", defaultWarnAtPct: warnAtPct });
|
|
852
1173
|
return null;
|
|
853
1174
|
},
|
|
854
1175
|
});
|
|
855
1176
|
|
|
1177
|
+
// Delete a bucket and (for the `user` dimension) all of that user's request
|
|
1178
|
+
// rows — e.g. account deletion / GDPR. Deletes requests in bounded batches and
|
|
1179
|
+
// self-reschedules so it never exceeds the per-transaction document limit.
|
|
1180
|
+
const DELETE_BATCH = 500;
|
|
1181
|
+
export const deleteBucket = mutation({
|
|
1182
|
+
args: { dimension: v.string(), value: v.string() },
|
|
1183
|
+
returns: v.object({ deletedThisBatch: v.number(), done: v.boolean() }),
|
|
1184
|
+
handler: async (ctx, { dimension, value }) => {
|
|
1185
|
+
// Only the user dimension owns request rows (indexed by userId). Other
|
|
1186
|
+
// dimensions just drop their budget-holder row.
|
|
1187
|
+
if (dimension === USER_DIM) {
|
|
1188
|
+
const rows = await ctx.db
|
|
1189
|
+
.query("requests")
|
|
1190
|
+
.withIndex("userId", (q) => q.eq("userId", value))
|
|
1191
|
+
.take(DELETE_BATCH);
|
|
1192
|
+
for (const r of rows) {
|
|
1193
|
+
await deleteRequestTags(ctx, r._id);
|
|
1194
|
+
await ctx.db.delete(r._id);
|
|
1195
|
+
}
|
|
1196
|
+
if (rows.length === DELETE_BATCH) {
|
|
1197
|
+
await ctx.scheduler.runAfter(0, api.lib.deleteBucket, {
|
|
1198
|
+
dimension,
|
|
1199
|
+
value,
|
|
1200
|
+
});
|
|
1201
|
+
return { deletedThisBatch: rows.length, done: false };
|
|
1202
|
+
}
|
|
1203
|
+
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
1204
|
+
if (bucket) await ctx.db.delete(bucket._id);
|
|
1205
|
+
return { deletedThisBatch: rows.length + (bucket ? 1 : 0), done: true };
|
|
1206
|
+
}
|
|
1207
|
+
const bucket = await getBucketDoc(ctx, dimension, value);
|
|
1208
|
+
if (bucket) await ctx.db.delete(bucket._id);
|
|
1209
|
+
return { deletedThisBatch: bucket ? 1 : 0, done: true };
|
|
1210
|
+
},
|
|
1211
|
+
});
|
|
1212
|
+
|
|
856
1213
|
export const getModelPolicy = query({
|
|
857
1214
|
args: {},
|
|
858
1215
|
returns: v.object({
|
|
@@ -880,6 +1237,9 @@ export const getGlobalStatus = query({
|
|
|
880
1237
|
enforcement: v.union(v.literal("hard"), v.literal("soft")),
|
|
881
1238
|
spentTodayNanos: v.number(),
|
|
882
1239
|
spentTotalNanos: v.number(),
|
|
1240
|
+
// deployment-wide config (surfaced for the admin dashboard)
|
|
1241
|
+
retentionMs: v.union(v.number(), v.null()),
|
|
1242
|
+
defaultWarnAtPct: v.union(v.number(), v.null()),
|
|
883
1243
|
}),
|
|
884
1244
|
handler: async (ctx) => {
|
|
885
1245
|
const s = await ctx.db
|
|
@@ -892,6 +1252,8 @@ export const getGlobalStatus = query({
|
|
|
892
1252
|
enforcement: s?.globalEnforcement ?? "hard",
|
|
893
1253
|
spentTodayNanos: await globalSpend.count(ctx, globalDayKey(dayStamp())),
|
|
894
1254
|
spentTotalNanos: await globalSpend.count(ctx, GLOBAL_TOTAL),
|
|
1255
|
+
retentionMs: s?.retentionMs ?? null,
|
|
1256
|
+
defaultWarnAtPct: s?.defaultWarnAtPct ?? null,
|
|
895
1257
|
};
|
|
896
1258
|
},
|
|
897
1259
|
});
|
|
@@ -920,6 +1282,24 @@ export const setGlobalLimits = mutation({
|
|
|
920
1282
|
},
|
|
921
1283
|
});
|
|
922
1284
|
|
|
1285
|
+
export const bumpGlobal = mutation({
|
|
1286
|
+
args: vBumpArgs,
|
|
1287
|
+
returns: v.null(),
|
|
1288
|
+
handler: async (ctx, { dailyNanos, lifetimeNanos }) => {
|
|
1289
|
+
const today = dayStamp();
|
|
1290
|
+
const s = await getSettings(ctx);
|
|
1291
|
+
const curDaily = s?.globalBumpDayStamp === today ? s?.globalDailyBumpNanos ?? 0 : 0;
|
|
1292
|
+
const patch = {
|
|
1293
|
+
globalBumpDayStamp: today,
|
|
1294
|
+
globalDailyBumpNanos: curDaily + (dailyNanos ?? 0),
|
|
1295
|
+
globalLifetimeBumpNanos: (s?.globalLifetimeBumpNanos ?? 0) + (lifetimeNanos ?? 0),
|
|
1296
|
+
};
|
|
1297
|
+
if (s) await ctx.db.patch(s._id, patch);
|
|
1298
|
+
else await ctx.db.insert("settings", { key: "singleton", ...patch });
|
|
1299
|
+
return null;
|
|
1300
|
+
},
|
|
1301
|
+
});
|
|
1302
|
+
|
|
923
1303
|
export const setModelPolicy = mutation({
|
|
924
1304
|
args: {
|
|
925
1305
|
mode: v.union(
|
|
@@ -953,12 +1333,18 @@ export const setPrice = mutation({
|
|
|
953
1333
|
model: v.string(),
|
|
954
1334
|
inputNanosPerMTok: v.number(),
|
|
955
1335
|
outputNanosPerMTok: v.number(),
|
|
1336
|
+
// optional cache-read rate; if omitted, a default discount off input applies
|
|
1337
|
+
cachedNanosPerMTok: v.optional(v.number()),
|
|
956
1338
|
},
|
|
957
1339
|
returns: v.null(),
|
|
958
1340
|
handler: async (ctx, args) => {
|
|
959
1341
|
// Negative prices would make costOf return a negative cost, which folds
|
|
960
1342
|
// into totals as a spend *refund* — pushing a user back under their cap.
|
|
961
|
-
if (
|
|
1343
|
+
if (
|
|
1344
|
+
args.inputNanosPerMTok < 0 ||
|
|
1345
|
+
args.outputNanosPerMTok < 0 ||
|
|
1346
|
+
(args.cachedNanosPerMTok ?? 0) < 0
|
|
1347
|
+
) {
|
|
962
1348
|
throw new Error("Prices must be non-negative");
|
|
963
1349
|
}
|
|
964
1350
|
const existing = await ctx.db
|
|
@@ -978,7 +1364,10 @@ export const listPrices = query({
|
|
|
978
1364
|
args: {},
|
|
979
1365
|
handler: async (ctx) => {
|
|
980
1366
|
const overrides = await ctx.db.query("prices").collect();
|
|
981
|
-
const merged: Record<
|
|
1367
|
+
const merged: Record<
|
|
1368
|
+
string,
|
|
1369
|
+
{ input: number; output: number; cached?: number; overridden: boolean }
|
|
1370
|
+
> = {};
|
|
982
1371
|
for (const [model, p] of Object.entries(DEFAULT_PRICES)) {
|
|
983
1372
|
merged[model] = { ...p, overridden: false };
|
|
984
1373
|
}
|
|
@@ -986,9 +1375,33 @@ export const listPrices = query({
|
|
|
986
1375
|
merged[o.model] = {
|
|
987
1376
|
input: o.inputNanosPerMTok,
|
|
988
1377
|
output: o.outputNanosPerMTok,
|
|
1378
|
+
cached: o.cachedNanosPerMTok,
|
|
989
1379
|
overridden: true,
|
|
990
1380
|
};
|
|
991
1381
|
}
|
|
992
1382
|
return merged;
|
|
993
1383
|
},
|
|
994
1384
|
});
|
|
1385
|
+
|
|
1386
|
+
// Per-call fees for provider server tools (web search, etc.), defaults merged
|
|
1387
|
+
// with any deployment overrides.
|
|
1388
|
+
export const listServerToolPrices = query({
|
|
1389
|
+
args: {},
|
|
1390
|
+
handler: async (ctx) => {
|
|
1391
|
+
const s = await getSettings(ctx as any);
|
|
1392
|
+
return { ...DEFAULT_SERVER_TOOL_PRICES, ...(s?.serverToolPrices ?? {}) };
|
|
1393
|
+
},
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
export const setServerToolPrice = mutation({
|
|
1397
|
+
args: { tool: v.string(), nanosPerCall: v.number() },
|
|
1398
|
+
returns: v.null(),
|
|
1399
|
+
handler: async (ctx, { tool, nanosPerCall }) => {
|
|
1400
|
+
if (nanosPerCall < 0) throw new Error("Prices must be non-negative");
|
|
1401
|
+
const s = await getSettings(ctx);
|
|
1402
|
+
const serverToolPrices = { ...(s?.serverToolPrices ?? {}), [tool]: nanosPerCall };
|
|
1403
|
+
if (s) await ctx.db.patch(s._id, { serverToolPrices });
|
|
1404
|
+
else await ctx.db.insert("settings", { key: "singleton", serverToolPrices });
|
|
1405
|
+
return null;
|
|
1406
|
+
},
|
|
1407
|
+
});
|