@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/client/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { httpActionGeneric, } from "convex/server";
|
|
1
2
|
import { ConvexError } from "convex/values";
|
|
2
3
|
import { generateText, wrapLanguageModel } from "ai";
|
|
3
4
|
import { convexGateway } from "@convex-dev/ai-sdk-provider";
|
|
5
|
+
import { DASHBOARD_HTML } from "./dashboard";
|
|
4
6
|
// The calling Convex action's name (e.g. "ai:sendMessage"), unless overridden.
|
|
5
7
|
async function resolveActionName(ctx, explicit) {
|
|
6
8
|
if (explicit !== undefined)
|
|
@@ -39,14 +41,35 @@ function extractUsage(usage) {
|
|
|
39
41
|
return {
|
|
40
42
|
promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens),
|
|
41
43
|
completionTokens: toTokenCount(usage?.outputTokens ?? usage?.completionTokens),
|
|
42
|
-
// cached prompt tokens
|
|
43
|
-
// `
|
|
44
|
-
|
|
44
|
+
// cached prompt tokens. The Convex gateway reports these at
|
|
45
|
+
// `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
|
|
46
|
+
// (`cachedInputTokens`) and raw OpenAI-compatible shapes.
|
|
47
|
+
cachedTokens: toTokenCount(usage?.inputTokenDetails?.cacheReadTokens ??
|
|
48
|
+
usage?.cachedInputTokens ??
|
|
45
49
|
usage?.promptTokensDetails?.cachedTokens ??
|
|
46
50
|
usage?.prompt_tokens_details?.cached_tokens ??
|
|
47
51
|
usage?.cached_tokens),
|
|
48
52
|
};
|
|
49
53
|
}
|
|
54
|
+
// The AI Gateway reports the authoritative dollar cost of each request.
|
|
55
|
+
// @convex-dev/ai-sdk-provider surfaces it at
|
|
56
|
+
// `providerMetadata.convexGateway.cost` (USD); convert to nanodollars and pass
|
|
57
|
+
// it through as authoritative (finishRequest prefers it over the token-based
|
|
58
|
+
// estimate). No-op on older provider versions that don't surface it.
|
|
59
|
+
function extractGatewayCostNanos(result) {
|
|
60
|
+
const meta = result?.providerMetadata?.convexGateway;
|
|
61
|
+
const costUsd = meta?.cost;
|
|
62
|
+
if (typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd >= 0) {
|
|
63
|
+
return Math.round(costUsd * NANOS_PER_DOLLAR);
|
|
64
|
+
}
|
|
65
|
+
// Back-compat: honor an explicitly nano-denominated field if ever present.
|
|
66
|
+
const costNanos = meta?.costNanos ?? result?.usage?.costNanos;
|
|
67
|
+
if (typeof costNanos === "number" && Number.isFinite(costNanos) && costNanos >= 0) {
|
|
68
|
+
return Math.round(costNanos);
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const NANOS_PER_DOLLAR = 1e9;
|
|
50
73
|
// Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
|
|
51
74
|
function simplifyPrompt(prompt) {
|
|
52
75
|
if (!Array.isArray(prompt))
|
|
@@ -79,42 +102,89 @@ function extractText(result) {
|
|
|
79
102
|
return "";
|
|
80
103
|
}
|
|
81
104
|
// ---------- client ----------
|
|
105
|
+
// Length-independent-branch string compare, so the dashboard token check
|
|
106
|
+
// doesn't leak the token via response timing. (Length itself is not secret.)
|
|
107
|
+
function timingSafeEqual(a, b) {
|
|
108
|
+
if (a.length !== b.length)
|
|
109
|
+
return false;
|
|
110
|
+
let r = 0;
|
|
111
|
+
for (let i = 0; i < a.length; i++)
|
|
112
|
+
r |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
113
|
+
return r === 0;
|
|
114
|
+
}
|
|
82
115
|
export class AIBudget {
|
|
83
116
|
component;
|
|
84
117
|
defaultModel;
|
|
85
118
|
onSoftLimit;
|
|
119
|
+
onThreshold;
|
|
120
|
+
onLimitReached;
|
|
86
121
|
constructor(component, options) {
|
|
87
122
|
this.component = component;
|
|
88
123
|
this.defaultModel = options?.defaultModel ?? "openai/gpt-4o-mini";
|
|
89
124
|
this.onSoftLimit = options?.onSoftLimit;
|
|
125
|
+
this.onThreshold = options?.onThreshold;
|
|
126
|
+
this.onLimitReached = options?.onLimitReached;
|
|
127
|
+
}
|
|
128
|
+
// Fire the soft-limit + threshold callbacks from a startRequest result.
|
|
129
|
+
async fireBudgetEvents(base, warnings, notices) {
|
|
130
|
+
if (warnings.length > 0 && this.onSoftLimit) {
|
|
131
|
+
try {
|
|
132
|
+
await this.onSoftLimit({ ...base, messages: warnings, warnings });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* never let a callback error break a request */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (notices.length > 0 && this.onThreshold) {
|
|
139
|
+
try {
|
|
140
|
+
await this.onThreshold({ ...base, messages: notices });
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
/* swallow */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
90
146
|
}
|
|
91
|
-
async
|
|
92
|
-
if (
|
|
147
|
+
async fireLimitReached(info) {
|
|
148
|
+
if (!this.onLimitReached)
|
|
93
149
|
return;
|
|
94
150
|
try {
|
|
95
|
-
await this.
|
|
151
|
+
await this.onLimitReached(info);
|
|
96
152
|
}
|
|
97
153
|
catch {
|
|
98
|
-
|
|
154
|
+
/* swallow */
|
|
99
155
|
}
|
|
100
156
|
}
|
|
101
157
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
158
|
+
* Meter ANY LLM call — gateway, a provider SDK, a raw fetch — with the same
|
|
159
|
+
* budgets, audit log, and cost tracking. Reserves before your `run` (throwing
|
|
160
|
+
* a ConvexError over a hard cap), runs it, then records the actual usage/cost.
|
|
161
|
+
* This is the provider-agnostic core; `chat` is sugar over it for the gateway.
|
|
162
|
+
*
|
|
163
|
+
* `run` returns what happened. Pass a raw provider `usage` object (auto-
|
|
164
|
+
* normalized) OR explicit `promptTokens`/`completionTokens`/`cachedTokens`,
|
|
165
|
+
* plus optional `serverToolUses` (e.g. `{ web_search: 3 }`, priced on top of
|
|
166
|
+
* tokens) and an authoritative `costNanos` (used verbatim if present).
|
|
104
167
|
*/
|
|
105
|
-
async
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
const actionName = await resolveActionName(ctx, args.action);
|
|
109
|
-
const messages = args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
|
|
168
|
+
async meter(ctx, opts, run) {
|
|
169
|
+
const userId = await resolveUserId(ctx, opts.userId);
|
|
170
|
+
const actionName = await resolveActionName(ctx, opts.action);
|
|
110
171
|
const started = await ctx.runMutation(this.component.lib.startRequest, {
|
|
111
172
|
userId,
|
|
112
173
|
actionName,
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
174
|
+
tags: opts.tags,
|
|
175
|
+
model: opts.model,
|
|
176
|
+
messages: opts.messages,
|
|
177
|
+
rerunOf: opts.rerunOf,
|
|
116
178
|
});
|
|
117
179
|
if (!started.allowed) {
|
|
180
|
+
await this.fireLimitReached({
|
|
181
|
+
userId,
|
|
182
|
+
action: actionName,
|
|
183
|
+
tags: opts.tags,
|
|
184
|
+
messages: [started.reason],
|
|
185
|
+
code: started.code,
|
|
186
|
+
reason: started.reason,
|
|
187
|
+
});
|
|
118
188
|
throw new ConvexError({
|
|
119
189
|
kind: "AIBudgetLimit",
|
|
120
190
|
code: started.code,
|
|
@@ -122,30 +192,30 @@ export class AIBudget {
|
|
|
122
192
|
});
|
|
123
193
|
}
|
|
124
194
|
const requestId = started.requestId;
|
|
125
|
-
const warnings = started
|
|
126
|
-
await this.
|
|
195
|
+
const { warnings, notices } = started;
|
|
196
|
+
await this.fireBudgetEvents({ userId, action: actionName, tags: opts.tags, requestId }, warnings, notices);
|
|
127
197
|
const start = Date.now();
|
|
128
198
|
try {
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
const
|
|
132
|
-
.
|
|
133
|
-
.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
});
|
|
141
|
-
const usage = extractUsage(result.usage);
|
|
199
|
+
const out = await run();
|
|
200
|
+
// Explicit token fields win; otherwise normalize a raw provider usage.
|
|
201
|
+
const usage = out.promptTokens !== undefined ||
|
|
202
|
+
out.completionTokens !== undefined ||
|
|
203
|
+
out.cachedTokens !== undefined
|
|
204
|
+
? {
|
|
205
|
+
promptTokens: out.promptTokens ?? 0,
|
|
206
|
+
completionTokens: out.completionTokens ?? 0,
|
|
207
|
+
cachedTokens: out.cachedTokens ?? 0,
|
|
208
|
+
}
|
|
209
|
+
: extractUsage(out.usage);
|
|
142
210
|
const { costNanos } = await ctx.runMutation(this.component.lib.finishRequest, {
|
|
143
211
|
requestId,
|
|
144
|
-
responseText:
|
|
212
|
+
responseText: out.text,
|
|
145
213
|
...usage,
|
|
214
|
+
serverToolUses: out.serverToolUses,
|
|
215
|
+
costNanos: out.costNanos,
|
|
146
216
|
latencyMs: Date.now() - start,
|
|
147
217
|
});
|
|
148
|
-
return { text:
|
|
218
|
+
return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
|
|
149
219
|
}
|
|
150
220
|
catch (e) {
|
|
151
221
|
await ctx.runMutation(this.component.lib.finishRequest, {
|
|
@@ -156,6 +226,40 @@ export class AIBudget {
|
|
|
156
226
|
throw e;
|
|
157
227
|
}
|
|
158
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* One-shot chat through the AI Gateway with tracking + limits — sugar over
|
|
231
|
+
* `meter`. Call from an action. `userId` defaults to the authenticated caller.
|
|
232
|
+
*/
|
|
233
|
+
async chat(ctx, args = {}) {
|
|
234
|
+
const model = args.model ?? this.defaultModel;
|
|
235
|
+
const messages = args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
|
|
236
|
+
return this.meter(ctx, {
|
|
237
|
+
model,
|
|
238
|
+
messages,
|
|
239
|
+
userId: args.userId,
|
|
240
|
+
action: args.action,
|
|
241
|
+
tags: args.tags,
|
|
242
|
+
rerunOf: args.rerunOf,
|
|
243
|
+
}, async () => {
|
|
244
|
+
// The full chain (incl. system) is stored for audit/replay, but the AI
|
|
245
|
+
// SDK wants system prompts in the `system` option, not messages.
|
|
246
|
+
const system = messages
|
|
247
|
+
.filter((m) => m.role === "system")
|
|
248
|
+
.map((m) => m.content)
|
|
249
|
+
.join("\n\n") || undefined;
|
|
250
|
+
const convo = messages.filter((m) => m.role !== "system");
|
|
251
|
+
const result = await generateText({
|
|
252
|
+
model: convexGateway(model),
|
|
253
|
+
...(system ? { system } : {}),
|
|
254
|
+
messages: convo,
|
|
255
|
+
});
|
|
256
|
+
return {
|
|
257
|
+
text: result.text,
|
|
258
|
+
usage: result.usage,
|
|
259
|
+
costNanos: extractGatewayCostNanos(result),
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
}
|
|
159
263
|
/**
|
|
160
264
|
* An AI SDK LanguageModel that enforces limits and records usage/cost for
|
|
161
265
|
* `userId` on every call. Drop it into `generateText`, `streamText`, or the
|
|
@@ -165,29 +269,33 @@ export class AIBudget {
|
|
|
165
269
|
languageModel(ctx, opts = {}) {
|
|
166
270
|
const modelId = opts.model ?? this.defaultModel;
|
|
167
271
|
const component = this.component;
|
|
168
|
-
const
|
|
272
|
+
const fireBudgetEvents = this.fireBudgetEvents.bind(this);
|
|
273
|
+
const fireLimitReached = this.fireLimitReached.bind(this);
|
|
169
274
|
const begin = async (params) => {
|
|
170
275
|
const userId = await resolveUserId(ctx, opts.userId);
|
|
171
276
|
const actionName = await resolveActionName(ctx, opts.action);
|
|
277
|
+
const base = { userId, action: actionName, tags: opts.tags };
|
|
172
278
|
const started = await ctx.runMutation(component.lib.startRequest, {
|
|
173
279
|
userId,
|
|
174
280
|
actionName,
|
|
281
|
+
tags: opts.tags,
|
|
175
282
|
model: modelId,
|
|
176
283
|
messages: simplifyPrompt(params.prompt),
|
|
177
284
|
});
|
|
178
285
|
if (!started.allowed) {
|
|
286
|
+
await fireLimitReached({
|
|
287
|
+
...base,
|
|
288
|
+
messages: [started.reason],
|
|
289
|
+
code: started.code,
|
|
290
|
+
reason: started.reason,
|
|
291
|
+
});
|
|
179
292
|
throw new ConvexError({
|
|
180
293
|
kind: "AIBudgetLimit",
|
|
181
294
|
code: started.code,
|
|
182
295
|
reason: started.reason,
|
|
183
296
|
});
|
|
184
297
|
}
|
|
185
|
-
await
|
|
186
|
-
userId,
|
|
187
|
-
action: actionName,
|
|
188
|
-
requestId: started.requestId,
|
|
189
|
-
warnings: started.warnings,
|
|
190
|
-
});
|
|
298
|
+
await fireBudgetEvents({ ...base, requestId: started.requestId }, started.warnings, started.notices);
|
|
191
299
|
return started.requestId;
|
|
192
300
|
};
|
|
193
301
|
const finish = async (requestId, fields) => ctx.runMutation(component.lib.finishRequest, { requestId, ...fields });
|
|
@@ -202,6 +310,7 @@ export class AIBudget {
|
|
|
202
310
|
await finish(requestId, {
|
|
203
311
|
responseText: extractText(result),
|
|
204
312
|
...extractUsage(result.usage),
|
|
313
|
+
costNanos: extractGatewayCostNanos(result),
|
|
205
314
|
latencyMs: Date.now() - start,
|
|
206
315
|
});
|
|
207
316
|
return result;
|
|
@@ -219,6 +328,7 @@ export class AIBudget {
|
|
|
219
328
|
const start = Date.now();
|
|
220
329
|
let text = "";
|
|
221
330
|
let usage = undefined;
|
|
331
|
+
let providerMetadata = undefined;
|
|
222
332
|
try {
|
|
223
333
|
const result = await doStream();
|
|
224
334
|
// finishRequest is idempotent (terminal-guarded server-side), so
|
|
@@ -235,6 +345,7 @@ export class AIBudget {
|
|
|
235
345
|
responseText: text,
|
|
236
346
|
error,
|
|
237
347
|
...extractUsage(usage),
|
|
348
|
+
costNanos: extractGatewayCostNanos({ providerMetadata }),
|
|
238
349
|
latencyMs: Date.now() - start,
|
|
239
350
|
});
|
|
240
351
|
};
|
|
@@ -243,8 +354,10 @@ export class AIBudget {
|
|
|
243
354
|
if (chunk?.type === "text-delta") {
|
|
244
355
|
text += chunk.delta ?? chunk.textDelta ?? "";
|
|
245
356
|
}
|
|
246
|
-
if (chunk?.type === "finish")
|
|
357
|
+
if (chunk?.type === "finish") {
|
|
247
358
|
usage = chunk.usage;
|
|
359
|
+
providerMetadata = chunk.providerMetadata ?? providerMetadata;
|
|
360
|
+
}
|
|
248
361
|
if (chunk?.type === "error")
|
|
249
362
|
void settle(String(chunk.error));
|
|
250
363
|
controller.enqueue(chunk);
|
|
@@ -278,6 +391,7 @@ export class AIBudget {
|
|
|
278
391
|
messages: args.messages ?? original.messages,
|
|
279
392
|
rerunOf: args.requestId,
|
|
280
393
|
action: original.actionName,
|
|
394
|
+
tags: original.tags,
|
|
281
395
|
});
|
|
282
396
|
}
|
|
283
397
|
// ---------- namespaced admin API ----------
|
|
@@ -285,35 +399,88 @@ export class AIBudget {
|
|
|
285
399
|
get requests() {
|
|
286
400
|
const c = this.component;
|
|
287
401
|
return {
|
|
402
|
+
/** Filter by userId, or by any {dimension, value} (incl. custom tags). */
|
|
288
403
|
list: (ctx, args = {}) => ctx.runQuery(c.lib.listRequests, args),
|
|
404
|
+
/** One request, including its stored prompt and response. */
|
|
405
|
+
get: (ctx, args) => ctx.runQuery(c.lib.getRequest, { requestId: args.requestId }),
|
|
289
406
|
/** Ancestors up to the original, plus direct re-runs. */
|
|
290
407
|
lineage: (ctx, args) => ctx.runQuery(c.lib.lineage, { requestId: args.requestId }),
|
|
291
408
|
/** Replay a stored request, optionally with edited messages/model. */
|
|
292
409
|
rerun: (ctx, args) => this.rerunImpl(ctx, args),
|
|
293
410
|
};
|
|
294
411
|
}
|
|
295
|
-
/**
|
|
296
|
-
|
|
412
|
+
/**
|
|
413
|
+
* Budgets and controls for an arbitrary attribution dimension — the
|
|
414
|
+
* generalization of `users`/`actions`. Give it any dimension name (team,
|
|
415
|
+
* project, tenant, customer, env, feature, …) and set caps per value:
|
|
416
|
+
*
|
|
417
|
+
* ai.tag("customer").setLimits(ctx, { value: "acme", monthlySpendLimitNanos });
|
|
418
|
+
* ai.tag("customer").history(ctx, { value: "acme", period: "day" });
|
|
419
|
+
*
|
|
420
|
+
* Attribute a call to it by passing `tags` to `chat`/`languageModel`.
|
|
421
|
+
*/
|
|
422
|
+
tag(dimension) {
|
|
423
|
+
return this.dimensionApi(dimension, (a) => a.value);
|
|
424
|
+
}
|
|
425
|
+
// Shared implementation behind tag()/users/actions. `key` maps the namespace's
|
|
426
|
+
// id field (value/userId/name) to the bucket value.
|
|
427
|
+
dimensionApi(dimension, key) {
|
|
297
428
|
const c = this.component;
|
|
298
429
|
return {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
/** One
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
430
|
+
/** All buckets in this dimension. */
|
|
431
|
+
list: (ctx) => ctx.runQuery(c.lib.listBuckets, { dimension }),
|
|
432
|
+
/** One bucket's limits + spend (null if it has none yet). */
|
|
433
|
+
get: (ctx, args) => ctx.runQuery(c.lib.getBucket, { dimension, value: key(args) }),
|
|
434
|
+
setLimits: (ctx, args) => {
|
|
435
|
+
const { value, userId, name, ...limits } = args;
|
|
436
|
+
return ctx.runMutation(c.lib.setBucketLimits, {
|
|
437
|
+
dimension,
|
|
438
|
+
value: key(args),
|
|
439
|
+
...limits,
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
/** One-time "approve another $X" bump (daily/monthly reset with the window). */
|
|
443
|
+
bump: (ctx, args) => ctx.runMutation(c.lib.bumpBucket, {
|
|
444
|
+
dimension,
|
|
445
|
+
value: key(args),
|
|
446
|
+
dailyNanos: args.dailyNanos,
|
|
447
|
+
monthlyNanos: args.monthlyNanos,
|
|
448
|
+
lifetimeNanos: args.lifetimeNanos,
|
|
449
|
+
}),
|
|
450
|
+
/** Manually credit (negative) or debit (positive) this bucket. */
|
|
451
|
+
adjust: (ctx, args) => ctx.runMutation(c.lib.adjustBucket, {
|
|
452
|
+
dimension,
|
|
453
|
+
value: key(args),
|
|
454
|
+
deltaNanos: args.deltaNanos,
|
|
455
|
+
tokens: args.tokens,
|
|
456
|
+
reason: args.reason,
|
|
457
|
+
}),
|
|
458
|
+
/** Durable spend history for this bucket (per day or per month). */
|
|
459
|
+
history: (ctx, args) => ctx.runQuery(c.lib.usageHistory, {
|
|
460
|
+
dimension,
|
|
461
|
+
value: key(args),
|
|
462
|
+
period: args.period ?? "day",
|
|
463
|
+
limit: args.limit,
|
|
464
|
+
}),
|
|
465
|
+
/** Manual-adjustment audit log for this bucket. */
|
|
466
|
+
adjustments: (ctx, args) => ctx.runQuery(c.lib.listAdjustments, {
|
|
467
|
+
dimension,
|
|
468
|
+
value: key(args),
|
|
469
|
+
limit: args.limit,
|
|
470
|
+
}),
|
|
471
|
+
/** Delete the bucket (for "user", also its request rows). */
|
|
472
|
+
delete: (ctx, args) => ctx.runMutation(c.lib.deleteBucket, { dimension, value: key(args) }),
|
|
305
473
|
};
|
|
306
474
|
}
|
|
307
|
-
/** Per-
|
|
475
|
+
/** Per-user budgets and controls — sugar over the "user" dimension. */
|
|
476
|
+
get users() {
|
|
477
|
+
return this.dimensionApi("user", (a) => a.userId);
|
|
478
|
+
}
|
|
479
|
+
/** Per-action (per-feature) budgets — sugar over the "action" dimension. */
|
|
308
480
|
get actions() {
|
|
309
|
-
|
|
310
|
-
return {
|
|
311
|
-
list: (ctx) => ctx.runQuery(c.lib.listActions, {}),
|
|
312
|
-
setLimits: (ctx, args) => ctx.runMutation(c.lib.setActionLimits, args),
|
|
313
|
-
bump: (ctx, args) => ctx.runMutation(c.lib.bumpAction, args),
|
|
314
|
-
};
|
|
481
|
+
return this.dimensionApi("action", (a) => a.name);
|
|
315
482
|
}
|
|
316
|
-
/** The deployment-wide budget and retention config. */
|
|
483
|
+
/** The deployment-wide budget, alerts, and retention config. */
|
|
317
484
|
get global() {
|
|
318
485
|
const c = this.component;
|
|
319
486
|
return {
|
|
@@ -322,6 +489,8 @@ export class AIBudget {
|
|
|
322
489
|
/** A killswitch spend cap across all users/actions (enforced approximately). */
|
|
323
490
|
setLimits: (ctx, args) => ctx.runMutation(c.lib.setGlobalLimits, args),
|
|
324
491
|
bump: (ctx, args) => ctx.runMutation(c.lib.bumpGlobal, args),
|
|
492
|
+
/** Default approaching-limit alert threshold (fraction of a cap, e.g. 0.8). */
|
|
493
|
+
setAlertDefaults: (ctx, args) => ctx.runMutation(c.lib.setAlertDefaults, args),
|
|
325
494
|
/** Request-row retention window in ms (default 1h; 0 disables). */
|
|
326
495
|
setRetention: (ctx, args) => ctx.runMutation(c.lib.setRetention, args),
|
|
327
496
|
};
|
|
@@ -335,13 +504,128 @@ export class AIBudget {
|
|
|
335
504
|
setPolicy: (ctx, args) => ctx.runMutation(c.lib.setModelPolicy, args),
|
|
336
505
|
};
|
|
337
506
|
}
|
|
338
|
-
/** Per-model prices (
|
|
507
|
+
/** Per-model prices (nanodollars per million tokens) + server-tool fees. */
|
|
339
508
|
get prices() {
|
|
340
509
|
const c = this.component;
|
|
341
510
|
return {
|
|
342
511
|
list: (ctx) => ctx.runQuery(c.lib.listPrices, {}),
|
|
343
512
|
set: (ctx, args) => ctx.runMutation(c.lib.setPrice, args),
|
|
513
|
+
/** Per-call fees for provider server tools (web search, etc.). */
|
|
514
|
+
listServerTools: (ctx) => ctx.runQuery(c.lib.listServerToolPrices, {}),
|
|
515
|
+
/** Set a server-tool's per-call price, e.g. { tool: "web_search", nanosPerCall }. */
|
|
516
|
+
setServerTool: (ctx, args) => ctx.runMutation(c.lib.setServerToolPrice, args),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Mount the built-in admin dashboard on your app's HTTP router with one call.
|
|
521
|
+
* Serves a self-contained HTML dashboard (buckets, requests, usage history,
|
|
522
|
+
* settings) plus a small JSON API, all backed by the component — no extra
|
|
523
|
+
* queries to write.
|
|
524
|
+
*
|
|
525
|
+
* // convex/http.ts
|
|
526
|
+
* import { httpRouter } from "convex/server";
|
|
527
|
+
* const http = httpRouter();
|
|
528
|
+
* ai.registerRoutes(http, { authorize: async (ctx) =>
|
|
529
|
+
* (await ctx.auth.getUserIdentity())?.role === "admin" });
|
|
530
|
+
* export default http;
|
|
531
|
+
*
|
|
532
|
+
* It then lives at `https://<deployment>.convex.site/aibudget`.
|
|
533
|
+
*
|
|
534
|
+
* SECURITY: the endpoint is public on the internet. You MUST gate it — either
|
|
535
|
+
* pass `authorize` (recommended: check the caller is a deployment admin) or
|
|
536
|
+
* set the `AI_BUDGET_DASHBOARD_TOKEN` env var (a bearer token / `?token=`).
|
|
537
|
+
* With neither, every route returns 401.
|
|
538
|
+
*/
|
|
539
|
+
registerRoutes(http, opts = {}) {
|
|
540
|
+
const prefix = (opts.path ?? "/aibudget").replace(/\/+$/, "");
|
|
541
|
+
const c = this.component.lib;
|
|
542
|
+
const authorize = opts.authorize;
|
|
543
|
+
const guard = async (ctx, request) => {
|
|
544
|
+
if (authorize)
|
|
545
|
+
return { ok: await authorize(ctx, request), token: "" };
|
|
546
|
+
const token = globalThis.process?.env?.AI_BUDGET_DASHBOARD_TOKEN;
|
|
547
|
+
if (!token)
|
|
548
|
+
return { ok: false, token: "" };
|
|
549
|
+
const url = new URL(request.url);
|
|
550
|
+
const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
|
|
551
|
+
// `?token=` is accepted only for the initial page navigation (a browser
|
|
552
|
+
// GET can't set headers); the page strips it from the URL on load and the
|
|
553
|
+
// JSON API is called with the bearer header. Compared in constant time.
|
|
554
|
+
const provided = bearer || url.searchParams.get("token") || "";
|
|
555
|
+
return { ok: timingSafeEqual(provided, token), token };
|
|
556
|
+
};
|
|
557
|
+
const json = (data, status = 200) => new Response(JSON.stringify(data ?? null), {
|
|
558
|
+
status,
|
|
559
|
+
headers: { "content-type": "application/json" },
|
|
560
|
+
});
|
|
561
|
+
const handle = async (ctx, request) => {
|
|
562
|
+
const url = new URL(request.url);
|
|
563
|
+
const sub = url.pathname.slice(prefix.length) || "/";
|
|
564
|
+
const { ok, token } = await guard(ctx, request);
|
|
565
|
+
if (!ok) {
|
|
566
|
+
return new Response("Unauthorized. Pass `authorize` to registerRoutes or set AI_BUDGET_DASHBOARD_TOKEN.", { status: 401 });
|
|
567
|
+
}
|
|
568
|
+
if (sub.startsWith("/api/")) {
|
|
569
|
+
const route = request.method + " " + sub.slice(4); // strip "/api"
|
|
570
|
+
const p = {};
|
|
571
|
+
url.searchParams.forEach((v, k) => {
|
|
572
|
+
p[k] = v;
|
|
573
|
+
});
|
|
574
|
+
const body = request.method === "POST"
|
|
575
|
+
? await request.json().catch(() => ({}))
|
|
576
|
+
: {};
|
|
577
|
+
switch (route) {
|
|
578
|
+
case "GET /buckets":
|
|
579
|
+
return json(await ctx.runQuery(c.listBuckets, { dimension: p.dimension || undefined }));
|
|
580
|
+
case "GET /requests":
|
|
581
|
+
return json(await ctx.runQuery(c.listRequests, {
|
|
582
|
+
userId: p.userId || undefined,
|
|
583
|
+
dimension: p.dimension || undefined,
|
|
584
|
+
value: p.value || undefined,
|
|
585
|
+
limit: 100,
|
|
586
|
+
}));
|
|
587
|
+
case "GET /usage":
|
|
588
|
+
return json(await ctx.runQuery(c.usageHistory, {
|
|
589
|
+
dimension: p.dimension,
|
|
590
|
+
value: p.value,
|
|
591
|
+
period: p.period === "month" ? "month" : "day",
|
|
592
|
+
}));
|
|
593
|
+
case "GET /global":
|
|
594
|
+
return json(await ctx.runQuery(c.getGlobalStatus, {}));
|
|
595
|
+
case "GET /prices":
|
|
596
|
+
return json(await ctx.runQuery(c.listPrices, {}));
|
|
597
|
+
case "POST /setLimits":
|
|
598
|
+
return json(await ctx.runMutation(c.setBucketLimits, body));
|
|
599
|
+
case "POST /bump":
|
|
600
|
+
return json(await ctx.runMutation(c.bumpBucket, body));
|
|
601
|
+
case "POST /adjust":
|
|
602
|
+
return json(await ctx.runMutation(c.adjustBucket, body));
|
|
603
|
+
case "POST /delete":
|
|
604
|
+
return json(await ctx.runMutation(c.deleteBucket, body));
|
|
605
|
+
case "POST /global/setLimits":
|
|
606
|
+
return json(await ctx.runMutation(c.setGlobalLimits, body));
|
|
607
|
+
case "POST /global/setAlertDefaults":
|
|
608
|
+
return json(await ctx.runMutation(c.setAlertDefaults, body));
|
|
609
|
+
case "POST /global/setRetention":
|
|
610
|
+
return json(await ctx.runMutation(c.setRetention, body));
|
|
611
|
+
case "POST /setPrice":
|
|
612
|
+
return json(await ctx.runMutation(c.setPrice, body));
|
|
613
|
+
default:
|
|
614
|
+
return json({ error: "not found" }, 404);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// Inject as JSON literals (function replacers so `$` in the value isn't
|
|
618
|
+
// treated as a replacement pattern). This keeps a token/prefix containing
|
|
619
|
+
// quotes, backslashes, or `</script>` from breaking out of the JS string.
|
|
620
|
+
const html = DASHBOARD_HTML.replace(/__API_BASE__/g, () => JSON.stringify(`${prefix}/api`)).replace(/__TOKEN__/g, () => JSON.stringify(token));
|
|
621
|
+
return new Response(html, {
|
|
622
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
623
|
+
});
|
|
344
624
|
};
|
|
625
|
+
const handler = httpActionGeneric(handle);
|
|
626
|
+
http.route({ path: prefix, method: "GET", handler });
|
|
627
|
+
http.route({ pathPrefix: `${prefix}/`, method: "GET", handler });
|
|
628
|
+
http.route({ pathPrefix: `${prefix}/`, method: "POST", handler });
|
|
345
629
|
}
|
|
346
630
|
}
|
|
347
631
|
/** @deprecated Renamed to `AIBudget`. */
|