@convex-dev/ai-budget 0.0.2-alpha.4 → 0.0.2-alpha.6
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 +98 -6
- package/dist/client/dashboard.d.ts +1 -0
- package/dist/client/dashboard.js +215 -0
- package/dist/client/index.d.ts +347 -44
- package/dist/client/index.js +222 -68
- package/dist/component/_generated/component.d.ts +34 -0
- package/dist/component/lib.d.ts +75 -0
- package/dist/component/lib.js +309 -32
- package/dist/component/schema.d.ts +76 -3
- package/dist/component/schema.js +56 -2
- package/package.json +1 -1
- package/src/client/dashboard.ts +215 -0
- package/src/client/index.ts +334 -132
- package/src/component/_generated/component.ts +56 -3
- package/src/component/lib.test.ts +118 -0
- package/src/component/lib.ts +380 -48
- package/src/component/schema.ts +59 -2
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
|
|
|
@@ -49,22 +55,32 @@ export type AIGatewayApi = AIBudgetApi;
|
|
|
49
55
|
*/
|
|
50
56
|
export type Tag = { dimension: string; value: string };
|
|
51
57
|
|
|
52
|
-
/**
|
|
53
|
-
export type
|
|
58
|
+
/** Common shape for budget-event callbacks. */
|
|
59
|
+
export type BudgetEventInfo = {
|
|
54
60
|
userId: string;
|
|
55
61
|
action?: string;
|
|
56
62
|
tags?: Tag[];
|
|
57
|
-
requestId
|
|
58
|
-
warnings
|
|
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;
|
|
59
69
|
};
|
|
70
|
+
/** @deprecated use BudgetEventInfo */
|
|
71
|
+
export type SoftLimitInfo = BudgetEventInfo & { warnings: string[] };
|
|
60
72
|
export type AIBudgetOptions = {
|
|
61
73
|
defaultModel?: string;
|
|
62
74
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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.
|
|
66
78
|
*/
|
|
67
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>;
|
|
68
84
|
};
|
|
69
85
|
|
|
70
86
|
type RunQueryCtx = {
|
|
@@ -123,6 +139,8 @@ export type ChatResult = {
|
|
|
123
139
|
cachedTokens: number;
|
|
124
140
|
/** Soft-limit warnings raised at admission (empty unless a soft cap was hit). */
|
|
125
141
|
warnings: string[];
|
|
142
|
+
/** Approaching-cap notices (empty unless a warnAtPct threshold was crossed). */
|
|
143
|
+
notices: string[];
|
|
126
144
|
};
|
|
127
145
|
|
|
128
146
|
// ---------- helpers ----------
|
|
@@ -143,10 +161,12 @@ function extractUsage(usage: any): {
|
|
|
143
161
|
return {
|
|
144
162
|
promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens),
|
|
145
163
|
completionTokens: toTokenCount(usage?.outputTokens ?? usage?.completionTokens),
|
|
146
|
-
// cached prompt tokens
|
|
147
|
-
// `
|
|
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.
|
|
148
167
|
cachedTokens: toTokenCount(
|
|
149
|
-
usage?.
|
|
168
|
+
usage?.inputTokenDetails?.cacheReadTokens ??
|
|
169
|
+
usage?.cachedInputTokens ??
|
|
150
170
|
usage?.promptTokensDetails?.cachedTokens ??
|
|
151
171
|
usage?.prompt_tokens_details?.cached_tokens ??
|
|
152
172
|
usage?.cached_tokens
|
|
@@ -154,6 +174,20 @@ function extractUsage(usage: any): {
|
|
|
154
174
|
};
|
|
155
175
|
}
|
|
156
176
|
|
|
177
|
+
// The gateway doesn't report a dollar cost today, but if a future response ever
|
|
178
|
+
// carries an unambiguous nanodollar cost we pass it straight through as
|
|
179
|
+
// authoritative (finishRequest prefers it over the token-based estimate). Only
|
|
180
|
+
// an explicitly nano-denominated field is trusted — a bare `cost` could be in
|
|
181
|
+
// dollars and silently mis-bill by 1e9×.
|
|
182
|
+
function extractGatewayCostNanos(result: any): number | undefined {
|
|
183
|
+
const meta = result?.providerMetadata?.convexGateway;
|
|
184
|
+
const candidates = [meta?.costNanos, result?.usage?.costNanos];
|
|
185
|
+
for (const c of candidates) {
|
|
186
|
+
if (typeof c === "number" && Number.isFinite(c) && c >= 0) return c;
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
157
191
|
// Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
|
|
158
192
|
function simplifyPrompt(prompt: any): Message[] {
|
|
159
193
|
if (!Array.isArray(prompt)) return [];
|
|
@@ -187,23 +221,71 @@ function extractText(result: any): string {
|
|
|
187
221
|
|
|
188
222
|
// ---------- client ----------
|
|
189
223
|
|
|
224
|
+
/** Limits/controls settable on any budget bucket (user, action, or tag). */
|
|
225
|
+
export type BucketLimits = {
|
|
226
|
+
requestsPerMinute?: number;
|
|
227
|
+
maxConcurrent?: number;
|
|
228
|
+
dailySpendLimitNanos?: number;
|
|
229
|
+
monthlySpendLimitNanos?: number;
|
|
230
|
+
lifetimeSpendLimitNanos?: number;
|
|
231
|
+
dailyTokenLimit?: number;
|
|
232
|
+
monthlyTokenLimit?: number;
|
|
233
|
+
lifetimeTokenLimit?: number;
|
|
234
|
+
/** Fire an approaching-limit alert at this fraction of a cap (e.g. 0.8). */
|
|
235
|
+
warnAtPct?: number;
|
|
236
|
+
enforcement?: "hard" | "soft";
|
|
237
|
+
blocked?: boolean;
|
|
238
|
+
};
|
|
239
|
+
/** One-time bump amounts, added on top of a standing cap. */
|
|
240
|
+
export type BumpArgs = {
|
|
241
|
+
dailyNanos?: number;
|
|
242
|
+
monthlyNanos?: number;
|
|
243
|
+
lifetimeNanos?: number;
|
|
244
|
+
};
|
|
245
|
+
|
|
190
246
|
export class AIBudget {
|
|
191
247
|
public defaultModel: string;
|
|
192
248
|
private onSoftLimit?: AIBudgetOptions["onSoftLimit"];
|
|
249
|
+
private onThreshold?: AIBudgetOptions["onThreshold"];
|
|
250
|
+
private onLimitReached?: AIBudgetOptions["onLimitReached"];
|
|
193
251
|
constructor(
|
|
194
252
|
public component: AIBudgetApi,
|
|
195
253
|
options?: AIBudgetOptions
|
|
196
254
|
) {
|
|
197
255
|
this.defaultModel = options?.defaultModel ?? "openai/gpt-4o-mini";
|
|
198
256
|
this.onSoftLimit = options?.onSoftLimit;
|
|
257
|
+
this.onThreshold = options?.onThreshold;
|
|
258
|
+
this.onLimitReached = options?.onLimitReached;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Fire the soft-limit + threshold callbacks from a startRequest result.
|
|
262
|
+
private async fireBudgetEvents(
|
|
263
|
+
base: Omit<BudgetEventInfo, "messages">,
|
|
264
|
+
warnings: string[],
|
|
265
|
+
notices: string[]
|
|
266
|
+
) {
|
|
267
|
+
if (warnings.length > 0 && this.onSoftLimit) {
|
|
268
|
+
try {
|
|
269
|
+
await this.onSoftLimit({ ...base, messages: warnings, warnings });
|
|
270
|
+
} catch {
|
|
271
|
+
/* never let a callback error break a request */
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (notices.length > 0 && this.onThreshold) {
|
|
275
|
+
try {
|
|
276
|
+
await this.onThreshold({ ...base, messages: notices });
|
|
277
|
+
} catch {
|
|
278
|
+
/* swallow */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
199
281
|
}
|
|
200
282
|
|
|
201
|
-
private async
|
|
202
|
-
if (
|
|
283
|
+
private async fireLimitReached(info: BudgetEventInfo) {
|
|
284
|
+
if (!this.onLimitReached) return;
|
|
203
285
|
try {
|
|
204
|
-
await this.
|
|
286
|
+
await this.onLimitReached(info);
|
|
205
287
|
} catch {
|
|
206
|
-
|
|
288
|
+
/* swallow */
|
|
207
289
|
}
|
|
208
290
|
}
|
|
209
291
|
|
|
@@ -240,6 +322,14 @@ export class AIBudget {
|
|
|
240
322
|
rerunOf: args.rerunOf as any,
|
|
241
323
|
});
|
|
242
324
|
if (!started.allowed) {
|
|
325
|
+
await this.fireLimitReached({
|
|
326
|
+
userId,
|
|
327
|
+
action: actionName,
|
|
328
|
+
tags: args.tags,
|
|
329
|
+
messages: [started.reason],
|
|
330
|
+
code: started.code,
|
|
331
|
+
reason: started.reason,
|
|
332
|
+
});
|
|
243
333
|
throw new ConvexError({
|
|
244
334
|
kind: "AIBudgetLimit",
|
|
245
335
|
code: started.code,
|
|
@@ -248,13 +338,12 @@ export class AIBudget {
|
|
|
248
338
|
}
|
|
249
339
|
const requestId = started.requestId;
|
|
250
340
|
const warnings = started.warnings;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
action: actionName,
|
|
254
|
-
tags: args.tags,
|
|
255
|
-
requestId,
|
|
341
|
+
const notices = started.notices;
|
|
342
|
+
await this.fireBudgetEvents(
|
|
343
|
+
{ userId, action: actionName, tags: args.tags, requestId },
|
|
256
344
|
warnings,
|
|
257
|
-
|
|
345
|
+
notices
|
|
346
|
+
);
|
|
258
347
|
const start = Date.now();
|
|
259
348
|
try {
|
|
260
349
|
// The full chain (incl. system) is stored on the request for audit/replay,
|
|
@@ -277,10 +366,11 @@ export class AIBudget {
|
|
|
277
366
|
requestId,
|
|
278
367
|
responseText: result.text,
|
|
279
368
|
...usage,
|
|
369
|
+
costNanos: extractGatewayCostNanos(result),
|
|
280
370
|
latencyMs: Date.now() - start,
|
|
281
371
|
}
|
|
282
372
|
);
|
|
283
|
-
return { text: result.text, requestId, costNanos, warnings, ...usage };
|
|
373
|
+
return { text: result.text, requestId, costNanos, warnings, notices, ...usage };
|
|
284
374
|
} catch (e) {
|
|
285
375
|
await ctx.runMutation(this.component.lib.finishRequest, {
|
|
286
376
|
requestId,
|
|
@@ -309,11 +399,13 @@ export class AIBudget {
|
|
|
309
399
|
): LanguageModel {
|
|
310
400
|
const modelId = opts.model ?? this.defaultModel;
|
|
311
401
|
const component = this.component;
|
|
312
|
-
const
|
|
402
|
+
const fireBudgetEvents = this.fireBudgetEvents.bind(this);
|
|
403
|
+
const fireLimitReached = this.fireLimitReached.bind(this);
|
|
313
404
|
|
|
314
405
|
const begin = async (params: any) => {
|
|
315
406
|
const userId = await resolveUserId(ctx, opts.userId);
|
|
316
407
|
const actionName = await resolveActionName(ctx, opts.action);
|
|
408
|
+
const base = { userId, action: actionName, tags: opts.tags };
|
|
317
409
|
const started = await ctx.runMutation(component.lib.startRequest, {
|
|
318
410
|
userId,
|
|
319
411
|
actionName,
|
|
@@ -322,19 +414,23 @@ export class AIBudget {
|
|
|
322
414
|
messages: simplifyPrompt(params.prompt),
|
|
323
415
|
});
|
|
324
416
|
if (!started.allowed) {
|
|
417
|
+
await fireLimitReached({
|
|
418
|
+
...base,
|
|
419
|
+
messages: [started.reason],
|
|
420
|
+
code: started.code,
|
|
421
|
+
reason: started.reason,
|
|
422
|
+
});
|
|
325
423
|
throw new ConvexError({
|
|
326
424
|
kind: "AIBudgetLimit",
|
|
327
425
|
code: started.code,
|
|
328
426
|
reason: started.reason,
|
|
329
427
|
});
|
|
330
428
|
}
|
|
331
|
-
await
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
warnings: started.warnings,
|
|
337
|
-
});
|
|
429
|
+
await fireBudgetEvents(
|
|
430
|
+
{ ...base, requestId: started.requestId },
|
|
431
|
+
started.warnings,
|
|
432
|
+
started.notices
|
|
433
|
+
);
|
|
338
434
|
return started.requestId;
|
|
339
435
|
};
|
|
340
436
|
const finish = async (
|
|
@@ -344,6 +440,8 @@ export class AIBudget {
|
|
|
344
440
|
error?: string;
|
|
345
441
|
promptTokens?: number;
|
|
346
442
|
completionTokens?: number;
|
|
443
|
+
cachedTokens?: number;
|
|
444
|
+
costNanos?: number;
|
|
347
445
|
latencyMs?: number;
|
|
348
446
|
}
|
|
349
447
|
) => ctx.runMutation(component.lib.finishRequest, { requestId, ...fields });
|
|
@@ -359,6 +457,7 @@ export class AIBudget {
|
|
|
359
457
|
await finish(requestId, {
|
|
360
458
|
responseText: extractText(result),
|
|
361
459
|
...extractUsage(result.usage),
|
|
460
|
+
costNanos: extractGatewayCostNanos(result),
|
|
362
461
|
latencyMs: Date.now() - start,
|
|
363
462
|
});
|
|
364
463
|
return result;
|
|
@@ -445,8 +544,16 @@ export class AIBudget {
|
|
|
445
544
|
get requests() {
|
|
446
545
|
const c = this.component;
|
|
447
546
|
return {
|
|
448
|
-
|
|
449
|
-
|
|
547
|
+
/** Filter by userId, or by any {dimension, value} (incl. custom tags). */
|
|
548
|
+
list: (
|
|
549
|
+
ctx: RunQueryCtx,
|
|
550
|
+
args: {
|
|
551
|
+
userId?: string;
|
|
552
|
+
dimension?: string;
|
|
553
|
+
value?: string;
|
|
554
|
+
limit?: number;
|
|
555
|
+
} = {}
|
|
556
|
+
) => ctx.runQuery(c.lib.listRequests, args),
|
|
450
557
|
/** Ancestors up to the original, plus direct re-runs. */
|
|
451
558
|
lineage: (ctx: RunQueryCtx, args: { requestId: string }) =>
|
|
452
559
|
ctx.runQuery(c.lib.lineage, { requestId: args.requestId as any }),
|
|
@@ -463,130 +570,92 @@ export class AIBudget {
|
|
|
463
570
|
* generalization of `users`/`actions`. Give it any dimension name (team,
|
|
464
571
|
* project, tenant, customer, env, feature, …) and set caps per value:
|
|
465
572
|
*
|
|
466
|
-
* ai.tag("customer").setLimits(ctx, { value: "acme",
|
|
467
|
-
* ai.tag("customer").
|
|
573
|
+
* ai.tag("customer").setLimits(ctx, { value: "acme", monthlySpendLimitNanos });
|
|
574
|
+
* ai.tag("customer").history(ctx, { value: "acme", period: "day" });
|
|
468
575
|
*
|
|
469
576
|
* Attribute a call to it by passing `tags` to `chat`/`languageModel`.
|
|
470
577
|
*/
|
|
471
578
|
tag(dimension: string) {
|
|
472
|
-
|
|
473
|
-
return {
|
|
474
|
-
/** All buckets in this dimension. */
|
|
475
|
-
list: (ctx: RunQueryCtx) =>
|
|
476
|
-
ctx.runQuery(c.lib.listBuckets, { dimension }),
|
|
477
|
-
/** One bucket's limits + spend (null if it has none yet). */
|
|
478
|
-
get: (ctx: RunQueryCtx, args: { value: string }) =>
|
|
479
|
-
ctx.runQuery(c.lib.getBucket, { dimension, value: args.value }),
|
|
480
|
-
setLimits: (
|
|
481
|
-
ctx: RunMutationCtx,
|
|
482
|
-
args: {
|
|
483
|
-
value: string;
|
|
484
|
-
requestsPerMinute?: number;
|
|
485
|
-
dailySpendLimitNanos?: number;
|
|
486
|
-
lifetimeSpendLimitNanos?: number;
|
|
487
|
-
dailyTokenLimit?: number;
|
|
488
|
-
lifetimeTokenLimit?: number;
|
|
489
|
-
enforcement?: "hard" | "soft";
|
|
490
|
-
blocked?: boolean;
|
|
491
|
-
}
|
|
492
|
-
) => ctx.runMutation(c.lib.setBucketLimits, { dimension, ...args }),
|
|
493
|
-
/** One-time "approve another $X" bump (daily is today-only). */
|
|
494
|
-
bump: (
|
|
495
|
-
ctx: RunMutationCtx,
|
|
496
|
-
args: { value: string; dailyNanos?: number; lifetimeNanos?: number }
|
|
497
|
-
) => ctx.runMutation(c.lib.bumpBucket, { dimension, ...args }),
|
|
498
|
-
/** Delete the bucket (for "user", also its request rows). */
|
|
499
|
-
delete: (ctx: RunMutationCtx, args: { value: string }) =>
|
|
500
|
-
ctx.runMutation(c.lib.deleteBucket, { dimension, value: args.value }),
|
|
501
|
-
};
|
|
579
|
+
return this.dimensionApi(dimension, (a: { value: string }) => a.value);
|
|
502
580
|
}
|
|
503
581
|
|
|
504
|
-
|
|
505
|
-
|
|
582
|
+
// Shared implementation behind tag()/users/actions. `key` maps the namespace's
|
|
583
|
+
// id field (value/userId/name) to the bucket value.
|
|
584
|
+
private dimensionApi<A extends Record<string, any>>(
|
|
585
|
+
dimension: string,
|
|
586
|
+
key: (a: A) => string
|
|
587
|
+
) {
|
|
506
588
|
const c = this.component;
|
|
507
589
|
return {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
dailySpendLimitNanos?: number;
|
|
516
|
-
lifetimeSpendLimitNanos?: number;
|
|
517
|
-
dailyTokenLimit?: number;
|
|
518
|
-
lifetimeTokenLimit?: number;
|
|
519
|
-
enforcement?: "hard" | "soft";
|
|
520
|
-
blocked?: boolean;
|
|
521
|
-
}
|
|
522
|
-
) => {
|
|
523
|
-
const { userId, ...limits } = args;
|
|
590
|
+
/** All buckets in this dimension. */
|
|
591
|
+
list: (ctx: RunQueryCtx) => ctx.runQuery(c.lib.listBuckets, { dimension }),
|
|
592
|
+
/** One bucket's limits + spend (null if it has none yet). */
|
|
593
|
+
get: (ctx: RunQueryCtx, args: A) =>
|
|
594
|
+
ctx.runQuery(c.lib.getBucket, { dimension, value: key(args) }),
|
|
595
|
+
setLimits: (ctx: RunMutationCtx, args: A & BucketLimits) => {
|
|
596
|
+
const { value, userId, name, ...limits } = args as any;
|
|
524
597
|
return ctx.runMutation(c.lib.setBucketLimits, {
|
|
525
|
-
dimension
|
|
526
|
-
value:
|
|
598
|
+
dimension,
|
|
599
|
+
value: key(args),
|
|
527
600
|
...limits,
|
|
528
601
|
});
|
|
529
602
|
},
|
|
530
|
-
/** One-time "approve another $X" bump (daily
|
|
531
|
-
bump: (
|
|
532
|
-
ctx: RunMutationCtx,
|
|
533
|
-
args: { userId: string; dailyNanos?: number; lifetimeNanos?: number }
|
|
534
|
-
) =>
|
|
603
|
+
/** One-time "approve another $X" bump (daily/monthly reset with the window). */
|
|
604
|
+
bump: (ctx: RunMutationCtx, args: A & BumpArgs) =>
|
|
535
605
|
ctx.runMutation(c.lib.bumpBucket, {
|
|
536
|
-
dimension
|
|
537
|
-
value: args
|
|
606
|
+
dimension,
|
|
607
|
+
value: key(args),
|
|
538
608
|
dailyNanos: args.dailyNanos,
|
|
609
|
+
monthlyNanos: args.monthlyNanos,
|
|
539
610
|
lifetimeNanos: args.lifetimeNanos,
|
|
540
611
|
}),
|
|
541
|
-
/**
|
|
542
|
-
|
|
543
|
-
ctx
|
|
544
|
-
|
|
545
|
-
|
|
612
|
+
/** Manually credit (negative) or debit (positive) this bucket. */
|
|
613
|
+
adjust: (
|
|
614
|
+
ctx: RunMutationCtx,
|
|
615
|
+
args: A & { deltaNanos: number; tokens?: number; reason?: string }
|
|
616
|
+
) =>
|
|
617
|
+
ctx.runMutation(c.lib.adjustBucket, {
|
|
618
|
+
dimension,
|
|
619
|
+
value: key(args),
|
|
620
|
+
deltaNanos: args.deltaNanos,
|
|
621
|
+
tokens: args.tokens,
|
|
622
|
+
reason: args.reason,
|
|
623
|
+
}),
|
|
624
|
+
/** Durable spend history for this bucket (per day or per month). */
|
|
625
|
+
history: (
|
|
626
|
+
ctx: RunQueryCtx,
|
|
627
|
+
args: A & { period?: "day" | "month"; limit?: number }
|
|
628
|
+
) =>
|
|
629
|
+
ctx.runQuery(c.lib.usageHistory, {
|
|
630
|
+
dimension,
|
|
631
|
+
value: key(args),
|
|
632
|
+
period: args.period ?? "day",
|
|
633
|
+
limit: args.limit,
|
|
546
634
|
}),
|
|
635
|
+
/** Manual-adjustment audit log for this bucket. */
|
|
636
|
+
adjustments: (ctx: RunQueryCtx, args: A & { limit?: number }) =>
|
|
637
|
+
ctx.runQuery(c.lib.listAdjustments, {
|
|
638
|
+
dimension,
|
|
639
|
+
value: key(args),
|
|
640
|
+
limit: args.limit,
|
|
641
|
+
}),
|
|
642
|
+
/** Delete the bucket (for "user", also its request rows). */
|
|
643
|
+
delete: (ctx: RunMutationCtx, args: A) =>
|
|
644
|
+
ctx.runMutation(c.lib.deleteBucket, { dimension, value: key(args) }),
|
|
547
645
|
};
|
|
548
646
|
}
|
|
549
647
|
|
|
648
|
+
/** Per-user budgets and controls — sugar over the "user" dimension. */
|
|
649
|
+
get users() {
|
|
650
|
+
return this.dimensionApi<{ userId: string }>("user", (a) => a.userId);
|
|
651
|
+
}
|
|
652
|
+
|
|
550
653
|
/** Per-action (per-feature) budgets — sugar over the "action" dimension. */
|
|
551
654
|
get actions() {
|
|
552
|
-
|
|
553
|
-
return {
|
|
554
|
-
list: (ctx: RunQueryCtx) =>
|
|
555
|
-
ctx.runQuery(c.lib.listBuckets, { dimension: "action" }),
|
|
556
|
-
setLimits: (
|
|
557
|
-
ctx: RunMutationCtx,
|
|
558
|
-
args: {
|
|
559
|
-
name: string;
|
|
560
|
-
dailySpendLimitNanos?: number;
|
|
561
|
-
lifetimeSpendLimitNanos?: number;
|
|
562
|
-
dailyTokenLimit?: number;
|
|
563
|
-
lifetimeTokenLimit?: number;
|
|
564
|
-
enforcement?: "hard" | "soft";
|
|
565
|
-
/** Hard-block this action (was `disabled`). */
|
|
566
|
-
blocked?: boolean;
|
|
567
|
-
}
|
|
568
|
-
) => {
|
|
569
|
-
const { name, ...limits } = args;
|
|
570
|
-
return ctx.runMutation(c.lib.setBucketLimits, {
|
|
571
|
-
dimension: "action",
|
|
572
|
-
value: name,
|
|
573
|
-
...limits,
|
|
574
|
-
});
|
|
575
|
-
},
|
|
576
|
-
bump: (
|
|
577
|
-
ctx: RunMutationCtx,
|
|
578
|
-
args: { name: string; dailyNanos?: number; lifetimeNanos?: number }
|
|
579
|
-
) =>
|
|
580
|
-
ctx.runMutation(c.lib.bumpBucket, {
|
|
581
|
-
dimension: "action",
|
|
582
|
-
value: args.name,
|
|
583
|
-
dailyNanos: args.dailyNanos,
|
|
584
|
-
lifetimeNanos: args.lifetimeNanos,
|
|
585
|
-
}),
|
|
586
|
-
};
|
|
655
|
+
return this.dimensionApi<{ name: string }>("action", (a) => a.name);
|
|
587
656
|
}
|
|
588
657
|
|
|
589
|
-
/** The deployment-wide budget and retention config. */
|
|
658
|
+
/** The deployment-wide budget, alerts, and retention config. */
|
|
590
659
|
get global() {
|
|
591
660
|
const c = this.component;
|
|
592
661
|
return {
|
|
@@ -605,6 +674,9 @@ export class AIBudget {
|
|
|
605
674
|
ctx: RunMutationCtx,
|
|
606
675
|
args: { dailyNanos?: number; lifetimeNanos?: number }
|
|
607
676
|
) => ctx.runMutation(c.lib.bumpGlobal, args),
|
|
677
|
+
/** Default approaching-limit alert threshold (fraction of a cap, e.g. 0.8). */
|
|
678
|
+
setAlertDefaults: (ctx: RunMutationCtx, args: { warnAtPct?: number }) =>
|
|
679
|
+
ctx.runMutation(c.lib.setAlertDefaults, args),
|
|
608
680
|
/** Request-row retention window in ms (default 1h; 0 disables). */
|
|
609
681
|
setRetention: (ctx: RunMutationCtx, args: { retentionMs: number }) =>
|
|
610
682
|
ctx.runMutation(c.lib.setRetention, args),
|
|
@@ -635,10 +707,140 @@ export class AIBudget {
|
|
|
635
707
|
model: string;
|
|
636
708
|
inputNanosPerMTok: number;
|
|
637
709
|
outputNanosPerMTok: number;
|
|
710
|
+
/** Cache-read rate; defaults to a discount off input if omitted. */
|
|
711
|
+
cachedNanosPerMTok?: number;
|
|
638
712
|
}
|
|
639
713
|
) => ctx.runMutation(c.lib.setPrice, args),
|
|
640
714
|
};
|
|
641
715
|
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Mount the built-in admin dashboard on your app's HTTP router with one call.
|
|
719
|
+
* Serves a self-contained HTML dashboard (buckets, requests, usage history,
|
|
720
|
+
* settings) plus a small JSON API, all backed by the component — no extra
|
|
721
|
+
* queries to write.
|
|
722
|
+
*
|
|
723
|
+
* // convex/http.ts
|
|
724
|
+
* import { httpRouter } from "convex/server";
|
|
725
|
+
* const http = httpRouter();
|
|
726
|
+
* ai.registerRoutes(http, { authorize: async (ctx) =>
|
|
727
|
+
* (await ctx.auth.getUserIdentity())?.role === "admin" });
|
|
728
|
+
* export default http;
|
|
729
|
+
*
|
|
730
|
+
* It then lives at `https://<deployment>.convex.site/aibudget`.
|
|
731
|
+
*
|
|
732
|
+
* SECURITY: the endpoint is public on the internet. You MUST gate it — either
|
|
733
|
+
* pass `authorize` (recommended: check the caller is a deployment admin) or
|
|
734
|
+
* set the `AI_BUDGET_DASHBOARD_TOKEN` env var (a bearer token / `?token=`).
|
|
735
|
+
* With neither, every route returns 401.
|
|
736
|
+
*/
|
|
737
|
+
registerRoutes(
|
|
738
|
+
http: HttpRouter,
|
|
739
|
+
opts: {
|
|
740
|
+
/** Mount path (default "/aibudget"). */
|
|
741
|
+
path?: string;
|
|
742
|
+
/** Return true to allow the request. Runs on the HTML page and every API call. */
|
|
743
|
+
authorize?: (ctx: any, request: Request) => boolean | Promise<boolean>;
|
|
744
|
+
} = {}
|
|
745
|
+
) {
|
|
746
|
+
const prefix = (opts.path ?? "/aibudget").replace(/\/+$/, "");
|
|
747
|
+
const c = this.component.lib;
|
|
748
|
+
const authorize = opts.authorize;
|
|
749
|
+
|
|
750
|
+
const guard = async (
|
|
751
|
+
ctx: any,
|
|
752
|
+
request: Request
|
|
753
|
+
): Promise<{ ok: boolean; token: string }> => {
|
|
754
|
+
if (authorize) return { ok: await authorize(ctx, request), token: "" };
|
|
755
|
+
const token = (globalThis as any).process?.env?.AI_BUDGET_DASHBOARD_TOKEN;
|
|
756
|
+
if (!token) return { ok: false, token: "" };
|
|
757
|
+
const url = new URL(request.url);
|
|
758
|
+
const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
|
|
759
|
+
const provided = bearer || url.searchParams.get("token") || "";
|
|
760
|
+
return { ok: provided === token, token };
|
|
761
|
+
};
|
|
762
|
+
const json = (data: unknown, status = 200) =>
|
|
763
|
+
new Response(JSON.stringify(data ?? null), {
|
|
764
|
+
status,
|
|
765
|
+
headers: { "content-type": "application/json" },
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
const handle = async (ctx: any, request: Request): Promise<Response> => {
|
|
769
|
+
const url = new URL(request.url);
|
|
770
|
+
const sub = url.pathname.slice(prefix.length) || "/";
|
|
771
|
+
const { ok, token } = await guard(ctx, request);
|
|
772
|
+
if (!ok) {
|
|
773
|
+
return new Response(
|
|
774
|
+
"Unauthorized. Pass `authorize` to registerRoutes or set AI_BUDGET_DASHBOARD_TOKEN.",
|
|
775
|
+
{ status: 401 }
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (sub.startsWith("/api/")) {
|
|
780
|
+
const route = request.method + " " + sub.slice(4); // strip "/api"
|
|
781
|
+
const p: Record<string, string> = {};
|
|
782
|
+
url.searchParams.forEach((v, k) => {
|
|
783
|
+
p[k] = v;
|
|
784
|
+
});
|
|
785
|
+
const body =
|
|
786
|
+
request.method === "POST"
|
|
787
|
+
? await request.json().catch(() => ({}))
|
|
788
|
+
: {};
|
|
789
|
+
switch (route) {
|
|
790
|
+
case "GET /buckets":
|
|
791
|
+
return json(await ctx.runQuery(c.listBuckets, { dimension: p.dimension || undefined }));
|
|
792
|
+
case "GET /requests":
|
|
793
|
+
return json(await ctx.runQuery(c.listRequests, {
|
|
794
|
+
userId: p.userId || undefined,
|
|
795
|
+
dimension: p.dimension || undefined,
|
|
796
|
+
value: p.value || undefined,
|
|
797
|
+
limit: 100,
|
|
798
|
+
}));
|
|
799
|
+
case "GET /usage":
|
|
800
|
+
return json(await ctx.runQuery(c.usageHistory, {
|
|
801
|
+
dimension: p.dimension,
|
|
802
|
+
value: p.value,
|
|
803
|
+
period: p.period === "month" ? "month" : "day",
|
|
804
|
+
}));
|
|
805
|
+
case "GET /global":
|
|
806
|
+
return json(await ctx.runQuery(c.getGlobalStatus, {}));
|
|
807
|
+
case "GET /prices":
|
|
808
|
+
return json(await ctx.runQuery(c.listPrices, {}));
|
|
809
|
+
case "POST /setLimits":
|
|
810
|
+
return json(await ctx.runMutation(c.setBucketLimits, body));
|
|
811
|
+
case "POST /bump":
|
|
812
|
+
return json(await ctx.runMutation(c.bumpBucket, body));
|
|
813
|
+
case "POST /adjust":
|
|
814
|
+
return json(await ctx.runMutation(c.adjustBucket, body));
|
|
815
|
+
case "POST /delete":
|
|
816
|
+
return json(await ctx.runMutation(c.deleteBucket, body));
|
|
817
|
+
case "POST /global/setLimits":
|
|
818
|
+
return json(await ctx.runMutation(c.setGlobalLimits, body));
|
|
819
|
+
case "POST /global/setAlertDefaults":
|
|
820
|
+
return json(await ctx.runMutation(c.setAlertDefaults, body));
|
|
821
|
+
case "POST /global/setRetention":
|
|
822
|
+
return json(await ctx.runMutation(c.setRetention, body));
|
|
823
|
+
case "POST /setPrice":
|
|
824
|
+
return json(await ctx.runMutation(c.setPrice, body));
|
|
825
|
+
default:
|
|
826
|
+
return json({ error: "not found" }, 404);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const html = DASHBOARD_HTML.replace(/__API_BASE__/g, `${prefix}/api`).replace(
|
|
831
|
+
/__TOKEN__/g,
|
|
832
|
+
token
|
|
833
|
+
);
|
|
834
|
+
return new Response(html, {
|
|
835
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
836
|
+
});
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
const handler = httpActionGeneric(handle);
|
|
840
|
+
http.route({ path: prefix, method: "GET", handler });
|
|
841
|
+
http.route({ pathPrefix: `${prefix}/`, method: "GET", handler });
|
|
842
|
+
http.route({ pathPrefix: `${prefix}/`, method: "POST", handler });
|
|
843
|
+
}
|
|
642
844
|
}
|
|
643
845
|
|
|
644
846
|
/** @deprecated Renamed to `AIBudget`. */
|