@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.
@@ -1,8 +1,14 @@
1
- import type { Expand, FunctionReference } from "convex/server";
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
- /** Fired when a request is admitted over a *soft* limit. */
45
- export type SoftLimitInfo = {
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
- requestId: string;
49
- warnings: string[];
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
- * Called when a soft limit is exceeded (the request is still allowed). Lets
55
- * you surface budget warnings even on the languageModel/Agent path, where
56
- * they can't be returned. Errors thrown here are swallowed.
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: AI SDK v5 `cachedInputTokens`, OpenAI-compat
138
- // `prompt_tokens_details.cached_tokens` / `cached_tokens`.
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?.cachedInputTokens ??
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
- private async fireSoftLimit(info: SoftLimitInfo) {
193
- if (info.warnings.length === 0 || !this.onSoftLimit) return;
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.onSoftLimit(info);
302
+ await this.onLimitReached(info);
196
303
  } catch {
197
- // never let a callback error break a request
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
- await this.fireSoftLimit({ userId, action: actionName, requestId, warnings });
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: { userId?: string; model?: string; action?: string } = {}
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 fireSoftLimit = this.fireSoftLimit.bind(this);
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 fireSoftLimit({
307
- userId,
308
- action: actionName,
309
- requestId: started.requestId,
310
- warnings: started.warnings,
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") usage = chunk.usage;
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
- list: (ctx: RunQueryCtx, args: { userId?: string; limit?: number } = {}) =>
422
- ctx.runQuery(c.lib.listRequests, args),
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
- /** Per-user budgets and controls. */
435
- get users() {
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
- list: (ctx: RunQueryCtx) => ctx.runQuery(c.lib.listUsers, {}),
439
- setLimits: (
440
- ctx: RunMutationCtx,
441
- args: {
442
- userId: string;
443
- requestsPerMinute?: number;
444
- dailySpendLimitNanos?: number;
445
- lifetimeSpendLimitNanos?: number;
446
- dailyTokenLimit?: number;
447
- lifetimeTokenLimit?: number;
448
- enforcement?: "hard" | "soft";
449
- blocked?: boolean;
450
- }
451
- ) => ctx.runMutation(c.lib.setLimits, args),
452
- /** One-time "approve another $X" bump (daily is today-only). */
453
- bump: (
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: { userId: string; dailyNanos?: number; lifetimeNanos?: number }
456
- ) => ctx.runMutation(c.lib.bumpUser, args),
457
- /** Delete a user and all their request rows. */
458
- delete: (ctx: RunMutationCtx, args: { userId: string }) =>
459
- ctx.runMutation(c.lib.deleteUser, args),
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-action (per-feature) budgets. */
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
- const c = this.component;
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`. */