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