@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,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,56 +228,133 @@ 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
 
201
308
  /**
202
- * One-shot chat through the AI Gateway with tracking + limits.
203
- * Call from an action. `userId` defaults to the authenticated caller.
309
+ * Meter ANY LLM call gateway, a provider SDK, a raw fetch — with the same
310
+ * budgets, audit log, and cost tracking. Reserves before your `run` (throwing
311
+ * a ConvexError over a hard cap), runs it, then records the actual usage/cost.
312
+ * This is the provider-agnostic core; `chat` is sugar over it for the gateway.
313
+ *
314
+ * `run` returns what happened. Pass a raw provider `usage` object (auto-
315
+ * normalized) OR explicit `promptTokens`/`completionTokens`/`cachedTokens`,
316
+ * plus optional `serverToolUses` (e.g. `{ web_search: 3 }`, priced on top of
317
+ * tokens) and an authoritative `costNanos` (used verbatim if present).
204
318
  */
205
- async chat(
319
+ async meter(
206
320
  ctx: RunMutationCtx,
207
- args: {
208
- /** Whom to bill. Defaults to the authenticated user (ctx.auth). */
321
+ opts: {
322
+ model: string;
323
+ messages: Message[];
209
324
  userId?: string;
210
- prompt?: string;
211
- messages?: Message[];
212
- model?: string;
213
- rerunOf?: string;
214
- /** Attribute spend to this action name. Defaults to the calling Convex action. */
215
325
  action?: string;
216
- } = {}
326
+ tags?: Tag[];
327
+ rerunOf?: string;
328
+ },
329
+ run: () => Promise<{
330
+ text?: string;
331
+ usage?: any;
332
+ promptTokens?: number;
333
+ completionTokens?: number;
334
+ cachedTokens?: number;
335
+ serverToolUses?: Record<string, number>;
336
+ costNanos?: number;
337
+ }>
217
338
  ): Promise<ChatResult> {
218
- const model = args.model ?? this.defaultModel;
219
- const userId = await resolveUserId(ctx, args.userId);
220
- const actionName = await resolveActionName(ctx, args.action);
221
- const messages: Message[] =
222
- args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
339
+ const userId = await resolveUserId(ctx, opts.userId);
340
+ const actionName = await resolveActionName(ctx, opts.action);
223
341
  const started = await ctx.runMutation(this.component.lib.startRequest, {
224
342
  userId,
225
343
  actionName,
226
- model,
227
- messages,
228
- rerunOf: args.rerunOf as any,
344
+ tags: opts.tags,
345
+ model: opts.model,
346
+ messages: opts.messages,
347
+ rerunOf: opts.rerunOf as any,
229
348
  });
230
349
  if (!started.allowed) {
350
+ await this.fireLimitReached({
351
+ userId,
352
+ action: actionName,
353
+ tags: opts.tags,
354
+ messages: [started.reason],
355
+ code: started.code,
356
+ reason: started.reason,
357
+ });
231
358
  throw new ConvexError({
232
359
  kind: "AIBudgetLimit",
233
360
  code: started.code,
@@ -235,34 +362,38 @@ export class AIBudget {
235
362
  });
236
363
  }
237
364
  const requestId = started.requestId;
238
- const warnings = started.warnings;
239
- await this.fireSoftLimit({ userId, action: actionName, requestId, warnings });
365
+ const { warnings, notices } = started;
366
+ await this.fireBudgetEvents(
367
+ { userId, action: actionName, tags: opts.tags, requestId },
368
+ warnings,
369
+ notices
370
+ );
240
371
  const start = Date.now();
241
372
  try {
242
- // The full chain (incl. system) is stored on the request for audit/replay,
243
- // but the AI SDK wants system prompts in the `system` option, not messages.
244
- const system =
245
- messages
246
- .filter((m) => m.role === "system")
247
- .map((m) => m.content)
248
- .join("\n\n") || undefined;
249
- const convo = messages.filter((m) => m.role !== "system");
250
- const result = await generateText({
251
- model: convexGateway(model),
252
- ...(system ? { system } : {}),
253
- messages: convo as any,
254
- });
255
- const usage = extractUsage(result.usage);
373
+ const out = await run();
374
+ // Explicit token fields win; otherwise normalize a raw provider usage.
375
+ const usage =
376
+ out.promptTokens !== undefined ||
377
+ out.completionTokens !== undefined ||
378
+ out.cachedTokens !== undefined
379
+ ? {
380
+ promptTokens: out.promptTokens ?? 0,
381
+ completionTokens: out.completionTokens ?? 0,
382
+ cachedTokens: out.cachedTokens ?? 0,
383
+ }
384
+ : extractUsage(out.usage);
256
385
  const { costNanos } = await ctx.runMutation(
257
386
  this.component.lib.finishRequest,
258
387
  {
259
388
  requestId,
260
- responseText: result.text,
389
+ responseText: out.text,
261
390
  ...usage,
391
+ serverToolUses: out.serverToolUses,
392
+ costNanos: out.costNanos,
262
393
  latencyMs: Date.now() - start,
263
394
  }
264
395
  );
265
- return { text: result.text, requestId, costNanos, warnings, ...usage };
396
+ return { text: out.text ?? "", requestId, costNanos, warnings, notices, ...usage };
266
397
  } catch (e) {
267
398
  await ctx.runMutation(this.component.lib.finishRequest, {
268
399
  requestId,
@@ -273,6 +404,61 @@ export class AIBudget {
273
404
  }
274
405
  }
275
406
 
407
+ /**
408
+ * One-shot chat through the AI Gateway with tracking + limits — sugar over
409
+ * `meter`. Call from an action. `userId` defaults to the authenticated caller.
410
+ */
411
+ async chat(
412
+ ctx: RunMutationCtx,
413
+ args: {
414
+ /** Whom to bill. Defaults to the authenticated user (ctx.auth). */
415
+ userId?: string;
416
+ prompt?: string;
417
+ messages?: Message[];
418
+ model?: string;
419
+ rerunOf?: string;
420
+ /** Attribute spend to this action name. Defaults to the calling Convex action. */
421
+ action?: string;
422
+ /** Extra attribution dimensions to bill/limit (team, customer, env, …). */
423
+ tags?: Tag[];
424
+ } = {}
425
+ ): Promise<ChatResult> {
426
+ const model = args.model ?? this.defaultModel;
427
+ const messages: Message[] =
428
+ args.messages ?? [{ role: "user", content: args.prompt ?? "" }];
429
+ return this.meter(
430
+ ctx,
431
+ {
432
+ model,
433
+ messages,
434
+ userId: args.userId,
435
+ action: args.action,
436
+ tags: args.tags,
437
+ rerunOf: args.rerunOf,
438
+ },
439
+ async () => {
440
+ // The full chain (incl. system) is stored for audit/replay, but the AI
441
+ // SDK wants system prompts in the `system` option, not messages.
442
+ const system =
443
+ messages
444
+ .filter((m) => m.role === "system")
445
+ .map((m) => m.content)
446
+ .join("\n\n") || undefined;
447
+ const convo = messages.filter((m) => m.role !== "system");
448
+ const result = await generateText({
449
+ model: convexGateway(model),
450
+ ...(system ? { system } : {}),
451
+ messages: convo as any,
452
+ });
453
+ return {
454
+ text: result.text,
455
+ usage: result.usage,
456
+ costNanos: extractGatewayCostNanos(result),
457
+ };
458
+ }
459
+ );
460
+ }
461
+
276
462
  /**
277
463
  * An AI SDK LanguageModel that enforces limits and records usage/cost for
278
464
  * `userId` on every call. Drop it into `generateText`, `streamText`, or the
@@ -281,34 +467,48 @@ export class AIBudget {
281
467
  */
282
468
  languageModel(
283
469
  ctx: RunMutationCtx,
284
- opts: { userId?: string; model?: string; action?: string } = {}
470
+ opts: {
471
+ userId?: string;
472
+ model?: string;
473
+ action?: string;
474
+ /** Extra attribution dimensions to bill/limit (team, customer, env, …). */
475
+ tags?: Tag[];
476
+ } = {}
285
477
  ): LanguageModel {
286
478
  const modelId = opts.model ?? this.defaultModel;
287
479
  const component = this.component;
288
- const fireSoftLimit = this.fireSoftLimit.bind(this);
480
+ const fireBudgetEvents = this.fireBudgetEvents.bind(this);
481
+ const fireLimitReached = this.fireLimitReached.bind(this);
289
482
 
290
483
  const begin = async (params: any) => {
291
484
  const userId = await resolveUserId(ctx, opts.userId);
292
485
  const actionName = await resolveActionName(ctx, opts.action);
486
+ const base = { userId, action: actionName, tags: opts.tags };
293
487
  const started = await ctx.runMutation(component.lib.startRequest, {
294
488
  userId,
295
489
  actionName,
490
+ tags: opts.tags,
296
491
  model: modelId,
297
492
  messages: simplifyPrompt(params.prompt),
298
493
  });
299
494
  if (!started.allowed) {
495
+ await fireLimitReached({
496
+ ...base,
497
+ messages: [started.reason],
498
+ code: started.code,
499
+ reason: started.reason,
500
+ });
300
501
  throw new ConvexError({
301
502
  kind: "AIBudgetLimit",
302
503
  code: started.code,
303
504
  reason: started.reason,
304
505
  });
305
506
  }
306
- await fireSoftLimit({
307
- userId,
308
- action: actionName,
309
- requestId: started.requestId,
310
- warnings: started.warnings,
311
- });
507
+ await fireBudgetEvents(
508
+ { ...base, requestId: started.requestId },
509
+ started.warnings,
510
+ started.notices
511
+ );
312
512
  return started.requestId;
313
513
  };
314
514
  const finish = async (
@@ -318,6 +518,8 @@ export class AIBudget {
318
518
  error?: string;
319
519
  promptTokens?: number;
320
520
  completionTokens?: number;
521
+ cachedTokens?: number;
522
+ costNanos?: number;
321
523
  latencyMs?: number;
322
524
  }
323
525
  ) => ctx.runMutation(component.lib.finishRequest, { requestId, ...fields });
@@ -333,6 +535,7 @@ export class AIBudget {
333
535
  await finish(requestId, {
334
536
  responseText: extractText(result),
335
537
  ...extractUsage(result.usage),
538
+ costNanos: extractGatewayCostNanos(result),
336
539
  latencyMs: Date.now() - start,
337
540
  });
338
541
  return result;
@@ -349,6 +552,7 @@ export class AIBudget {
349
552
  const start = Date.now();
350
553
  let text = "";
351
554
  let usage: any = undefined;
555
+ let providerMetadata: any = undefined;
352
556
  try {
353
557
  const result = await doStream();
354
558
  // finishRequest is idempotent (terminal-guarded server-side), so
@@ -364,6 +568,7 @@ export class AIBudget {
364
568
  responseText: text,
365
569
  error,
366
570
  ...extractUsage(usage),
571
+ costNanos: extractGatewayCostNanos({ providerMetadata }),
367
572
  latencyMs: Date.now() - start,
368
573
  });
369
574
  };
@@ -373,7 +578,10 @@ export class AIBudget {
373
578
  if (chunk?.type === "text-delta") {
374
579
  text += chunk.delta ?? chunk.textDelta ?? "";
375
580
  }
376
- if (chunk?.type === "finish") usage = chunk.usage;
581
+ if (chunk?.type === "finish") {
582
+ usage = chunk.usage;
583
+ providerMetadata = chunk.providerMetadata ?? providerMetadata;
584
+ }
377
585
  if (chunk?.type === "error") void settle(String(chunk.error));
378
586
  controller.enqueue(chunk);
379
587
  },
@@ -409,6 +617,7 @@ export class AIBudget {
409
617
  messages: args.messages ?? original.messages,
410
618
  rerunOf: args.requestId,
411
619
  action: original.actionName,
620
+ tags: original.tags,
412
621
  });
413
622
  }
414
623
 
@@ -418,8 +627,19 @@ export class AIBudget {
418
627
  get requests() {
419
628
  const c = this.component;
420
629
  return {
421
- list: (ctx: RunQueryCtx, args: { userId?: string; limit?: number } = {}) =>
422
- ctx.runQuery(c.lib.listRequests, args),
630
+ /** Filter by userId, or by any {dimension, value} (incl. custom tags). */
631
+ list: (
632
+ ctx: RunQueryCtx,
633
+ args: {
634
+ userId?: string;
635
+ dimension?: string;
636
+ value?: string;
637
+ limit?: number;
638
+ } = {}
639
+ ) => ctx.runQuery(c.lib.listRequests, args),
640
+ /** One request, including its stored prompt and response. */
641
+ get: (ctx: RunQueryCtx, args: { requestId: string }) =>
642
+ ctx.runQuery(c.lib.getRequest, { requestId: args.requestId as any }),
423
643
  /** Ancestors up to the original, plus direct re-runs. */
424
644
  lineage: (ctx: RunQueryCtx, args: { requestId: string }) =>
425
645
  ctx.runQuery(c.lib.lineage, { requestId: args.requestId as any }),
@@ -431,60 +651,97 @@ export class AIBudget {
431
651
  };
432
652
  }
433
653
 
434
- /** Per-user budgets and controls. */
435
- get users() {
654
+ /**
655
+ * Budgets and controls for an arbitrary attribution dimension — the
656
+ * generalization of `users`/`actions`. Give it any dimension name (team,
657
+ * project, tenant, customer, env, feature, …) and set caps per value:
658
+ *
659
+ * ai.tag("customer").setLimits(ctx, { value: "acme", monthlySpendLimitNanos });
660
+ * ai.tag("customer").history(ctx, { value: "acme", period: "day" });
661
+ *
662
+ * Attribute a call to it by passing `tags` to `chat`/`languageModel`.
663
+ */
664
+ tag(dimension: string) {
665
+ return this.dimensionApi(dimension, (a: { value: string }) => a.value);
666
+ }
667
+
668
+ // Shared implementation behind tag()/users/actions. `key` maps the namespace's
669
+ // id field (value/userId/name) to the bucket value.
670
+ private dimensionApi<A extends Record<string, any>>(
671
+ dimension: string,
672
+ key: (a: A) => string
673
+ ) {
436
674
  const c = this.component;
437
675
  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: (
676
+ /** All buckets in this dimension. */
677
+ list: (ctx: RunQueryCtx) => ctx.runQuery(c.lib.listBuckets, { dimension }),
678
+ /** One bucket's limits + spend (null if it has none yet). */
679
+ get: (ctx: RunQueryCtx, args: A) =>
680
+ ctx.runQuery(c.lib.getBucket, { dimension, value: key(args) }),
681
+ setLimits: (ctx: RunMutationCtx, args: A & BucketLimits) => {
682
+ const { value, userId, name, ...limits } = args as any;
683
+ return ctx.runMutation(c.lib.setBucketLimits, {
684
+ dimension,
685
+ value: key(args),
686
+ ...limits,
687
+ });
688
+ },
689
+ /** One-time "approve another $X" bump (daily/monthly reset with the window). */
690
+ bump: (ctx: RunMutationCtx, args: A & BumpArgs) =>
691
+ ctx.runMutation(c.lib.bumpBucket, {
692
+ dimension,
693
+ value: key(args),
694
+ dailyNanos: args.dailyNanos,
695
+ monthlyNanos: args.monthlyNanos,
696
+ lifetimeNanos: args.lifetimeNanos,
697
+ }),
698
+ /** Manually credit (negative) or debit (positive) this bucket. */
699
+ adjust: (
454
700
  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),
701
+ args: A & { deltaNanos: number; tokens?: number; reason?: string }
702
+ ) =>
703
+ ctx.runMutation(c.lib.adjustBucket, {
704
+ dimension,
705
+ value: key(args),
706
+ deltaNanos: args.deltaNanos,
707
+ tokens: args.tokens,
708
+ reason: args.reason,
709
+ }),
710
+ /** Durable spend history for this bucket (per day or per month). */
711
+ history: (
712
+ ctx: RunQueryCtx,
713
+ args: A & { period?: "day" | "month"; limit?: number }
714
+ ) =>
715
+ ctx.runQuery(c.lib.usageHistory, {
716
+ dimension,
717
+ value: key(args),
718
+ period: args.period ?? "day",
719
+ limit: args.limit,
720
+ }),
721
+ /** Manual-adjustment audit log for this bucket. */
722
+ adjustments: (ctx: RunQueryCtx, args: A & { limit?: number }) =>
723
+ ctx.runQuery(c.lib.listAdjustments, {
724
+ dimension,
725
+ value: key(args),
726
+ limit: args.limit,
727
+ }),
728
+ /** Delete the bucket (for "user", also its request rows). */
729
+ delete: (ctx: RunMutationCtx, args: A) =>
730
+ ctx.runMutation(c.lib.deleteBucket, { dimension, value: key(args) }),
460
731
  };
461
732
  }
462
733
 
463
- /** Per-action (per-feature) budgets. */
734
+ /** Per-user budgets and controls — sugar over the "user" dimension. */
735
+ get users() {
736
+ return this.dimensionApi<{ userId: string }>("user", (a) => a.userId);
737
+ }
738
+
739
+ /** Per-action (per-feature) budgets — sugar over the "action" dimension. */
464
740
  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
- };
741
+ return this.dimensionApi<{ name: string }>("action", (a) => a.name);
485
742
  }
486
743
 
487
- /** The deployment-wide budget and retention config. */
744
+ /** The deployment-wide budget, alerts, and retention config. */
488
745
  get global() {
489
746
  const c = this.component;
490
747
  return {
@@ -503,6 +760,9 @@ export class AIBudget {
503
760
  ctx: RunMutationCtx,
504
761
  args: { dailyNanos?: number; lifetimeNanos?: number }
505
762
  ) => ctx.runMutation(c.lib.bumpGlobal, args),
763
+ /** Default approaching-limit alert threshold (fraction of a cap, e.g. 0.8). */
764
+ setAlertDefaults: (ctx: RunMutationCtx, args: { warnAtPct?: number }) =>
765
+ ctx.runMutation(c.lib.setAlertDefaults, args),
506
766
  /** Request-row retention window in ms (default 1h; 0 disables). */
507
767
  setRetention: (ctx: RunMutationCtx, args: { retentionMs: number }) =>
508
768
  ctx.runMutation(c.lib.setRetention, args),
@@ -522,7 +782,7 @@ export class AIBudget {
522
782
  };
523
783
  }
524
784
 
525
- /** Per-model prices (cents per million tokens). */
785
+ /** Per-model prices (nanodollars per million tokens) + server-tool fees. */
526
786
  get prices() {
527
787
  const c = this.component;
528
788
  return {
@@ -533,10 +793,154 @@ export class AIBudget {
533
793
  model: string;
534
794
  inputNanosPerMTok: number;
535
795
  outputNanosPerMTok: number;
796
+ /** Cache-read rate; defaults to a discount off input if omitted. */
797
+ cachedNanosPerMTok?: number;
536
798
  }
537
799
  ) => ctx.runMutation(c.lib.setPrice, args),
800
+ /** Per-call fees for provider server tools (web search, etc.). */
801
+ listServerTools: (ctx: RunQueryCtx) =>
802
+ ctx.runQuery(c.lib.listServerToolPrices, {}),
803
+ /** Set a server-tool's per-call price, e.g. { tool: "web_search", nanosPerCall }. */
804
+ setServerTool: (
805
+ ctx: RunMutationCtx,
806
+ args: { tool: string; nanosPerCall: number }
807
+ ) => ctx.runMutation(c.lib.setServerToolPrice, args),
538
808
  };
539
809
  }
810
+
811
+ /**
812
+ * Mount the built-in admin dashboard on your app's HTTP router with one call.
813
+ * Serves a self-contained HTML dashboard (buckets, requests, usage history,
814
+ * settings) plus a small JSON API, all backed by the component — no extra
815
+ * queries to write.
816
+ *
817
+ * // convex/http.ts
818
+ * import { httpRouter } from "convex/server";
819
+ * const http = httpRouter();
820
+ * ai.registerRoutes(http, { authorize: async (ctx) =>
821
+ * (await ctx.auth.getUserIdentity())?.role === "admin" });
822
+ * export default http;
823
+ *
824
+ * It then lives at `https://<deployment>.convex.site/aibudget`.
825
+ *
826
+ * SECURITY: the endpoint is public on the internet. You MUST gate it — either
827
+ * pass `authorize` (recommended: check the caller is a deployment admin) or
828
+ * set the `AI_BUDGET_DASHBOARD_TOKEN` env var (a bearer token / `?token=`).
829
+ * With neither, every route returns 401.
830
+ */
831
+ registerRoutes(
832
+ http: HttpRouter,
833
+ opts: {
834
+ /** Mount path (default "/aibudget"). */
835
+ path?: string;
836
+ /** Return true to allow the request. Runs on the HTML page and every API call. */
837
+ authorize?: (ctx: any, request: Request) => boolean | Promise<boolean>;
838
+ } = {}
839
+ ) {
840
+ const prefix = (opts.path ?? "/aibudget").replace(/\/+$/, "");
841
+ const c = this.component.lib;
842
+ const authorize = opts.authorize;
843
+
844
+ const guard = async (
845
+ ctx: any,
846
+ request: Request
847
+ ): Promise<{ ok: boolean; token: string }> => {
848
+ if (authorize) return { ok: await authorize(ctx, request), token: "" };
849
+ const token = (globalThis as any).process?.env?.AI_BUDGET_DASHBOARD_TOKEN;
850
+ if (!token) return { ok: false, token: "" };
851
+ const url = new URL(request.url);
852
+ const bearer = (request.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
853
+ // `?token=` is accepted only for the initial page navigation (a browser
854
+ // GET can't set headers); the page strips it from the URL on load and the
855
+ // JSON API is called with the bearer header. Compared in constant time.
856
+ const provided = bearer || url.searchParams.get("token") || "";
857
+ return { ok: timingSafeEqual(provided, token), token };
858
+ };
859
+ const json = (data: unknown, status = 200) =>
860
+ new Response(JSON.stringify(data ?? null), {
861
+ status,
862
+ headers: { "content-type": "application/json" },
863
+ });
864
+
865
+ const handle = async (ctx: any, request: Request): Promise<Response> => {
866
+ const url = new URL(request.url);
867
+ const sub = url.pathname.slice(prefix.length) || "/";
868
+ const { ok, token } = await guard(ctx, request);
869
+ if (!ok) {
870
+ return new Response(
871
+ "Unauthorized. Pass `authorize` to registerRoutes or set AI_BUDGET_DASHBOARD_TOKEN.",
872
+ { status: 401 }
873
+ );
874
+ }
875
+
876
+ if (sub.startsWith("/api/")) {
877
+ const route = request.method + " " + sub.slice(4); // strip "/api"
878
+ const p: Record<string, string> = {};
879
+ url.searchParams.forEach((v, k) => {
880
+ p[k] = v;
881
+ });
882
+ const body =
883
+ request.method === "POST"
884
+ ? await request.json().catch(() => ({}))
885
+ : {};
886
+ switch (route) {
887
+ case "GET /buckets":
888
+ return json(await ctx.runQuery(c.listBuckets, { dimension: p.dimension || undefined }));
889
+ case "GET /requests":
890
+ return json(await ctx.runQuery(c.listRequests, {
891
+ userId: p.userId || undefined,
892
+ dimension: p.dimension || undefined,
893
+ value: p.value || undefined,
894
+ limit: 100,
895
+ }));
896
+ case "GET /usage":
897
+ return json(await ctx.runQuery(c.usageHistory, {
898
+ dimension: p.dimension,
899
+ value: p.value,
900
+ period: p.period === "month" ? "month" : "day",
901
+ }));
902
+ case "GET /global":
903
+ return json(await ctx.runQuery(c.getGlobalStatus, {}));
904
+ case "GET /prices":
905
+ return json(await ctx.runQuery(c.listPrices, {}));
906
+ case "POST /setLimits":
907
+ return json(await ctx.runMutation(c.setBucketLimits, body));
908
+ case "POST /bump":
909
+ return json(await ctx.runMutation(c.bumpBucket, body));
910
+ case "POST /adjust":
911
+ return json(await ctx.runMutation(c.adjustBucket, body));
912
+ case "POST /delete":
913
+ return json(await ctx.runMutation(c.deleteBucket, body));
914
+ case "POST /global/setLimits":
915
+ return json(await ctx.runMutation(c.setGlobalLimits, body));
916
+ case "POST /global/setAlertDefaults":
917
+ return json(await ctx.runMutation(c.setAlertDefaults, body));
918
+ case "POST /global/setRetention":
919
+ return json(await ctx.runMutation(c.setRetention, body));
920
+ case "POST /setPrice":
921
+ return json(await ctx.runMutation(c.setPrice, body));
922
+ default:
923
+ return json({ error: "not found" }, 404);
924
+ }
925
+ }
926
+
927
+ // Inject as JSON literals (function replacers so `$` in the value isn't
928
+ // treated as a replacement pattern). This keeps a token/prefix containing
929
+ // quotes, backslashes, or `</script>` from breaking out of the JS string.
930
+ const html = DASHBOARD_HTML.replace(
931
+ /__API_BASE__/g,
932
+ () => JSON.stringify(`${prefix}/api`)
933
+ ).replace(/__TOKEN__/g, () => JSON.stringify(token));
934
+ return new Response(html, {
935
+ headers: { "content-type": "text/html; charset=utf-8" },
936
+ });
937
+ };
938
+
939
+ const handler = httpActionGeneric(handle);
940
+ http.route({ path: prefix, method: "GET", handler });
941
+ http.route({ pathPrefix: `${prefix}/`, method: "GET", handler });
942
+ http.route({ pathPrefix: `${prefix}/`, method: "POST", handler });
943
+ }
540
944
  }
541
945
 
542
946
  /** @deprecated Renamed to `AIBudget`. */