@convex-dev/ai-budget 0.0.2-alpha.0 → 0.0.2-alpha.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
+ import { httpActionGeneric, } from "convex/server";
1
2
  import { ConvexError } from "convex/values";
2
3
  import { generateText, wrapLanguageModel } from "ai";
3
4
  import { convexGateway } from "@convex-dev/ai-sdk-provider";
5
+ import { DASHBOARD_HTML } from "./dashboard";
4
6
  // The calling Convex action's name (e.g. "ai:sendMessage"), unless overridden.
5
7
  async function resolveActionName(ctx, explicit) {
6
8
  if (explicit !== undefined)
@@ -39,14 +41,35 @@ function extractUsage(usage) {
39
41
  return {
40
42
  promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens),
41
43
  completionTokens: toTokenCount(usage?.outputTokens ?? usage?.completionTokens),
42
- // cached prompt tokens: AI SDK v5 `cachedInputTokens`, OpenAI-compat
43
- // `prompt_tokens_details.cached_tokens` / `cached_tokens`.
44
- cachedTokens: toTokenCount(usage?.cachedInputTokens ??
44
+ // cached prompt tokens. The Convex gateway reports these at
45
+ // `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
46
+ // (`cachedInputTokens`) and raw OpenAI-compatible shapes.
47
+ cachedTokens: toTokenCount(usage?.inputTokenDetails?.cacheReadTokens ??
48
+ usage?.cachedInputTokens ??
45
49
  usage?.promptTokensDetails?.cachedTokens ??
46
50
  usage?.prompt_tokens_details?.cached_tokens ??
47
51
  usage?.cached_tokens),
48
52
  };
49
53
  }
54
+ // The AI Gateway reports the authoritative dollar cost of each request.
55
+ // @convex-dev/ai-sdk-provider surfaces it at
56
+ // `providerMetadata.convexGateway.cost` (USD); convert to nanodollars and pass
57
+ // it through as authoritative (finishRequest prefers it over the token-based
58
+ // estimate). No-op on older provider versions that don't surface it.
59
+ function extractGatewayCostNanos(result) {
60
+ const meta = result?.providerMetadata?.convexGateway;
61
+ const costUsd = meta?.cost;
62
+ if (typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd >= 0) {
63
+ return Math.round(costUsd * NANOS_PER_DOLLAR);
64
+ }
65
+ // Back-compat: honor an explicitly nano-denominated field if ever present.
66
+ const costNanos = meta?.costNanos ?? result?.usage?.costNanos;
67
+ if (typeof costNanos === "number" && Number.isFinite(costNanos) && costNanos >= 0) {
68
+ return Math.round(costNanos);
69
+ }
70
+ return undefined;
71
+ }
72
+ const NANOS_PER_DOLLAR = 1e9;
50
73
  // Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
51
74
  function simplifyPrompt(prompt) {
52
75
  if (!Array.isArray(prompt))
@@ -79,42 +102,89 @@ function extractText(result) {
79
102
  return "";
80
103
  }
81
104
  // ---------- client ----------
105
+ // Length-independent-branch string compare, so the dashboard token check
106
+ // doesn't leak the token via response timing. (Length itself is not secret.)
107
+ function timingSafeEqual(a, b) {
108
+ if (a.length !== b.length)
109
+ return false;
110
+ let r = 0;
111
+ for (let i = 0; i < a.length; i++)
112
+ r |= a.charCodeAt(i) ^ b.charCodeAt(i);
113
+ return r === 0;
114
+ }
82
115
  export class AIBudget {
83
116
  component;
84
117
  defaultModel;
85
118
  onSoftLimit;
119
+ onThreshold;
120
+ onLimitReached;
86
121
  constructor(component, options) {
87
122
  this.component = component;
88
123
  this.defaultModel = options?.defaultModel ?? "openai/gpt-4o-mini";
89
124
  this.onSoftLimit = options?.onSoftLimit;
125
+ this.onThreshold = options?.onThreshold;
126
+ this.onLimitReached = options?.onLimitReached;
127
+ }
128
+ // Fire the soft-limit + threshold callbacks from a startRequest result.
129
+ async fireBudgetEvents(base, warnings, notices) {
130
+ if (warnings.length > 0 && this.onSoftLimit) {
131
+ try {
132
+ await this.onSoftLimit({ ...base, messages: warnings, warnings });
133
+ }
134
+ catch {
135
+ /* never let a callback error break a request */
136
+ }
137
+ }
138
+ if (notices.length > 0 && this.onThreshold) {
139
+ try {
140
+ await this.onThreshold({ ...base, messages: notices });
141
+ }
142
+ catch {
143
+ /* swallow */
144
+ }
145
+ }
90
146
  }
91
- async fireSoftLimit(info) {
92
- if (info.warnings.length === 0 || !this.onSoftLimit)
147
+ async fireLimitReached(info) {
148
+ if (!this.onLimitReached)
93
149
  return;
94
150
  try {
95
- await this.onSoftLimit(info);
151
+ await this.onLimitReached(info);
96
152
  }
97
153
  catch {
98
- // never let a callback error break a request
154
+ /* swallow */
99
155
  }
100
156
  }
101
157
  /**
102
- * One-shot chat through the AI Gateway with tracking + limits.
103
- * Call from an action. `userId` defaults to the authenticated caller.
158
+ * Meter ANY LLM call gateway, a provider SDK, a raw fetch — with the same
159
+ * budgets, audit log, and cost tracking. Reserves before your `run` (throwing
160
+ * a ConvexError over a hard cap), runs it, then records the actual usage/cost.
161
+ * This is the provider-agnostic core; `chat` is sugar over it for the gateway.
162
+ *
163
+ * `run` returns what happened. Pass a raw provider `usage` object (auto-
164
+ * normalized) OR explicit `promptTokens`/`completionTokens`/`cachedTokens`,
165
+ * plus optional `serverToolUses` (e.g. `{ web_search: 3 }`, priced on top of
166
+ * tokens) and an authoritative `costNanos` (used verbatim if present).
104
167
  */
105
- async chat(ctx, args = {}) {
106
- const model = args.model ?? this.defaultModel;
107
- const userId = await resolveUserId(ctx, args.userId);
108
- const actionName = await resolveActionName(ctx, args.action);
109
- const messages = args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
168
+ async meter(ctx, opts, run) {
169
+ const userId = await resolveUserId(ctx, opts.userId);
170
+ const actionName = await resolveActionName(ctx, opts.action);
110
171
  const started = await ctx.runMutation(this.component.lib.startRequest, {
111
172
  userId,
112
173
  actionName,
113
- model,
114
- messages,
115
- rerunOf: args.rerunOf,
174
+ tags: opts.tags,
175
+ model: opts.model,
176
+ messages: opts.messages,
177
+ rerunOf: opts.rerunOf,
116
178
  });
117
179
  if (!started.allowed) {
180
+ await this.fireLimitReached({
181
+ userId,
182
+ action: actionName,
183
+ tags: opts.tags,
184
+ messages: [started.reason],
185
+ code: started.code,
186
+ reason: started.reason,
187
+ });
118
188
  throw new ConvexError({
119
189
  kind: "AIBudgetLimit",
120
190
  code: started.code,
@@ -122,30 +192,30 @@ export class AIBudget {
122
192
  });
123
193
  }
124
194
  const requestId = started.requestId;
125
- const warnings = started.warnings;
126
- await this.fireSoftLimit({ userId, action: actionName, requestId, warnings });
195
+ const { warnings, notices } = started;
196
+ await this.fireBudgetEvents({ userId, action: actionName, tags: opts.tags, requestId }, warnings, notices);
127
197
  const start = Date.now();
128
198
  try {
129
- // The full chain (incl. system) is stored on the request for audit/replay,
130
- // but the AI SDK wants system prompts in the `system` option, not messages.
131
- const system = messages
132
- .filter((m) => m.role === "system")
133
- .map((m) => m.content)
134
- .join("\n\n") || undefined;
135
- const convo = messages.filter((m) => m.role !== "system");
136
- const result = await generateText({
137
- model: convexGateway(model),
138
- ...(system ? { system } : {}),
139
- messages: convo,
140
- });
141
- const usage = extractUsage(result.usage);
199
+ const out = await run();
200
+ // Explicit token fields win; otherwise normalize a raw provider usage.
201
+ const usage = out.promptTokens !== undefined ||
202
+ out.completionTokens !== undefined ||
203
+ out.cachedTokens !== undefined
204
+ ? {
205
+ promptTokens: out.promptTokens ?? 0,
206
+ completionTokens: out.completionTokens ?? 0,
207
+ cachedTokens: out.cachedTokens ?? 0,
208
+ }
209
+ : extractUsage(out.usage);
142
210
  const { costNanos } = await ctx.runMutation(this.component.lib.finishRequest, {
143
211
  requestId,
144
- responseText: result.text,
212
+ responseText: out.text,
145
213
  ...usage,
214
+ serverToolUses: out.serverToolUses,
215
+ costNanos: out.costNanos,
146
216
  latencyMs: Date.now() - start,
147
217
  });
148
- return { text: result.text, requestId, costNanos, warnings, ...usage };
218
+ return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
149
219
  }
150
220
  catch (e) {
151
221
  await ctx.runMutation(this.component.lib.finishRequest, {
@@ -156,6 +226,40 @@ export class AIBudget {
156
226
  throw e;
157
227
  }
158
228
  }
229
+ /**
230
+ * One-shot chat through the AI Gateway with tracking + limits — sugar over
231
+ * `meter`. Call from an action. `userId` defaults to the authenticated caller.
232
+ */
233
+ async chat(ctx, args = {}) {
234
+ const model = args.model ?? this.defaultModel;
235
+ const messages = args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
236
+ return this.meter(ctx, {
237
+ model,
238
+ messages,
239
+ userId: args.userId,
240
+ action: args.action,
241
+ tags: args.tags,
242
+ rerunOf: args.rerunOf,
243
+ }, async () => {
244
+ // The full chain (incl. system) is stored for audit/replay, but the AI
245
+ // SDK wants system prompts in the `system` option, not messages.
246
+ const system = messages
247
+ .filter((m) => m.role === "system")
248
+ .map((m) => m.content)
249
+ .join("\n\n") || undefined;
250
+ const convo = messages.filter((m) => m.role !== "system");
251
+ const result = await generateText({
252
+ model: convexGateway(model),
253
+ ...(system ? { system } : {}),
254
+ messages: convo,
255
+ });
256
+ return {
257
+ text: result.text,
258
+ usage: result.usage,
259
+ costNanos: extractGatewayCostNanos(result),
260
+ };
261
+ });
262
+ }
159
263
  /**
160
264
  * An AI SDK LanguageModel that enforces limits and records usage/cost for
161
265
  * `userId` on every call. Drop it into `generateText`, `streamText`, or the
@@ -165,29 +269,33 @@ export class AIBudget {
165
269
  languageModel(ctx, opts = {}) {
166
270
  const modelId = opts.model ?? this.defaultModel;
167
271
  const component = this.component;
168
- const fireSoftLimit = this.fireSoftLimit.bind(this);
272
+ const fireBudgetEvents = this.fireBudgetEvents.bind(this);
273
+ const fireLimitReached = this.fireLimitReached.bind(this);
169
274
  const begin = async (params) => {
170
275
  const userId = await resolveUserId(ctx, opts.userId);
171
276
  const actionName = await resolveActionName(ctx, opts.action);
277
+ const base = { userId, action: actionName, tags: opts.tags };
172
278
  const started = await ctx.runMutation(component.lib.startRequest, {
173
279
  userId,
174
280
  actionName,
281
+ tags: opts.tags,
175
282
  model: modelId,
176
283
  messages: simplifyPrompt(params.prompt),
177
284
  });
178
285
  if (!started.allowed) {
286
+ await fireLimitReached({
287
+ ...base,
288
+ messages: [started.reason],
289
+ code: started.code,
290
+ reason: started.reason,
291
+ });
179
292
  throw new ConvexError({
180
293
  kind: "AIBudgetLimit",
181
294
  code: started.code,
182
295
  reason: started.reason,
183
296
  });
184
297
  }
185
- await fireSoftLimit({
186
- userId,
187
- action: actionName,
188
- requestId: started.requestId,
189
- warnings: started.warnings,
190
- });
298
+ await fireBudgetEvents({ ...base, requestId: started.requestId }, started.warnings, started.notices);
191
299
  return started.requestId;
192
300
  };
193
301
  const finish = async (requestId, fields) => ctx.runMutation(component.lib.finishRequest, { requestId, ...fields });
@@ -202,6 +310,7 @@ export class AIBudget {
202
310
  await finish(requestId, {
203
311
  responseText: extractText(result),
204
312
  ...extractUsage(result.usage),
313
+ costNanos: extractGatewayCostNanos(result),
205
314
  latencyMs: Date.now() - start,
206
315
  });
207
316
  return result;
@@ -219,6 +328,7 @@ export class AIBudget {
219
328
  const start = Date.now();
220
329
  let text = "";
221
330
  let usage = undefined;
331
+ let providerMetadata = undefined;
222
332
  try {
223
333
  const result = await doStream();
224
334
  // finishRequest is idempotent (terminal-guarded server-side), so
@@ -235,6 +345,7 @@ export class AIBudget {
235
345
  responseText: text,
236
346
  error,
237
347
  ...extractUsage(usage),
348
+ costNanos: extractGatewayCostNanos({ providerMetadata }),
238
349
  latencyMs: Date.now() - start,
239
350
  });
240
351
  };
@@ -243,8 +354,10 @@ export class AIBudget {
243
354
  if (chunk?.type === "text-delta") {
244
355
  text += chunk.delta ?? chunk.textDelta ?? "";
245
356
  }
246
- if (chunk?.type === "finish")
357
+ if (chunk?.type === "finish") {
247
358
  usage = chunk.usage;
359
+ providerMetadata = chunk.providerMetadata ?? providerMetadata;
360
+ }
248
361
  if (chunk?.type === "error")
249
362
  void settle(String(chunk.error));
250
363
  controller.enqueue(chunk);
@@ -278,6 +391,7 @@ export class AIBudget {
278
391
  messages: args.messages ?? original.messages,
279
392
  rerunOf: args.requestId,
280
393
  action: original.actionName,
394
+ tags: original.tags,
281
395
  });
282
396
  }
283
397
  // ---------- namespaced admin API ----------
@@ -285,35 +399,88 @@ export class AIBudget {
285
399
  get requests() {
286
400
  const c = this.component;
287
401
  return {
402
+ /** Filter by userId, or by any {dimension, value} (incl. custom tags). */
288
403
  list: (ctx, args = {}) => ctx.runQuery(c.lib.listRequests, args),
404
+ /** One request, including its stored prompt and response. */
405
+ get: (ctx, args) => ctx.runQuery(c.lib.getRequest, { requestId: args.requestId }),
289
406
  /** Ancestors up to the original, plus direct re-runs. */
290
407
  lineage: (ctx, args) => ctx.runQuery(c.lib.lineage, { requestId: args.requestId }),
291
408
  /** Replay a stored request, optionally with edited messages/model. */
292
409
  rerun: (ctx, args) => this.rerunImpl(ctx, args),
293
410
  };
294
411
  }
295
- /** Per-user budgets and controls. */
296
- get users() {
412
+ /**
413
+ * Budgets and controls for an arbitrary attribution dimension — the
414
+ * generalization of `users`/`actions`. Give it any dimension name (team,
415
+ * project, tenant, customer, env, feature, …) and set caps per value:
416
+ *
417
+ * ai.tag("customer").setLimits(ctx, { value: "acme", monthlySpendLimitNanos });
418
+ * ai.tag("customer").history(ctx, { value: "acme", period: "day" });
419
+ *
420
+ * Attribute a call to it by passing `tags` to `chat`/`languageModel`.
421
+ */
422
+ tag(dimension) {
423
+ return this.dimensionApi(dimension, (a) => a.value);
424
+ }
425
+ // Shared implementation behind tag()/users/actions. `key` maps the namespace's
426
+ // id field (value/userId/name) to the bucket value.
427
+ dimensionApi(dimension, key) {
297
428
  const c = this.component;
298
429
  return {
299
- list: (ctx) => ctx.runQuery(c.lib.listUsers, {}),
300
- setLimits: (ctx, args) => ctx.runMutation(c.lib.setLimits, args),
301
- /** One-time "approve another $X" bump (daily is today-only). */
302
- bump: (ctx, args) => ctx.runMutation(c.lib.bumpUser, args),
303
- /** Delete a user and all their request rows. */
304
- delete: (ctx, args) => ctx.runMutation(c.lib.deleteUser, args),
430
+ /** All buckets in this dimension. */
431
+ list: (ctx) => ctx.runQuery(c.lib.listBuckets, { dimension }),
432
+ /** One bucket's limits + spend (null if it has none yet). */
433
+ get: (ctx, args) => ctx.runQuery(c.lib.getBucket, { dimension, value: key(args) }),
434
+ setLimits: (ctx, args) => {
435
+ const { value, userId, name, ...limits } = args;
436
+ return ctx.runMutation(c.lib.setBucketLimits, {
437
+ dimension,
438
+ value: key(args),
439
+ ...limits,
440
+ });
441
+ },
442
+ /** One-time "approve another $X" bump (daily/monthly reset with the window). */
443
+ bump: (ctx, args) => ctx.runMutation(c.lib.bumpBucket, {
444
+ dimension,
445
+ value: key(args),
446
+ dailyNanos: args.dailyNanos,
447
+ monthlyNanos: args.monthlyNanos,
448
+ lifetimeNanos: args.lifetimeNanos,
449
+ }),
450
+ /** Manually credit (negative) or debit (positive) this bucket. */
451
+ adjust: (ctx, args) => ctx.runMutation(c.lib.adjustBucket, {
452
+ dimension,
453
+ value: key(args),
454
+ deltaNanos: args.deltaNanos,
455
+ tokens: args.tokens,
456
+ reason: args.reason,
457
+ }),
458
+ /** Durable spend history for this bucket (per day or per month). */
459
+ history: (ctx, args) => ctx.runQuery(c.lib.usageHistory, {
460
+ dimension,
461
+ value: key(args),
462
+ period: args.period ?? "day",
463
+ limit: args.limit,
464
+ }),
465
+ /** Manual-adjustment audit log for this bucket. */
466
+ adjustments: (ctx, args) => ctx.runQuery(c.lib.listAdjustments, {
467
+ dimension,
468
+ value: key(args),
469
+ limit: args.limit,
470
+ }),
471
+ /** Delete the bucket (for "user", also its request rows). */
472
+ delete: (ctx, args) => ctx.runMutation(c.lib.deleteBucket, { dimension, value: key(args) }),
305
473
  };
306
474
  }
307
- /** Per-action (per-feature) budgets. */
475
+ /** Per-user budgets and controls — sugar over the "user" dimension. */
476
+ get users() {
477
+ return this.dimensionApi("user", (a) => a.userId);
478
+ }
479
+ /** Per-action (per-feature) budgets — sugar over the "action" dimension. */
308
480
  get actions() {
309
- const c = this.component;
310
- return {
311
- list: (ctx) => ctx.runQuery(c.lib.listActions, {}),
312
- setLimits: (ctx, args) => ctx.runMutation(c.lib.setActionLimits, args),
313
- bump: (ctx, args) => ctx.runMutation(c.lib.bumpAction, args),
314
- };
481
+ return this.dimensionApi("action", (a) => a.name);
315
482
  }
316
- /** The deployment-wide budget and retention config. */
483
+ /** The deployment-wide budget, alerts, and retention config. */
317
484
  get global() {
318
485
  const c = this.component;
319
486
  return {
@@ -322,6 +489,8 @@ export class AIBudget {
322
489
  /** A killswitch spend cap across all users/actions (enforced approximately). */
323
490
  setLimits: (ctx, args) => ctx.runMutation(c.lib.setGlobalLimits, args),
324
491
  bump: (ctx, args) => ctx.runMutation(c.lib.bumpGlobal, args),
492
+ /** Default approaching-limit alert threshold (fraction of a cap, e.g. 0.8). */
493
+ setAlertDefaults: (ctx, args) => ctx.runMutation(c.lib.setAlertDefaults, args),
325
494
  /** Request-row retention window in ms (default 1h; 0 disables). */
326
495
  setRetention: (ctx, args) => ctx.runMutation(c.lib.setRetention, args),
327
496
  };
@@ -335,13 +504,128 @@ export class AIBudget {
335
504
  setPolicy: (ctx, args) => ctx.runMutation(c.lib.setModelPolicy, args),
336
505
  };
337
506
  }
338
- /** Per-model prices (cents per million tokens). */
507
+ /** Per-model prices (nanodollars per million tokens) + server-tool fees. */
339
508
  get prices() {
340
509
  const c = this.component;
341
510
  return {
342
511
  list: (ctx) => ctx.runQuery(c.lib.listPrices, {}),
343
512
  set: (ctx, args) => ctx.runMutation(c.lib.setPrice, args),
513
+ /** Per-call fees for provider server tools (web search, etc.). */
514
+ listServerTools: (ctx) => ctx.runQuery(c.lib.listServerToolPrices, {}),
515
+ /** Set a server-tool's per-call price, e.g. { tool: "web_search", nanosPerCall }. */
516
+ setServerTool: (ctx, args) => ctx.runMutation(c.lib.setServerToolPrice, args),
517
+ };
518
+ }
519
+ /**
520
+ * Mount the built-in admin dashboard on your app's HTTP router with one call.
521
+ * Serves a self-contained HTML dashboard (buckets, requests, usage history,
522
+ * settings) plus a small JSON API, all backed by the component — no extra
523
+ * queries to write.
524
+ *
525
+ * // convex/http.ts
526
+ * import { httpRouter } from "convex/server";
527
+ * const http = httpRouter();
528
+ * ai.registerRoutes(http, { authorize: async (ctx) =>
529
+ * (await ctx.auth.getUserIdentity())?.role === "admin" });
530
+ * export default http;
531
+ *
532
+ * It then lives at `https://<deployment>.convex.site/aibudget`.
533
+ *
534
+ * SECURITY: the endpoint is public on the internet. You MUST gate it — either
535
+ * pass `authorize` (recommended: check the caller is a deployment admin) or
536
+ * set the `AI_BUDGET_DASHBOARD_TOKEN` env var (a bearer token / `?token=`).
537
+ * With neither, every route returns 401.
538
+ */
539
+ registerRoutes(http, opts = {}) {
540
+ const prefix = (opts.path ?? "/aibudget").replace(/\/+$/, "");
541
+ const c = this.component.lib;
542
+ const authorize = opts.authorize;
543
+ const guard = async (ctx, request) => {
544
+ if (authorize)
545
+ return { ok: await authorize(ctx, request), token: "" };
546
+ const token = globalThis.process?.env?.AI_BUDGET_DASHBOARD_TOKEN;
547
+ if (!token)
548
+ return { ok: false, token: "" };
549
+ const url = new URL(request.url);
550
+ const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
551
+ // `?token=` is accepted only for the initial page navigation (a browser
552
+ // GET can't set headers); the page strips it from the URL on load and the
553
+ // JSON API is called with the bearer header. Compared in constant time.
554
+ const provided = bearer || url.searchParams.get("token") || "";
555
+ return { ok: timingSafeEqual(provided, token), token };
556
+ };
557
+ const json = (data, status = 200) => new Response(JSON.stringify(data ?? null), {
558
+ status,
559
+ headers: { "content-type": "application/json" },
560
+ });
561
+ const handle = async (ctx, request) => {
562
+ const url = new URL(request.url);
563
+ const sub = url.pathname.slice(prefix.length) || "/";
564
+ const { ok, token } = await guard(ctx, request);
565
+ if (!ok) {
566
+ return new Response("Unauthorized. Pass `authorize` to registerRoutes or set AI_BUDGET_DASHBOARD_TOKEN.", { status: 401 });
567
+ }
568
+ if (sub.startsWith("/api/")) {
569
+ const route = request.method + " " + sub.slice(4); // strip "/api"
570
+ const p = {};
571
+ url.searchParams.forEach((v, k) => {
572
+ p[k] = v;
573
+ });
574
+ const body = request.method === "POST"
575
+ ? await request.json().catch(() => ({}))
576
+ : {};
577
+ switch (route) {
578
+ case "GET /buckets":
579
+ return json(await ctx.runQuery(c.listBuckets, { dimension: p.dimension || undefined }));
580
+ case "GET /requests":
581
+ return json(await ctx.runQuery(c.listRequests, {
582
+ userId: p.userId || undefined,
583
+ dimension: p.dimension || undefined,
584
+ value: p.value || undefined,
585
+ limit: 100,
586
+ }));
587
+ case "GET /usage":
588
+ return json(await ctx.runQuery(c.usageHistory, {
589
+ dimension: p.dimension,
590
+ value: p.value,
591
+ period: p.period === "month" ? "month" : "day",
592
+ }));
593
+ case "GET /global":
594
+ return json(await ctx.runQuery(c.getGlobalStatus, {}));
595
+ case "GET /prices":
596
+ return json(await ctx.runQuery(c.listPrices, {}));
597
+ case "POST /setLimits":
598
+ return json(await ctx.runMutation(c.setBucketLimits, body));
599
+ case "POST /bump":
600
+ return json(await ctx.runMutation(c.bumpBucket, body));
601
+ case "POST /adjust":
602
+ return json(await ctx.runMutation(c.adjustBucket, body));
603
+ case "POST /delete":
604
+ return json(await ctx.runMutation(c.deleteBucket, body));
605
+ case "POST /global/setLimits":
606
+ return json(await ctx.runMutation(c.setGlobalLimits, body));
607
+ case "POST /global/setAlertDefaults":
608
+ return json(await ctx.runMutation(c.setAlertDefaults, body));
609
+ case "POST /global/setRetention":
610
+ return json(await ctx.runMutation(c.setRetention, body));
611
+ case "POST /setPrice":
612
+ return json(await ctx.runMutation(c.setPrice, body));
613
+ default:
614
+ return json({ error: "not found" }, 404);
615
+ }
616
+ }
617
+ // Inject as JSON literals (function replacers so `$` in the value isn't
618
+ // treated as a replacement pattern). This keeps a token/prefix containing
619
+ // quotes, backslashes, or `</script>` from breaking out of the JS string.
620
+ const html = DASHBOARD_HTML.replace(/__API_BASE__/g, () => JSON.stringify(`${prefix}/api`)).replace(/__TOKEN__/g, () => JSON.stringify(token));
621
+ return new Response(html, {
622
+ headers: { "content-type": "text/html; charset=utf-8" },
623
+ });
344
624
  };
625
+ const handler = httpActionGeneric(handle);
626
+ http.route({ path: prefix, method: "GET", handler });
627
+ http.route({ pathPrefix: `${prefix}/`, method: "GET", handler });
628
+ http.route({ pathPrefix: `${prefix}/`, method: "POST", handler });
345
629
  }
346
630
  }
347
631
  /** @deprecated Renamed to `AIBudget`. */