@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.
@@ -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
 
@@ -49,22 +55,32 @@ export type AIGatewayApi = AIBudgetApi;
49
55
  */
50
56
  export type Tag = { dimension: string; value: string };
51
57
 
52
- /** Fired when a request is admitted over a *soft* limit. */
53
- export type SoftLimitInfo = {
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: string;
58
- warnings: string[];
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
- * Called when a soft limit is exceeded (the request is still allowed). Lets
64
- * you surface budget warnings even on the languageModel/Agent path, where
65
- * 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.
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: AI SDK v5 `cachedInputTokens`, OpenAI-compat
147
- // `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.
148
167
  cachedTokens: toTokenCount(
149
- usage?.cachedInputTokens ??
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 fireSoftLimit(info: SoftLimitInfo) {
202
- if (info.warnings.length === 0 || !this.onSoftLimit) return;
283
+ private async fireLimitReached(info: BudgetEventInfo) {
284
+ if (!this.onLimitReached) return;
203
285
  try {
204
- await this.onSoftLimit(info);
286
+ await this.onLimitReached(info);
205
287
  } catch {
206
- // never let a callback error break a request
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
- await this.fireSoftLimit({
252
- userId,
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 fireSoftLimit = this.fireSoftLimit.bind(this);
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 fireSoftLimit({
332
- userId,
333
- action: actionName,
334
- tags: opts.tags,
335
- requestId: started.requestId,
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
- list: (ctx: RunQueryCtx, args: { userId?: string; limit?: number } = {}) =>
449
- ctx.runQuery(c.lib.listRequests, args),
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", dailySpendLimitNanos });
467
- * ai.tag("customer").list(ctx);
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
- const c = this.component;
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
- /** Per-user budgets and controls sugar over the "user" dimension. */
505
- get users() {
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
- list: (ctx: RunQueryCtx) =>
509
- ctx.runQuery(c.lib.listBuckets, { dimension: "user" }),
510
- setLimits: (
511
- ctx: RunMutationCtx,
512
- args: {
513
- userId: string;
514
- requestsPerMinute?: number;
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: "user",
526
- value: userId,
598
+ dimension,
599
+ value: key(args),
527
600
  ...limits,
528
601
  });
529
602
  },
530
- /** One-time "approve another $X" bump (daily is today-only). */
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: "user",
537
- value: args.userId,
606
+ dimension,
607
+ value: key(args),
538
608
  dailyNanos: args.dailyNanos,
609
+ monthlyNanos: args.monthlyNanos,
539
610
  lifetimeNanos: args.lifetimeNanos,
540
611
  }),
541
- /** Delete a user and all their request rows. */
542
- delete: (ctx: RunMutationCtx, args: { userId: string }) =>
543
- ctx.runMutation(c.lib.deleteBucket, {
544
- dimension: "user",
545
- value: args.userId,
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
- const c = this.component;
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`. */