@convex-dev/ai-budget 0.0.2-alpha.0 → 0.0.2-alpha.10
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 +335 -183
- package/dist/client/dashboard.d.ts +1 -0
- package/dist/client/dashboard.js +223 -0
- package/dist/client/index.d.ts +494 -35
- package/dist/client/index.js +276 -33
- package/dist/component/_generated/component.d.ts +56 -24
- package/dist/component/lib.d.ts +132 -40
- package/dist/component/lib.js +590 -305
- package/dist/component/schema.d.ts +103 -53
- package/dist/component/schema.js +81 -38
- package/package.json +5 -5
- package/src/client/dashboard.ts +223 -0
- package/src/client/index.ts +407 -73
- package/src/component/_generated/component.ts +79 -29
- package/src/component/lib.test.ts +208 -15
- package/src/component/lib.ts +688 -333
- package/src/component/schema.ts +84 -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,23 +228,80 @@ 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
|
|
|
@@ -213,6 +320,8 @@ export class AIBudget {
|
|
|
213
320
|
rerunOf?: string;
|
|
214
321
|
/** Attribute spend to this action name. Defaults to the calling Convex action. */
|
|
215
322
|
action?: string;
|
|
323
|
+
/** Extra attribution dimensions to bill/limit (team, customer, env, …). */
|
|
324
|
+
tags?: Tag[];
|
|
216
325
|
} = {}
|
|
217
326
|
): Promise<ChatResult> {
|
|
218
327
|
const model = args.model ?? this.defaultModel;
|
|
@@ -223,11 +332,20 @@ export class AIBudget {
|
|
|
223
332
|
const started = await ctx.runMutation(this.component.lib.startRequest, {
|
|
224
333
|
userId,
|
|
225
334
|
actionName,
|
|
335
|
+
tags: args.tags,
|
|
226
336
|
model,
|
|
227
337
|
messages,
|
|
228
338
|
rerunOf: args.rerunOf as any,
|
|
229
339
|
});
|
|
230
340
|
if (!started.allowed) {
|
|
341
|
+
await this.fireLimitReached({
|
|
342
|
+
userId,
|
|
343
|
+
action: actionName,
|
|
344
|
+
tags: args.tags,
|
|
345
|
+
messages: [started.reason],
|
|
346
|
+
code: started.code,
|
|
347
|
+
reason: started.reason,
|
|
348
|
+
});
|
|
231
349
|
throw new ConvexError({
|
|
232
350
|
kind: "AIBudgetLimit",
|
|
233
351
|
code: started.code,
|
|
@@ -236,7 +354,12 @@ export class AIBudget {
|
|
|
236
354
|
}
|
|
237
355
|
const requestId = started.requestId;
|
|
238
356
|
const warnings = started.warnings;
|
|
239
|
-
|
|
357
|
+
const notices = started.notices;
|
|
358
|
+
await this.fireBudgetEvents(
|
|
359
|
+
{ userId, action: actionName, tags: args.tags, requestId },
|
|
360
|
+
warnings,
|
|
361
|
+
notices
|
|
362
|
+
);
|
|
240
363
|
const start = Date.now();
|
|
241
364
|
try {
|
|
242
365
|
// The full chain (incl. system) is stored on the request for audit/replay,
|
|
@@ -259,10 +382,11 @@ export class AIBudget {
|
|
|
259
382
|
requestId,
|
|
260
383
|
responseText: result.text,
|
|
261
384
|
...usage,
|
|
385
|
+
costNanos: extractGatewayCostNanos(result),
|
|
262
386
|
latencyMs: Date.now() - start,
|
|
263
387
|
}
|
|
264
388
|
);
|
|
265
|
-
return { text: result.text, requestId, costNanos, warnings, ...usage };
|
|
389
|
+
return { text: result.text, requestId, costNanos, warnings, notices, ...usage };
|
|
266
390
|
} catch (e) {
|
|
267
391
|
await ctx.runMutation(this.component.lib.finishRequest, {
|
|
268
392
|
requestId,
|
|
@@ -281,34 +405,48 @@ export class AIBudget {
|
|
|
281
405
|
*/
|
|
282
406
|
languageModel(
|
|
283
407
|
ctx: RunMutationCtx,
|
|
284
|
-
opts: {
|
|
408
|
+
opts: {
|
|
409
|
+
userId?: string;
|
|
410
|
+
model?: string;
|
|
411
|
+
action?: string;
|
|
412
|
+
/** Extra attribution dimensions to bill/limit (team, customer, env, …). */
|
|
413
|
+
tags?: Tag[];
|
|
414
|
+
} = {}
|
|
285
415
|
): LanguageModel {
|
|
286
416
|
const modelId = opts.model ?? this.defaultModel;
|
|
287
417
|
const component = this.component;
|
|
288
|
-
const
|
|
418
|
+
const fireBudgetEvents = this.fireBudgetEvents.bind(this);
|
|
419
|
+
const fireLimitReached = this.fireLimitReached.bind(this);
|
|
289
420
|
|
|
290
421
|
const begin = async (params: any) => {
|
|
291
422
|
const userId = await resolveUserId(ctx, opts.userId);
|
|
292
423
|
const actionName = await resolveActionName(ctx, opts.action);
|
|
424
|
+
const base = { userId, action: actionName, tags: opts.tags };
|
|
293
425
|
const started = await ctx.runMutation(component.lib.startRequest, {
|
|
294
426
|
userId,
|
|
295
427
|
actionName,
|
|
428
|
+
tags: opts.tags,
|
|
296
429
|
model: modelId,
|
|
297
430
|
messages: simplifyPrompt(params.prompt),
|
|
298
431
|
});
|
|
299
432
|
if (!started.allowed) {
|
|
433
|
+
await fireLimitReached({
|
|
434
|
+
...base,
|
|
435
|
+
messages: [started.reason],
|
|
436
|
+
code: started.code,
|
|
437
|
+
reason: started.reason,
|
|
438
|
+
});
|
|
300
439
|
throw new ConvexError({
|
|
301
440
|
kind: "AIBudgetLimit",
|
|
302
441
|
code: started.code,
|
|
303
442
|
reason: started.reason,
|
|
304
443
|
});
|
|
305
444
|
}
|
|
306
|
-
await
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
});
|
|
445
|
+
await fireBudgetEvents(
|
|
446
|
+
{ ...base, requestId: started.requestId },
|
|
447
|
+
started.warnings,
|
|
448
|
+
started.notices
|
|
449
|
+
);
|
|
312
450
|
return started.requestId;
|
|
313
451
|
};
|
|
314
452
|
const finish = async (
|
|
@@ -318,6 +456,8 @@ export class AIBudget {
|
|
|
318
456
|
error?: string;
|
|
319
457
|
promptTokens?: number;
|
|
320
458
|
completionTokens?: number;
|
|
459
|
+
cachedTokens?: number;
|
|
460
|
+
costNanos?: number;
|
|
321
461
|
latencyMs?: number;
|
|
322
462
|
}
|
|
323
463
|
) => ctx.runMutation(component.lib.finishRequest, { requestId, ...fields });
|
|
@@ -333,6 +473,7 @@ export class AIBudget {
|
|
|
333
473
|
await finish(requestId, {
|
|
334
474
|
responseText: extractText(result),
|
|
335
475
|
...extractUsage(result.usage),
|
|
476
|
+
costNanos: extractGatewayCostNanos(result),
|
|
336
477
|
latencyMs: Date.now() - start,
|
|
337
478
|
});
|
|
338
479
|
return result;
|
|
@@ -349,6 +490,7 @@ export class AIBudget {
|
|
|
349
490
|
const start = Date.now();
|
|
350
491
|
let text = "";
|
|
351
492
|
let usage: any = undefined;
|
|
493
|
+
let providerMetadata: any = undefined;
|
|
352
494
|
try {
|
|
353
495
|
const result = await doStream();
|
|
354
496
|
// finishRequest is idempotent (terminal-guarded server-side), so
|
|
@@ -364,6 +506,7 @@ export class AIBudget {
|
|
|
364
506
|
responseText: text,
|
|
365
507
|
error,
|
|
366
508
|
...extractUsage(usage),
|
|
509
|
+
costNanos: extractGatewayCostNanos({ providerMetadata }),
|
|
367
510
|
latencyMs: Date.now() - start,
|
|
368
511
|
});
|
|
369
512
|
};
|
|
@@ -373,7 +516,10 @@ export class AIBudget {
|
|
|
373
516
|
if (chunk?.type === "text-delta") {
|
|
374
517
|
text += chunk.delta ?? chunk.textDelta ?? "";
|
|
375
518
|
}
|
|
376
|
-
if (chunk?.type === "finish")
|
|
519
|
+
if (chunk?.type === "finish") {
|
|
520
|
+
usage = chunk.usage;
|
|
521
|
+
providerMetadata = chunk.providerMetadata ?? providerMetadata;
|
|
522
|
+
}
|
|
377
523
|
if (chunk?.type === "error") void settle(String(chunk.error));
|
|
378
524
|
controller.enqueue(chunk);
|
|
379
525
|
},
|
|
@@ -409,6 +555,7 @@ export class AIBudget {
|
|
|
409
555
|
messages: args.messages ?? original.messages,
|
|
410
556
|
rerunOf: args.requestId,
|
|
411
557
|
action: original.actionName,
|
|
558
|
+
tags: original.tags,
|
|
412
559
|
});
|
|
413
560
|
}
|
|
414
561
|
|
|
@@ -418,8 +565,19 @@ export class AIBudget {
|
|
|
418
565
|
get requests() {
|
|
419
566
|
const c = this.component;
|
|
420
567
|
return {
|
|
421
|
-
|
|
422
|
-
|
|
568
|
+
/** Filter by userId, or by any {dimension, value} (incl. custom tags). */
|
|
569
|
+
list: (
|
|
570
|
+
ctx: RunQueryCtx,
|
|
571
|
+
args: {
|
|
572
|
+
userId?: string;
|
|
573
|
+
dimension?: string;
|
|
574
|
+
value?: string;
|
|
575
|
+
limit?: number;
|
|
576
|
+
} = {}
|
|
577
|
+
) => ctx.runQuery(c.lib.listRequests, args),
|
|
578
|
+
/** One request, including its stored prompt and response. */
|
|
579
|
+
get: (ctx: RunQueryCtx, args: { requestId: string }) =>
|
|
580
|
+
ctx.runQuery(c.lib.getRequest, { requestId: args.requestId as any }),
|
|
423
581
|
/** Ancestors up to the original, plus direct re-runs. */
|
|
424
582
|
lineage: (ctx: RunQueryCtx, args: { requestId: string }) =>
|
|
425
583
|
ctx.runQuery(c.lib.lineage, { requestId: args.requestId as any }),
|
|
@@ -431,60 +589,97 @@ export class AIBudget {
|
|
|
431
589
|
};
|
|
432
590
|
}
|
|
433
591
|
|
|
434
|
-
/**
|
|
435
|
-
|
|
592
|
+
/**
|
|
593
|
+
* Budgets and controls for an arbitrary attribution dimension — the
|
|
594
|
+
* generalization of `users`/`actions`. Give it any dimension name (team,
|
|
595
|
+
* project, tenant, customer, env, feature, …) and set caps per value:
|
|
596
|
+
*
|
|
597
|
+
* ai.tag("customer").setLimits(ctx, { value: "acme", monthlySpendLimitNanos });
|
|
598
|
+
* ai.tag("customer").history(ctx, { value: "acme", period: "day" });
|
|
599
|
+
*
|
|
600
|
+
* Attribute a call to it by passing `tags` to `chat`/`languageModel`.
|
|
601
|
+
*/
|
|
602
|
+
tag(dimension: string) {
|
|
603
|
+
return this.dimensionApi(dimension, (a: { value: string }) => a.value);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Shared implementation behind tag()/users/actions. `key` maps the namespace's
|
|
607
|
+
// id field (value/userId/name) to the bucket value.
|
|
608
|
+
private dimensionApi<A extends Record<string, any>>(
|
|
609
|
+
dimension: string,
|
|
610
|
+
key: (a: A) => string
|
|
611
|
+
) {
|
|
436
612
|
const c = this.component;
|
|
437
613
|
return {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
614
|
+
/** All buckets in this dimension. */
|
|
615
|
+
list: (ctx: RunQueryCtx) => ctx.runQuery(c.lib.listBuckets, { dimension }),
|
|
616
|
+
/** One bucket's limits + spend (null if it has none yet). */
|
|
617
|
+
get: (ctx: RunQueryCtx, args: A) =>
|
|
618
|
+
ctx.runQuery(c.lib.getBucket, { dimension, value: key(args) }),
|
|
619
|
+
setLimits: (ctx: RunMutationCtx, args: A & BucketLimits) => {
|
|
620
|
+
const { value, userId, name, ...limits } = args as any;
|
|
621
|
+
return ctx.runMutation(c.lib.setBucketLimits, {
|
|
622
|
+
dimension,
|
|
623
|
+
value: key(args),
|
|
624
|
+
...limits,
|
|
625
|
+
});
|
|
626
|
+
},
|
|
627
|
+
/** One-time "approve another $X" bump (daily/monthly reset with the window). */
|
|
628
|
+
bump: (ctx: RunMutationCtx, args: A & BumpArgs) =>
|
|
629
|
+
ctx.runMutation(c.lib.bumpBucket, {
|
|
630
|
+
dimension,
|
|
631
|
+
value: key(args),
|
|
632
|
+
dailyNanos: args.dailyNanos,
|
|
633
|
+
monthlyNanos: args.monthlyNanos,
|
|
634
|
+
lifetimeNanos: args.lifetimeNanos,
|
|
635
|
+
}),
|
|
636
|
+
/** Manually credit (negative) or debit (positive) this bucket. */
|
|
637
|
+
adjust: (
|
|
454
638
|
ctx: RunMutationCtx,
|
|
455
|
-
args: {
|
|
456
|
-
) =>
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
639
|
+
args: A & { deltaNanos: number; tokens?: number; reason?: string }
|
|
640
|
+
) =>
|
|
641
|
+
ctx.runMutation(c.lib.adjustBucket, {
|
|
642
|
+
dimension,
|
|
643
|
+
value: key(args),
|
|
644
|
+
deltaNanos: args.deltaNanos,
|
|
645
|
+
tokens: args.tokens,
|
|
646
|
+
reason: args.reason,
|
|
647
|
+
}),
|
|
648
|
+
/** Durable spend history for this bucket (per day or per month). */
|
|
649
|
+
history: (
|
|
650
|
+
ctx: RunQueryCtx,
|
|
651
|
+
args: A & { period?: "day" | "month"; limit?: number }
|
|
652
|
+
) =>
|
|
653
|
+
ctx.runQuery(c.lib.usageHistory, {
|
|
654
|
+
dimension,
|
|
655
|
+
value: key(args),
|
|
656
|
+
period: args.period ?? "day",
|
|
657
|
+
limit: args.limit,
|
|
658
|
+
}),
|
|
659
|
+
/** Manual-adjustment audit log for this bucket. */
|
|
660
|
+
adjustments: (ctx: RunQueryCtx, args: A & { limit?: number }) =>
|
|
661
|
+
ctx.runQuery(c.lib.listAdjustments, {
|
|
662
|
+
dimension,
|
|
663
|
+
value: key(args),
|
|
664
|
+
limit: args.limit,
|
|
665
|
+
}),
|
|
666
|
+
/** Delete the bucket (for "user", also its request rows). */
|
|
667
|
+
delete: (ctx: RunMutationCtx, args: A) =>
|
|
668
|
+
ctx.runMutation(c.lib.deleteBucket, { dimension, value: key(args) }),
|
|
460
669
|
};
|
|
461
670
|
}
|
|
462
671
|
|
|
463
|
-
/** Per-
|
|
672
|
+
/** Per-user budgets and controls — sugar over the "user" dimension. */
|
|
673
|
+
get users() {
|
|
674
|
+
return this.dimensionApi<{ userId: string }>("user", (a) => a.userId);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Per-action (per-feature) budgets — sugar over the "action" dimension. */
|
|
464
678
|
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
|
-
};
|
|
679
|
+
return this.dimensionApi<{ name: string }>("action", (a) => a.name);
|
|
485
680
|
}
|
|
486
681
|
|
|
487
|
-
/** The deployment-wide budget and retention config. */
|
|
682
|
+
/** The deployment-wide budget, alerts, and retention config. */
|
|
488
683
|
get global() {
|
|
489
684
|
const c = this.component;
|
|
490
685
|
return {
|
|
@@ -503,6 +698,9 @@ export class AIBudget {
|
|
|
503
698
|
ctx: RunMutationCtx,
|
|
504
699
|
args: { dailyNanos?: number; lifetimeNanos?: number }
|
|
505
700
|
) => ctx.runMutation(c.lib.bumpGlobal, args),
|
|
701
|
+
/** Default approaching-limit alert threshold (fraction of a cap, e.g. 0.8). */
|
|
702
|
+
setAlertDefaults: (ctx: RunMutationCtx, args: { warnAtPct?: number }) =>
|
|
703
|
+
ctx.runMutation(c.lib.setAlertDefaults, args),
|
|
506
704
|
/** Request-row retention window in ms (default 1h; 0 disables). */
|
|
507
705
|
setRetention: (ctx: RunMutationCtx, args: { retentionMs: number }) =>
|
|
508
706
|
ctx.runMutation(c.lib.setRetention, args),
|
|
@@ -533,10 +731,146 @@ export class AIBudget {
|
|
|
533
731
|
model: string;
|
|
534
732
|
inputNanosPerMTok: number;
|
|
535
733
|
outputNanosPerMTok: number;
|
|
734
|
+
/** Cache-read rate; defaults to a discount off input if omitted. */
|
|
735
|
+
cachedNanosPerMTok?: number;
|
|
536
736
|
}
|
|
537
737
|
) => ctx.runMutation(c.lib.setPrice, args),
|
|
538
738
|
};
|
|
539
739
|
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Mount the built-in admin dashboard on your app's HTTP router with one call.
|
|
743
|
+
* Serves a self-contained HTML dashboard (buckets, requests, usage history,
|
|
744
|
+
* settings) plus a small JSON API, all backed by the component — no extra
|
|
745
|
+
* queries to write.
|
|
746
|
+
*
|
|
747
|
+
* // convex/http.ts
|
|
748
|
+
* import { httpRouter } from "convex/server";
|
|
749
|
+
* const http = httpRouter();
|
|
750
|
+
* ai.registerRoutes(http, { authorize: async (ctx) =>
|
|
751
|
+
* (await ctx.auth.getUserIdentity())?.role === "admin" });
|
|
752
|
+
* export default http;
|
|
753
|
+
*
|
|
754
|
+
* It then lives at `https://<deployment>.convex.site/aibudget`.
|
|
755
|
+
*
|
|
756
|
+
* SECURITY: the endpoint is public on the internet. You MUST gate it — either
|
|
757
|
+
* pass `authorize` (recommended: check the caller is a deployment admin) or
|
|
758
|
+
* set the `AI_BUDGET_DASHBOARD_TOKEN` env var (a bearer token / `?token=`).
|
|
759
|
+
* With neither, every route returns 401.
|
|
760
|
+
*/
|
|
761
|
+
registerRoutes(
|
|
762
|
+
http: HttpRouter,
|
|
763
|
+
opts: {
|
|
764
|
+
/** Mount path (default "/aibudget"). */
|
|
765
|
+
path?: string;
|
|
766
|
+
/** Return true to allow the request. Runs on the HTML page and every API call. */
|
|
767
|
+
authorize?: (ctx: any, request: Request) => boolean | Promise<boolean>;
|
|
768
|
+
} = {}
|
|
769
|
+
) {
|
|
770
|
+
const prefix = (opts.path ?? "/aibudget").replace(/\/+$/, "");
|
|
771
|
+
const c = this.component.lib;
|
|
772
|
+
const authorize = opts.authorize;
|
|
773
|
+
|
|
774
|
+
const guard = async (
|
|
775
|
+
ctx: any,
|
|
776
|
+
request: Request
|
|
777
|
+
): Promise<{ ok: boolean; token: string }> => {
|
|
778
|
+
if (authorize) return { ok: await authorize(ctx, request), token: "" };
|
|
779
|
+
const token = (globalThis as any).process?.env?.AI_BUDGET_DASHBOARD_TOKEN;
|
|
780
|
+
if (!token) return { ok: false, token: "" };
|
|
781
|
+
const url = new URL(request.url);
|
|
782
|
+
const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
|
|
783
|
+
// `?token=` is accepted only for the initial page navigation (a browser
|
|
784
|
+
// GET can't set headers); the page strips it from the URL on load and the
|
|
785
|
+
// JSON API is called with the bearer header. Compared in constant time.
|
|
786
|
+
const provided = bearer || url.searchParams.get("token") || "";
|
|
787
|
+
return { ok: timingSafeEqual(provided, token), token };
|
|
788
|
+
};
|
|
789
|
+
const json = (data: unknown, status = 200) =>
|
|
790
|
+
new Response(JSON.stringify(data ?? null), {
|
|
791
|
+
status,
|
|
792
|
+
headers: { "content-type": "application/json" },
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const handle = async (ctx: any, request: Request): Promise<Response> => {
|
|
796
|
+
const url = new URL(request.url);
|
|
797
|
+
const sub = url.pathname.slice(prefix.length) || "/";
|
|
798
|
+
const { ok, token } = await guard(ctx, request);
|
|
799
|
+
if (!ok) {
|
|
800
|
+
return new Response(
|
|
801
|
+
"Unauthorized. Pass `authorize` to registerRoutes or set AI_BUDGET_DASHBOARD_TOKEN.",
|
|
802
|
+
{ status: 401 }
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (sub.startsWith("/api/")) {
|
|
807
|
+
const route = request.method + " " + sub.slice(4); // strip "/api"
|
|
808
|
+
const p: Record<string, string> = {};
|
|
809
|
+
url.searchParams.forEach((v, k) => {
|
|
810
|
+
p[k] = v;
|
|
811
|
+
});
|
|
812
|
+
const body =
|
|
813
|
+
request.method === "POST"
|
|
814
|
+
? await request.json().catch(() => ({}))
|
|
815
|
+
: {};
|
|
816
|
+
switch (route) {
|
|
817
|
+
case "GET /buckets":
|
|
818
|
+
return json(await ctx.runQuery(c.listBuckets, { dimension: p.dimension || undefined }));
|
|
819
|
+
case "GET /requests":
|
|
820
|
+
return json(await ctx.runQuery(c.listRequests, {
|
|
821
|
+
userId: p.userId || undefined,
|
|
822
|
+
dimension: p.dimension || undefined,
|
|
823
|
+
value: p.value || undefined,
|
|
824
|
+
limit: 100,
|
|
825
|
+
}));
|
|
826
|
+
case "GET /usage":
|
|
827
|
+
return json(await ctx.runQuery(c.usageHistory, {
|
|
828
|
+
dimension: p.dimension,
|
|
829
|
+
value: p.value,
|
|
830
|
+
period: p.period === "month" ? "month" : "day",
|
|
831
|
+
}));
|
|
832
|
+
case "GET /global":
|
|
833
|
+
return json(await ctx.runQuery(c.getGlobalStatus, {}));
|
|
834
|
+
case "GET /prices":
|
|
835
|
+
return json(await ctx.runQuery(c.listPrices, {}));
|
|
836
|
+
case "POST /setLimits":
|
|
837
|
+
return json(await ctx.runMutation(c.setBucketLimits, body));
|
|
838
|
+
case "POST /bump":
|
|
839
|
+
return json(await ctx.runMutation(c.bumpBucket, body));
|
|
840
|
+
case "POST /adjust":
|
|
841
|
+
return json(await ctx.runMutation(c.adjustBucket, body));
|
|
842
|
+
case "POST /delete":
|
|
843
|
+
return json(await ctx.runMutation(c.deleteBucket, body));
|
|
844
|
+
case "POST /global/setLimits":
|
|
845
|
+
return json(await ctx.runMutation(c.setGlobalLimits, body));
|
|
846
|
+
case "POST /global/setAlertDefaults":
|
|
847
|
+
return json(await ctx.runMutation(c.setAlertDefaults, body));
|
|
848
|
+
case "POST /global/setRetention":
|
|
849
|
+
return json(await ctx.runMutation(c.setRetention, body));
|
|
850
|
+
case "POST /setPrice":
|
|
851
|
+
return json(await ctx.runMutation(c.setPrice, body));
|
|
852
|
+
default:
|
|
853
|
+
return json({ error: "not found" }, 404);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Inject as JSON literals (function replacers so `$` in the value isn't
|
|
858
|
+
// treated as a replacement pattern). This keeps a token/prefix containing
|
|
859
|
+
// quotes, backslashes, or `</script>` from breaking out of the JS string.
|
|
860
|
+
const html = DASHBOARD_HTML.replace(
|
|
861
|
+
/__API_BASE__/g,
|
|
862
|
+
() => JSON.stringify(`${prefix}/api`)
|
|
863
|
+
).replace(/__TOKEN__/g, () => JSON.stringify(token));
|
|
864
|
+
return new Response(html, {
|
|
865
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
866
|
+
});
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const handler = httpActionGeneric(handle);
|
|
870
|
+
http.route({ path: prefix, method: "GET", handler });
|
|
871
|
+
http.route({ pathPrefix: `${prefix}/`, method: "GET", handler });
|
|
872
|
+
http.route({ pathPrefix: `${prefix}/`, method: "POST", handler });
|
|
873
|
+
}
|
|
540
874
|
}
|
|
541
875
|
|
|
542
876
|
/** @deprecated Renamed to `AIBudget`. */
|