@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/client/index.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
httpActionGeneric,
|
|
3
|
+
type Expand,
|
|
4
|
+
type FunctionReference,
|
|
5
|
+
type HttpRouter,
|
|
6
|
+
} from "convex/server";
|
|
2
7
|
import { ConvexError, type GenericId } from "convex/values";
|
|
3
8
|
import { generateText, wrapLanguageModel, type LanguageModel } from "ai";
|
|
4
9
|
import { convexGateway } from "@convex-dev/ai-sdk-provider";
|
|
5
10
|
import type { api } from "../component/_generated/api";
|
|
11
|
+
import { DASHBOARD_HTML } from "./dashboard";
|
|
6
12
|
|
|
7
13
|
// ---------- types ----------
|
|
8
14
|
|
|
@@ -41,21 +47,40 @@ export type AIBudgetApi = UseApi<typeof api>;
|
|
|
41
47
|
/** @deprecated use AIBudgetApi */
|
|
42
48
|
export type AIGatewayApi = AIBudgetApi;
|
|
43
49
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
50
|
+
/**
|
|
51
|
+
* One attribution tag: a (dimension, value) pair, e.g. {dimension:"customer",
|
|
52
|
+
* value:"acme"}. `user` and `action` are built-in dimensions (set via
|
|
53
|
+
* userId/action); use tags for anything else — team, project, tenant, env, ….
|
|
54
|
+
* Any tagged bucket can carry its own budget (see `ai.tag(dimension)`).
|
|
55
|
+
*/
|
|
56
|
+
export type Tag = { dimension: string; value: string };
|
|
57
|
+
|
|
58
|
+
/** Common shape for budget-event callbacks. */
|
|
59
|
+
export type BudgetEventInfo = {
|
|
46
60
|
userId: string;
|
|
47
61
|
action?: string;
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
tags?: Tag[];
|
|
63
|
+
requestId?: string;
|
|
64
|
+
/** soft-cap warnings (onSoftLimit) or approaching-cap notices (onThreshold). */
|
|
65
|
+
messages: string[];
|
|
66
|
+
/** rejection code/reason (onLimitReached only). */
|
|
67
|
+
code?: string;
|
|
68
|
+
reason?: string;
|
|
50
69
|
};
|
|
70
|
+
/** @deprecated use BudgetEventInfo */
|
|
71
|
+
export type SoftLimitInfo = BudgetEventInfo & { warnings: string[] };
|
|
51
72
|
export type AIBudgetOptions = {
|
|
52
73
|
defaultModel?: string;
|
|
53
74
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
75
|
+
* A *soft* limit was exceeded (request still allowed). Lets you surface budget
|
|
76
|
+
* warnings even on the languageModel/Agent path where they can't be returned.
|
|
77
|
+
* Errors thrown in any of these callbacks are swallowed.
|
|
57
78
|
*/
|
|
58
79
|
onSoftLimit?: (info: SoftLimitInfo) => void | Promise<void>;
|
|
80
|
+
/** Usage crossed a bucket's warnAtPct threshold (approaching a cap). */
|
|
81
|
+
onThreshold?: (info: BudgetEventInfo) => void | Promise<void>;
|
|
82
|
+
/** A *hard* limit blocked the request (fires just before chat/model throws). */
|
|
83
|
+
onLimitReached?: (info: BudgetEventInfo) => void | Promise<void>;
|
|
59
84
|
};
|
|
60
85
|
|
|
61
86
|
type RunQueryCtx = {
|
|
@@ -114,6 +139,8 @@ export type ChatResult = {
|
|
|
114
139
|
cachedTokens: number;
|
|
115
140
|
/** Soft-limit warnings raised at admission (empty unless a soft cap was hit). */
|
|
116
141
|
warnings: string[];
|
|
142
|
+
/** Approaching-cap notices (empty unless a warnAtPct threshold was crossed). */
|
|
143
|
+
notices: string[];
|
|
117
144
|
};
|
|
118
145
|
|
|
119
146
|
// ---------- helpers ----------
|
|
@@ -134,10 +161,12 @@ function extractUsage(usage: any): {
|
|
|
134
161
|
return {
|
|
135
162
|
promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens),
|
|
136
163
|
completionTokens: toTokenCount(usage?.outputTokens ?? usage?.completionTokens),
|
|
137
|
-
// cached prompt tokens
|
|
138
|
-
// `
|
|
164
|
+
// cached prompt tokens. The Convex gateway reports these at
|
|
165
|
+
// `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
|
|
166
|
+
// (`cachedInputTokens`) and raw OpenAI-compatible shapes.
|
|
139
167
|
cachedTokens: toTokenCount(
|
|
140
|
-
usage?.
|
|
168
|
+
usage?.inputTokenDetails?.cacheReadTokens ??
|
|
169
|
+
usage?.cachedInputTokens ??
|
|
141
170
|
usage?.promptTokensDetails?.cachedTokens ??
|
|
142
171
|
usage?.prompt_tokens_details?.cached_tokens ??
|
|
143
172
|
usage?.cached_tokens
|
|
@@ -145,6 +174,27 @@ function extractUsage(usage: any): {
|
|
|
145
174
|
};
|
|
146
175
|
}
|
|
147
176
|
|
|
177
|
+
// The AI Gateway reports the authoritative dollar cost of each request.
|
|
178
|
+
// @convex-dev/ai-sdk-provider surfaces it at
|
|
179
|
+
// `providerMetadata.convexGateway.cost` (USD); convert to nanodollars and pass
|
|
180
|
+
// it through as authoritative (finishRequest prefers it over the token-based
|
|
181
|
+
// estimate). No-op on older provider versions that don't surface it.
|
|
182
|
+
function extractGatewayCostNanos(result: any): number | undefined {
|
|
183
|
+
const meta = result?.providerMetadata?.convexGateway;
|
|
184
|
+
const costUsd = meta?.cost;
|
|
185
|
+
if (typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd >= 0) {
|
|
186
|
+
return Math.round(costUsd * NANOS_PER_DOLLAR);
|
|
187
|
+
}
|
|
188
|
+
// Back-compat: honor an explicitly nano-denominated field if ever present.
|
|
189
|
+
const costNanos = meta?.costNanos ?? result?.usage?.costNanos;
|
|
190
|
+
if (typeof costNanos === "number" && Number.isFinite(costNanos) && costNanos >= 0) {
|
|
191
|
+
return Math.round(costNanos);
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const NANOS_PER_DOLLAR = 1e9;
|
|
197
|
+
|
|
148
198
|
// Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
|
|
149
199
|
function simplifyPrompt(prompt: any): Message[] {
|
|
150
200
|
if (!Array.isArray(prompt)) return [];
|
|
@@ -178,56 +228,133 @@ function extractText(result: any): string {
|
|
|
178
228
|
|
|
179
229
|
// ---------- client ----------
|
|
180
230
|
|
|
231
|
+
// Length-independent-branch string compare, so the dashboard token check
|
|
232
|
+
// doesn't leak the token via response timing. (Length itself is not secret.)
|
|
233
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
234
|
+
if (a.length !== b.length) return false;
|
|
235
|
+
let r = 0;
|
|
236
|
+
for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
237
|
+
return r === 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Limits/controls settable on any budget bucket (user, action, or tag). */
|
|
241
|
+
export type BucketLimits = {
|
|
242
|
+
requestsPerMinute?: number;
|
|
243
|
+
maxConcurrent?: number;
|
|
244
|
+
dailySpendLimitNanos?: number;
|
|
245
|
+
monthlySpendLimitNanos?: number;
|
|
246
|
+
lifetimeSpendLimitNanos?: number;
|
|
247
|
+
dailyTokenLimit?: number;
|
|
248
|
+
monthlyTokenLimit?: number;
|
|
249
|
+
lifetimeTokenLimit?: number;
|
|
250
|
+
/** Fire an approaching-limit alert at this fraction of a cap (e.g. 0.8). */
|
|
251
|
+
warnAtPct?: number;
|
|
252
|
+
enforcement?: "hard" | "soft";
|
|
253
|
+
blocked?: boolean;
|
|
254
|
+
};
|
|
255
|
+
/** One-time bump amounts, added on top of a standing cap. */
|
|
256
|
+
export type BumpArgs = {
|
|
257
|
+
dailyNanos?: number;
|
|
258
|
+
monthlyNanos?: number;
|
|
259
|
+
lifetimeNanos?: number;
|
|
260
|
+
};
|
|
261
|
+
|
|
181
262
|
export class AIBudget {
|
|
182
263
|
public defaultModel: string;
|
|
183
264
|
private onSoftLimit?: AIBudgetOptions["onSoftLimit"];
|
|
265
|
+
private onThreshold?: AIBudgetOptions["onThreshold"];
|
|
266
|
+
private onLimitReached?: AIBudgetOptions["onLimitReached"];
|
|
184
267
|
constructor(
|
|
185
268
|
public component: AIBudgetApi,
|
|
186
269
|
options?: AIBudgetOptions
|
|
187
270
|
) {
|
|
188
271
|
this.defaultModel = options?.defaultModel ?? "openai/gpt-4o-mini";
|
|
189
272
|
this.onSoftLimit = options?.onSoftLimit;
|
|
273
|
+
this.onThreshold = options?.onThreshold;
|
|
274
|
+
this.onLimitReached = options?.onLimitReached;
|
|
190
275
|
}
|
|
191
276
|
|
|
192
|
-
|
|
193
|
-
|
|
277
|
+
// Fire the soft-limit + threshold callbacks from a startRequest result.
|
|
278
|
+
private async fireBudgetEvents(
|
|
279
|
+
base: Omit<BudgetEventInfo, "messages">,
|
|
280
|
+
warnings: string[],
|
|
281
|
+
notices: string[]
|
|
282
|
+
) {
|
|
283
|
+
if (warnings.length > 0 && this.onSoftLimit) {
|
|
284
|
+
try {
|
|
285
|
+
await this.onSoftLimit({ ...base, messages: warnings, warnings });
|
|
286
|
+
} catch {
|
|
287
|
+
/* never let a callback error break a request */
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (notices.length > 0 && this.onThreshold) {
|
|
291
|
+
try {
|
|
292
|
+
await this.onThreshold({ ...base, messages: notices });
|
|
293
|
+
} catch {
|
|
294
|
+
/* swallow */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async fireLimitReached(info: BudgetEventInfo) {
|
|
300
|
+
if (!this.onLimitReached) return;
|
|
194
301
|
try {
|
|
195
|
-
await this.
|
|
302
|
+
await this.onLimitReached(info);
|
|
196
303
|
} catch {
|
|
197
|
-
|
|
304
|
+
/* swallow */
|
|
198
305
|
}
|
|
199
306
|
}
|
|
200
307
|
|
|
201
308
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
309
|
+
* Meter ANY LLM call — gateway, a provider SDK, a raw fetch — with the same
|
|
310
|
+
* budgets, audit log, and cost tracking. Reserves before your `run` (throwing
|
|
311
|
+
* a ConvexError over a hard cap), runs it, then records the actual usage/cost.
|
|
312
|
+
* This is the provider-agnostic core; `chat` is sugar over it for the gateway.
|
|
313
|
+
*
|
|
314
|
+
* `run` returns what happened. Pass a raw provider `usage` object (auto-
|
|
315
|
+
* normalized) OR explicit `promptTokens`/`completionTokens`/`cachedTokens`,
|
|
316
|
+
* plus optional `serverToolUses` (e.g. `{ web_search: 3 }`, priced on top of
|
|
317
|
+
* tokens) and an authoritative `costNanos` (used verbatim if present).
|
|
204
318
|
*/
|
|
205
|
-
async
|
|
319
|
+
async meter(
|
|
206
320
|
ctx: RunMutationCtx,
|
|
207
|
-
|
|
208
|
-
|
|
321
|
+
opts: {
|
|
322
|
+
model: string;
|
|
323
|
+
messages: Message[];
|
|
209
324
|
userId?: string;
|
|
210
|
-
prompt?: string;
|
|
211
|
-
messages?: Message[];
|
|
212
|
-
model?: string;
|
|
213
|
-
rerunOf?: string;
|
|
214
|
-
/** Attribute spend to this action name. Defaults to the calling Convex action. */
|
|
215
325
|
action?: string;
|
|
216
|
-
|
|
326
|
+
tags?: Tag[];
|
|
327
|
+
rerunOf?: string;
|
|
328
|
+
},
|
|
329
|
+
run: () => Promise<{
|
|
330
|
+
text?: string;
|
|
331
|
+
usage?: any;
|
|
332
|
+
promptTokens?: number;
|
|
333
|
+
completionTokens?: number;
|
|
334
|
+
cachedTokens?: number;
|
|
335
|
+
serverToolUses?: Record<string, number>;
|
|
336
|
+
costNanos?: number;
|
|
337
|
+
}>
|
|
217
338
|
): Promise<ChatResult> {
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
const actionName = await resolveActionName(ctx, args.action);
|
|
221
|
-
const messages: Message[] =
|
|
222
|
-
args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
|
|
339
|
+
const userId = await resolveUserId(ctx, opts.userId);
|
|
340
|
+
const actionName = await resolveActionName(ctx, opts.action);
|
|
223
341
|
const started = await ctx.runMutation(this.component.lib.startRequest, {
|
|
224
342
|
userId,
|
|
225
343
|
actionName,
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
344
|
+
tags: opts.tags,
|
|
345
|
+
model: opts.model,
|
|
346
|
+
messages: opts.messages,
|
|
347
|
+
rerunOf: opts.rerunOf as any,
|
|
229
348
|
});
|
|
230
349
|
if (!started.allowed) {
|
|
350
|
+
await this.fireLimitReached({
|
|
351
|
+
userId,
|
|
352
|
+
action: actionName,
|
|
353
|
+
tags: opts.tags,
|
|
354
|
+
messages: [started.reason],
|
|
355
|
+
code: started.code,
|
|
356
|
+
reason: started.reason,
|
|
357
|
+
});
|
|
231
358
|
throw new ConvexError({
|
|
232
359
|
kind: "AIBudgetLimit",
|
|
233
360
|
code: started.code,
|
|
@@ -235,34 +362,38 @@ export class AIBudget {
|
|
|
235
362
|
});
|
|
236
363
|
}
|
|
237
364
|
const requestId = started.requestId;
|
|
238
|
-
const warnings = started
|
|
239
|
-
await this.
|
|
365
|
+
const { warnings, notices } = started;
|
|
366
|
+
await this.fireBudgetEvents(
|
|
367
|
+
{ userId, action: actionName, tags: opts.tags, requestId },
|
|
368
|
+
warnings,
|
|
369
|
+
notices
|
|
370
|
+
);
|
|
240
371
|
const start = Date.now();
|
|
241
372
|
try {
|
|
242
|
-
|
|
243
|
-
//
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
});
|
|
255
|
-
const usage = extractUsage(result.usage);
|
|
373
|
+
const out = await run();
|
|
374
|
+
// Explicit token fields win; otherwise normalize a raw provider usage.
|
|
375
|
+
const usage =
|
|
376
|
+
out.promptTokens !== undefined ||
|
|
377
|
+
out.completionTokens !== undefined ||
|
|
378
|
+
out.cachedTokens !== undefined
|
|
379
|
+
? {
|
|
380
|
+
promptTokens: out.promptTokens ?? 0,
|
|
381
|
+
completionTokens: out.completionTokens ?? 0,
|
|
382
|
+
cachedTokens: out.cachedTokens ?? 0,
|
|
383
|
+
}
|
|
384
|
+
: extractUsage(out.usage);
|
|
256
385
|
const { costNanos } = await ctx.runMutation(
|
|
257
386
|
this.component.lib.finishRequest,
|
|
258
387
|
{
|
|
259
388
|
requestId,
|
|
260
|
-
responseText:
|
|
389
|
+
responseText: out.text,
|
|
261
390
|
...usage,
|
|
391
|
+
serverToolUses: out.serverToolUses,
|
|
392
|
+
costNanos: out.costNanos,
|
|
262
393
|
latencyMs: Date.now() - start,
|
|
263
394
|
}
|
|
264
395
|
);
|
|
265
|
-
return { text:
|
|
396
|
+
return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
|
|
266
397
|
} catch (e) {
|
|
267
398
|
await ctx.runMutation(this.component.lib.finishRequest, {
|
|
268
399
|
requestId,
|
|
@@ -273,6 +404,61 @@ export class AIBudget {
|
|
|
273
404
|
}
|
|
274
405
|
}
|
|
275
406
|
|
|
407
|
+
/**
|
|
408
|
+
* One-shot chat through the AI Gateway with tracking + limits — sugar over
|
|
409
|
+
* `meter`. Call from an action. `userId` defaults to the authenticated caller.
|
|
410
|
+
*/
|
|
411
|
+
async chat(
|
|
412
|
+
ctx: RunMutationCtx,
|
|
413
|
+
args: {
|
|
414
|
+
/** Whom to bill. Defaults to the authenticated user (ctx.auth). */
|
|
415
|
+
userId?: string;
|
|
416
|
+
prompt?: string;
|
|
417
|
+
messages?: Message[];
|
|
418
|
+
model?: string;
|
|
419
|
+
rerunOf?: string;
|
|
420
|
+
/** Attribute spend to this action name. Defaults to the calling Convex action. */
|
|
421
|
+
action?: string;
|
|
422
|
+
/** Extra attribution dimensions to bill/limit (team, customer, env, …). */
|
|
423
|
+
tags?: Tag[];
|
|
424
|
+
} = {}
|
|
425
|
+
): Promise<ChatResult> {
|
|
426
|
+
const model = args.model ?? this.defaultModel;
|
|
427
|
+
const messages: Message[] =
|
|
428
|
+
args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
|
|
429
|
+
return this.meter(
|
|
430
|
+
ctx,
|
|
431
|
+
{
|
|
432
|
+
model,
|
|
433
|
+
messages,
|
|
434
|
+
userId: args.userId,
|
|
435
|
+
action: args.action,
|
|
436
|
+
tags: args.tags,
|
|
437
|
+
rerunOf: args.rerunOf,
|
|
438
|
+
},
|
|
439
|
+
async () => {
|
|
440
|
+
// The full chain (incl. system) is stored for audit/replay, but the AI
|
|
441
|
+
// SDK wants system prompts in the `system` option, not messages.
|
|
442
|
+
const system =
|
|
443
|
+
messages
|
|
444
|
+
.filter((m) => m.role === "system")
|
|
445
|
+
.map((m) => m.content)
|
|
446
|
+
.join("\n\n") || undefined;
|
|
447
|
+
const convo = messages.filter((m) => m.role !== "system");
|
|
448
|
+
const result = await generateText({
|
|
449
|
+
model: convexGateway(model),
|
|
450
|
+
...(system ? { system } : {}),
|
|
451
|
+
messages: convo as any,
|
|
452
|
+
});
|
|
453
|
+
return {
|
|
454
|
+
text: result.text,
|
|
455
|
+
usage: result.usage,
|
|
456
|
+
costNanos: extractGatewayCostNanos(result),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
276
462
|
/**
|
|
277
463
|
* An AI SDK LanguageModel that enforces limits and records usage/cost for
|
|
278
464
|
* `userId` on every call. Drop it into `generateText`, `streamText`, or the
|
|
@@ -281,34 +467,48 @@ export class AIBudget {
|
|
|
281
467
|
*/
|
|
282
468
|
languageModel(
|
|
283
469
|
ctx: RunMutationCtx,
|
|
284
|
-
opts: {
|
|
470
|
+
opts: {
|
|
471
|
+
userId?: string;
|
|
472
|
+
model?: string;
|
|
473
|
+
action?: string;
|
|
474
|
+
/** Extra attribution dimensions to bill/limit (team, customer, env, …). */
|
|
475
|
+
tags?: Tag[];
|
|
476
|
+
} = {}
|
|
285
477
|
): LanguageModel {
|
|
286
478
|
const modelId = opts.model ?? this.defaultModel;
|
|
287
479
|
const component = this.component;
|
|
288
|
-
const
|
|
480
|
+
const fireBudgetEvents = this.fireBudgetEvents.bind(this);
|
|
481
|
+
const fireLimitReached = this.fireLimitReached.bind(this);
|
|
289
482
|
|
|
290
483
|
const begin = async (params: any) => {
|
|
291
484
|
const userId = await resolveUserId(ctx, opts.userId);
|
|
292
485
|
const actionName = await resolveActionName(ctx, opts.action);
|
|
486
|
+
const base = { userId, action: actionName, tags: opts.tags };
|
|
293
487
|
const started = await ctx.runMutation(component.lib.startRequest, {
|
|
294
488
|
userId,
|
|
295
489
|
actionName,
|
|
490
|
+
tags: opts.tags,
|
|
296
491
|
model: modelId,
|
|
297
492
|
messages: simplifyPrompt(params.prompt),
|
|
298
493
|
});
|
|
299
494
|
if (!started.allowed) {
|
|
495
|
+
await fireLimitReached({
|
|
496
|
+
...base,
|
|
497
|
+
messages: [started.reason],
|
|
498
|
+
code: started.code,
|
|
499
|
+
reason: started.reason,
|
|
500
|
+
});
|
|
300
501
|
throw new ConvexError({
|
|
301
502
|
kind: "AIBudgetLimit",
|
|
302
503
|
code: started.code,
|
|
303
504
|
reason: started.reason,
|
|
304
505
|
});
|
|
305
506
|
}
|
|
306
|
-
await
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
});
|
|
507
|
+
await fireBudgetEvents(
|
|
508
|
+
{ ...base, requestId: started.requestId },
|
|
509
|
+
started.warnings,
|
|
510
|
+
started.notices
|
|
511
|
+
);
|
|
312
512
|
return started.requestId;
|
|
313
513
|
};
|
|
314
514
|
const finish = async (
|
|
@@ -318,6 +518,8 @@ export class AIBudget {
|
|
|
318
518
|
error?: string;
|
|
319
519
|
promptTokens?: number;
|
|
320
520
|
completionTokens?: number;
|
|
521
|
+
cachedTokens?: number;
|
|
522
|
+
costNanos?: number;
|
|
321
523
|
latencyMs?: number;
|
|
322
524
|
}
|
|
323
525
|
) => ctx.runMutation(component.lib.finishRequest, { requestId, ...fields });
|
|
@@ -333,6 +535,7 @@ export class AIBudget {
|
|
|
333
535
|
await finish(requestId, {
|
|
334
536
|
responseText: extractText(result),
|
|
335
537
|
...extractUsage(result.usage),
|
|
538
|
+
costNanos: extractGatewayCostNanos(result),
|
|
336
539
|
latencyMs: Date.now() - start,
|
|
337
540
|
});
|
|
338
541
|
return result;
|
|
@@ -349,6 +552,7 @@ export class AIBudget {
|
|
|
349
552
|
const start = Date.now();
|
|
350
553
|
let text = "";
|
|
351
554
|
let usage: any = undefined;
|
|
555
|
+
let providerMetadata: any = undefined;
|
|
352
556
|
try {
|
|
353
557
|
const result = await doStream();
|
|
354
558
|
// finishRequest is idempotent (terminal-guarded server-side), so
|
|
@@ -364,6 +568,7 @@ export class AIBudget {
|
|
|
364
568
|
responseText: text,
|
|
365
569
|
error,
|
|
366
570
|
...extractUsage(usage),
|
|
571
|
+
costNanos: extractGatewayCostNanos({ providerMetadata }),
|
|
367
572
|
latencyMs: Date.now() - start,
|
|
368
573
|
});
|
|
369
574
|
};
|
|
@@ -373,7 +578,10 @@ export class AIBudget {
|
|
|
373
578
|
if (chunk?.type === "text-delta") {
|
|
374
579
|
text += chunk.delta ?? chunk.textDelta ?? "";
|
|
375
580
|
}
|
|
376
|
-
if (chunk?.type === "finish")
|
|
581
|
+
if (chunk?.type === "finish") {
|
|
582
|
+
usage = chunk.usage;
|
|
583
|
+
providerMetadata = chunk.providerMetadata ?? providerMetadata;
|
|
584
|
+
}
|
|
377
585
|
if (chunk?.type === "error") void settle(String(chunk.error));
|
|
378
586
|
controller.enqueue(chunk);
|
|
379
587
|
},
|
|
@@ -409,6 +617,7 @@ export class AIBudget {
|
|
|
409
617
|
messages: args.messages ?? original.messages,
|
|
410
618
|
rerunOf: args.requestId,
|
|
411
619
|
action: original.actionName,
|
|
620
|
+
tags: original.tags,
|
|
412
621
|
});
|
|
413
622
|
}
|
|
414
623
|
|
|
@@ -418,8 +627,19 @@ export class AIBudget {
|
|
|
418
627
|
get requests() {
|
|
419
628
|
const c = this.component;
|
|
420
629
|
return {
|
|
421
|
-
|
|
422
|
-
|
|
630
|
+
/** Filter by userId, or by any {dimension, value} (incl. custom tags). */
|
|
631
|
+
list: (
|
|
632
|
+
ctx: RunQueryCtx,
|
|
633
|
+
args: {
|
|
634
|
+
userId?: string;
|
|
635
|
+
dimension?: string;
|
|
636
|
+
value?: string;
|
|
637
|
+
limit?: number;
|
|
638
|
+
} = {}
|
|
639
|
+
) => ctx.runQuery(c.lib.listRequests, args),
|
|
640
|
+
/** One request, including its stored prompt and response. */
|
|
641
|
+
get: (ctx: RunQueryCtx, args: { requestId: string }) =>
|
|
642
|
+
ctx.runQuery(c.lib.getRequest, { requestId: args.requestId as any }),
|
|
423
643
|
/** Ancestors up to the original, plus direct re-runs. */
|
|
424
644
|
lineage: (ctx: RunQueryCtx, args: { requestId: string }) =>
|
|
425
645
|
ctx.runQuery(c.lib.lineage, { requestId: args.requestId as any }),
|
|
@@ -431,60 +651,97 @@ export class AIBudget {
|
|
|
431
651
|
};
|
|
432
652
|
}
|
|
433
653
|
|
|
434
|
-
/**
|
|
435
|
-
|
|
654
|
+
/**
|
|
655
|
+
* Budgets and controls for an arbitrary attribution dimension — the
|
|
656
|
+
* generalization of `users`/`actions`. Give it any dimension name (team,
|
|
657
|
+
* project, tenant, customer, env, feature, …) and set caps per value:
|
|
658
|
+
*
|
|
659
|
+
* ai.tag("customer").setLimits(ctx, { value: "acme", monthlySpendLimitNanos });
|
|
660
|
+
* ai.tag("customer").history(ctx, { value: "acme", period: "day" });
|
|
661
|
+
*
|
|
662
|
+
* Attribute a call to it by passing `tags` to `chat`/`languageModel`.
|
|
663
|
+
*/
|
|
664
|
+
tag(dimension: string) {
|
|
665
|
+
return this.dimensionApi(dimension, (a: { value: string }) => a.value);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Shared implementation behind tag()/users/actions. `key` maps the namespace's
|
|
669
|
+
// id field (value/userId/name) to the bucket value.
|
|
670
|
+
private dimensionApi<A extends Record<string, any>>(
|
|
671
|
+
dimension: string,
|
|
672
|
+
key: (a: A) => string
|
|
673
|
+
) {
|
|
436
674
|
const c = this.component;
|
|
437
675
|
return {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
676
|
+
/** All buckets in this dimension. */
|
|
677
|
+
list: (ctx: RunQueryCtx) => ctx.runQuery(c.lib.listBuckets, { dimension }),
|
|
678
|
+
/** One bucket's limits + spend (null if it has none yet). */
|
|
679
|
+
get: (ctx: RunQueryCtx, args: A) =>
|
|
680
|
+
ctx.runQuery(c.lib.getBucket, { dimension, value: key(args) }),
|
|
681
|
+
setLimits: (ctx: RunMutationCtx, args: A & BucketLimits) => {
|
|
682
|
+
const { value, userId, name, ...limits } = args as any;
|
|
683
|
+
return ctx.runMutation(c.lib.setBucketLimits, {
|
|
684
|
+
dimension,
|
|
685
|
+
value: key(args),
|
|
686
|
+
...limits,
|
|
687
|
+
});
|
|
688
|
+
},
|
|
689
|
+
/** One-time "approve another $X" bump (daily/monthly reset with the window). */
|
|
690
|
+
bump: (ctx: RunMutationCtx, args: A & BumpArgs) =>
|
|
691
|
+
ctx.runMutation(c.lib.bumpBucket, {
|
|
692
|
+
dimension,
|
|
693
|
+
value: key(args),
|
|
694
|
+
dailyNanos: args.dailyNanos,
|
|
695
|
+
monthlyNanos: args.monthlyNanos,
|
|
696
|
+
lifetimeNanos: args.lifetimeNanos,
|
|
697
|
+
}),
|
|
698
|
+
/** Manually credit (negative) or debit (positive) this bucket. */
|
|
699
|
+
adjust: (
|
|
454
700
|
ctx: RunMutationCtx,
|
|
455
|
-
args: {
|
|
456
|
-
) =>
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
701
|
+
args: A & { deltaNanos: number; tokens?: number; reason?: string }
|
|
702
|
+
) =>
|
|
703
|
+
ctx.runMutation(c.lib.adjustBucket, {
|
|
704
|
+
dimension,
|
|
705
|
+
value: key(args),
|
|
706
|
+
deltaNanos: args.deltaNanos,
|
|
707
|
+
tokens: args.tokens,
|
|
708
|
+
reason: args.reason,
|
|
709
|
+
}),
|
|
710
|
+
/** Durable spend history for this bucket (per day or per month). */
|
|
711
|
+
history: (
|
|
712
|
+
ctx: RunQueryCtx,
|
|
713
|
+
args: A & { period?: "day" | "month"; limit?: number }
|
|
714
|
+
) =>
|
|
715
|
+
ctx.runQuery(c.lib.usageHistory, {
|
|
716
|
+
dimension,
|
|
717
|
+
value: key(args),
|
|
718
|
+
period: args.period ?? "day",
|
|
719
|
+
limit: args.limit,
|
|
720
|
+
}),
|
|
721
|
+
/** Manual-adjustment audit log for this bucket. */
|
|
722
|
+
adjustments: (ctx: RunQueryCtx, args: A & { limit?: number }) =>
|
|
723
|
+
ctx.runQuery(c.lib.listAdjustments, {
|
|
724
|
+
dimension,
|
|
725
|
+
value: key(args),
|
|
726
|
+
limit: args.limit,
|
|
727
|
+
}),
|
|
728
|
+
/** Delete the bucket (for "user", also its request rows). */
|
|
729
|
+
delete: (ctx: RunMutationCtx, args: A) =>
|
|
730
|
+
ctx.runMutation(c.lib.deleteBucket, { dimension, value: key(args) }),
|
|
460
731
|
};
|
|
461
732
|
}
|
|
462
733
|
|
|
463
|
-
/** Per-
|
|
734
|
+
/** Per-user budgets and controls — sugar over the "user" dimension. */
|
|
735
|
+
get users() {
|
|
736
|
+
return this.dimensionApi<{ userId: string }>("user", (a) => a.userId);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Per-action (per-feature) budgets — sugar over the "action" dimension. */
|
|
464
740
|
get actions() {
|
|
465
|
-
|
|
466
|
-
return {
|
|
467
|
-
list: (ctx: RunQueryCtx) => ctx.runQuery(c.lib.listActions, {}),
|
|
468
|
-
setLimits: (
|
|
469
|
-
ctx: RunMutationCtx,
|
|
470
|
-
args: {
|
|
471
|
-
name: string;
|
|
472
|
-
dailySpendLimitNanos?: number;
|
|
473
|
-
lifetimeSpendLimitNanos?: number;
|
|
474
|
-
dailyTokenLimit?: number;
|
|
475
|
-
lifetimeTokenLimit?: number;
|
|
476
|
-
enforcement?: "hard" | "soft";
|
|
477
|
-
disabled?: boolean;
|
|
478
|
-
}
|
|
479
|
-
) => ctx.runMutation(c.lib.setActionLimits, args),
|
|
480
|
-
bump: (
|
|
481
|
-
ctx: RunMutationCtx,
|
|
482
|
-
args: { name: string; dailyNanos?: number; lifetimeNanos?: number }
|
|
483
|
-
) => ctx.runMutation(c.lib.bumpAction, args),
|
|
484
|
-
};
|
|
741
|
+
return this.dimensionApi<{ name: string }>("action", (a) => a.name);
|
|
485
742
|
}
|
|
486
743
|
|
|
487
|
-
/** The deployment-wide budget and retention config. */
|
|
744
|
+
/** The deployment-wide budget, alerts, and retention config. */
|
|
488
745
|
get global() {
|
|
489
746
|
const c = this.component;
|
|
490
747
|
return {
|
|
@@ -503,6 +760,9 @@ export class AIBudget {
|
|
|
503
760
|
ctx: RunMutationCtx,
|
|
504
761
|
args: { dailyNanos?: number; lifetimeNanos?: number }
|
|
505
762
|
) => ctx.runMutation(c.lib.bumpGlobal, args),
|
|
763
|
+
/** Default approaching-limit alert threshold (fraction of a cap, e.g. 0.8). */
|
|
764
|
+
setAlertDefaults: (ctx: RunMutationCtx, args: { warnAtPct?: number }) =>
|
|
765
|
+
ctx.runMutation(c.lib.setAlertDefaults, args),
|
|
506
766
|
/** Request-row retention window in ms (default 1h; 0 disables). */
|
|
507
767
|
setRetention: (ctx: RunMutationCtx, args: { retentionMs: number }) =>
|
|
508
768
|
ctx.runMutation(c.lib.setRetention, args),
|
|
@@ -522,7 +782,7 @@ export class AIBudget {
|
|
|
522
782
|
};
|
|
523
783
|
}
|
|
524
784
|
|
|
525
|
-
/** Per-model prices (
|
|
785
|
+
/** Per-model prices (nanodollars per million tokens) + server-tool fees. */
|
|
526
786
|
get prices() {
|
|
527
787
|
const c = this.component;
|
|
528
788
|
return {
|
|
@@ -533,10 +793,154 @@ export class AIBudget {
|
|
|
533
793
|
model: string;
|
|
534
794
|
inputNanosPerMTok: number;
|
|
535
795
|
outputNanosPerMTok: number;
|
|
796
|
+
/** Cache-read rate; defaults to a discount off input if omitted. */
|
|
797
|
+
cachedNanosPerMTok?: number;
|
|
536
798
|
}
|
|
537
799
|
) => ctx.runMutation(c.lib.setPrice, args),
|
|
800
|
+
/** Per-call fees for provider server tools (web search, etc.). */
|
|
801
|
+
listServerTools: (ctx: RunQueryCtx) =>
|
|
802
|
+
ctx.runQuery(c.lib.listServerToolPrices, {}),
|
|
803
|
+
/** Set a server-tool's per-call price, e.g. { tool: "web_search", nanosPerCall }. */
|
|
804
|
+
setServerTool: (
|
|
805
|
+
ctx: RunMutationCtx,
|
|
806
|
+
args: { tool: string; nanosPerCall: number }
|
|
807
|
+
) => ctx.runMutation(c.lib.setServerToolPrice, args),
|
|
538
808
|
};
|
|
539
809
|
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Mount the built-in admin dashboard on your app's HTTP router with one call.
|
|
813
|
+
* Serves a self-contained HTML dashboard (buckets, requests, usage history,
|
|
814
|
+
* settings) plus a small JSON API, all backed by the component — no extra
|
|
815
|
+
* queries to write.
|
|
816
|
+
*
|
|
817
|
+
* // convex/http.ts
|
|
818
|
+
* import { httpRouter } from "convex/server";
|
|
819
|
+
* const http = httpRouter();
|
|
820
|
+
* ai.registerRoutes(http, { authorize: async (ctx) =>
|
|
821
|
+
* (await ctx.auth.getUserIdentity())?.role === "admin" });
|
|
822
|
+
* export default http;
|
|
823
|
+
*
|
|
824
|
+
* It then lives at `https://<deployment>.convex.site/aibudget`.
|
|
825
|
+
*
|
|
826
|
+
* SECURITY: the endpoint is public on the internet. You MUST gate it — either
|
|
827
|
+
* pass `authorize` (recommended: check the caller is a deployment admin) or
|
|
828
|
+
* set the `AI_BUDGET_DASHBOARD_TOKEN` env var (a bearer token / `?token=`).
|
|
829
|
+
* With neither, every route returns 401.
|
|
830
|
+
*/
|
|
831
|
+
registerRoutes(
|
|
832
|
+
http: HttpRouter,
|
|
833
|
+
opts: {
|
|
834
|
+
/** Mount path (default "/aibudget"). */
|
|
835
|
+
path?: string;
|
|
836
|
+
/** Return true to allow the request. Runs on the HTML page and every API call. */
|
|
837
|
+
authorize?: (ctx: any, request: Request) => boolean | Promise<boolean>;
|
|
838
|
+
} = {}
|
|
839
|
+
) {
|
|
840
|
+
const prefix = (opts.path ?? "/aibudget").replace(/\/+$/, "");
|
|
841
|
+
const c = this.component.lib;
|
|
842
|
+
const authorize = opts.authorize;
|
|
843
|
+
|
|
844
|
+
const guard = async (
|
|
845
|
+
ctx: any,
|
|
846
|
+
request: Request
|
|
847
|
+
): Promise<{ ok: boolean; token: string }> => {
|
|
848
|
+
if (authorize) return { ok: await authorize(ctx, request), token: "" };
|
|
849
|
+
const token = (globalThis as any).process?.env?.AI_BUDGET_DASHBOARD_TOKEN;
|
|
850
|
+
if (!token) return { ok: false, token: "" };
|
|
851
|
+
const url = new URL(request.url);
|
|
852
|
+
const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
|
|
853
|
+
// `?token=` is accepted only for the initial page navigation (a browser
|
|
854
|
+
// GET can't set headers); the page strips it from the URL on load and the
|
|
855
|
+
// JSON API is called with the bearer header. Compared in constant time.
|
|
856
|
+
const provided = bearer || url.searchParams.get("token") || "";
|
|
857
|
+
return { ok: timingSafeEqual(provided, token), token };
|
|
858
|
+
};
|
|
859
|
+
const json = (data: unknown, status = 200) =>
|
|
860
|
+
new Response(JSON.stringify(data ?? null), {
|
|
861
|
+
status,
|
|
862
|
+
headers: { "content-type": "application/json" },
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
const handle = async (ctx: any, request: Request): Promise<Response> => {
|
|
866
|
+
const url = new URL(request.url);
|
|
867
|
+
const sub = url.pathname.slice(prefix.length) || "/";
|
|
868
|
+
const { ok, token } = await guard(ctx, request);
|
|
869
|
+
if (!ok) {
|
|
870
|
+
return new Response(
|
|
871
|
+
"Unauthorized. Pass `authorize` to registerRoutes or set AI_BUDGET_DASHBOARD_TOKEN.",
|
|
872
|
+
{ status: 401 }
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (sub.startsWith("/api/")) {
|
|
877
|
+
const route = request.method + " " + sub.slice(4); // strip "/api"
|
|
878
|
+
const p: Record<string, string> = {};
|
|
879
|
+
url.searchParams.forEach((v, k) => {
|
|
880
|
+
p[k] = v;
|
|
881
|
+
});
|
|
882
|
+
const body =
|
|
883
|
+
request.method === "POST"
|
|
884
|
+
? await request.json().catch(() => ({}))
|
|
885
|
+
: {};
|
|
886
|
+
switch (route) {
|
|
887
|
+
case "GET /buckets":
|
|
888
|
+
return json(await ctx.runQuery(c.listBuckets, { dimension: p.dimension || undefined }));
|
|
889
|
+
case "GET /requests":
|
|
890
|
+
return json(await ctx.runQuery(c.listRequests, {
|
|
891
|
+
userId: p.userId || undefined,
|
|
892
|
+
dimension: p.dimension || undefined,
|
|
893
|
+
value: p.value || undefined,
|
|
894
|
+
limit: 100,
|
|
895
|
+
}));
|
|
896
|
+
case "GET /usage":
|
|
897
|
+
return json(await ctx.runQuery(c.usageHistory, {
|
|
898
|
+
dimension: p.dimension,
|
|
899
|
+
value: p.value,
|
|
900
|
+
period: p.period === "month" ? "month" : "day",
|
|
901
|
+
}));
|
|
902
|
+
case "GET /global":
|
|
903
|
+
return json(await ctx.runQuery(c.getGlobalStatus, {}));
|
|
904
|
+
case "GET /prices":
|
|
905
|
+
return json(await ctx.runQuery(c.listPrices, {}));
|
|
906
|
+
case "POST /setLimits":
|
|
907
|
+
return json(await ctx.runMutation(c.setBucketLimits, body));
|
|
908
|
+
case "POST /bump":
|
|
909
|
+
return json(await ctx.runMutation(c.bumpBucket, body));
|
|
910
|
+
case "POST /adjust":
|
|
911
|
+
return json(await ctx.runMutation(c.adjustBucket, body));
|
|
912
|
+
case "POST /delete":
|
|
913
|
+
return json(await ctx.runMutation(c.deleteBucket, body));
|
|
914
|
+
case "POST /global/setLimits":
|
|
915
|
+
return json(await ctx.runMutation(c.setGlobalLimits, body));
|
|
916
|
+
case "POST /global/setAlertDefaults":
|
|
917
|
+
return json(await ctx.runMutation(c.setAlertDefaults, body));
|
|
918
|
+
case "POST /global/setRetention":
|
|
919
|
+
return json(await ctx.runMutation(c.setRetention, body));
|
|
920
|
+
case "POST /setPrice":
|
|
921
|
+
return json(await ctx.runMutation(c.setPrice, body));
|
|
922
|
+
default:
|
|
923
|
+
return json({ error: "not found" }, 404);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Inject as JSON literals (function replacers so `$` in the value isn't
|
|
928
|
+
// treated as a replacement pattern). This keeps a token/prefix containing
|
|
929
|
+
// quotes, backslashes, or `</script>` from breaking out of the JS string.
|
|
930
|
+
const html = DASHBOARD_HTML.replace(
|
|
931
|
+
/__API_BASE__/g,
|
|
932
|
+
() => JSON.stringify(`${prefix}/api`)
|
|
933
|
+
).replace(/__TOKEN__/g, () => JSON.stringify(token));
|
|
934
|
+
return new Response(html, {
|
|
935
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
936
|
+
});
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
const handler = httpActionGeneric(handle);
|
|
940
|
+
http.route({ path: prefix, method: "GET", handler });
|
|
941
|
+
http.route({ pathPrefix: `${prefix}/`, method: "GET", handler });
|
|
942
|
+
http.route({ pathPrefix: `${prefix}/`, method: "POST", handler });
|
|
943
|
+
}
|
|
540
944
|
}
|
|
541
945
|
|
|
542
946
|
/** @deprecated Renamed to `AIBudget`. */
|