@convex-dev/ai-budget 0.0.2-alpha.3 → 0.0.2-alpha.5

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.
@@ -39,14 +39,30 @@ function extractUsage(usage) {
39
39
  return {
40
40
  promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens),
41
41
  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 ??
42
+ // cached prompt tokens. The Convex gateway reports these at
43
+ // `usage.inputTokenDetails.cacheReadTokens`; the other paths cover AI SDK v5
44
+ // (`cachedInputTokens`) and raw OpenAI-compatible shapes.
45
+ cachedTokens: toTokenCount(usage?.inputTokenDetails?.cacheReadTokens ??
46
+ usage?.cachedInputTokens ??
45
47
  usage?.promptTokensDetails?.cachedTokens ??
46
48
  usage?.prompt_tokens_details?.cached_tokens ??
47
49
  usage?.cached_tokens),
48
50
  };
49
51
  }
52
+ // The gateway doesn't report a dollar cost today, but if a future response ever
53
+ // carries an unambiguous nanodollar cost we pass it straight through as
54
+ // authoritative (finishRequest prefers it over the token-based estimate). Only
55
+ // an explicitly nano-denominated field is trusted — a bare `cost` could be in
56
+ // dollars and silently mis-bill by 1e9×.
57
+ function extractGatewayCostNanos(result) {
58
+ const meta = result?.providerMetadata?.convexGateway;
59
+ const candidates = [meta?.costNanos, result?.usage?.costNanos];
60
+ for (const c of candidates) {
61
+ if (typeof c === "number" && Number.isFinite(c) && c >= 0)
62
+ return c;
63
+ }
64
+ return undefined;
65
+ }
50
66
  // Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
51
67
  function simplifyPrompt(prompt) {
52
68
  if (!Array.isArray(prompt))
@@ -78,24 +94,46 @@ function extractText(result) {
78
94
  }
79
95
  return "";
80
96
  }
81
- // ---------- client ----------
82
97
  export class AIBudget {
83
98
  component;
84
99
  defaultModel;
85
100
  onSoftLimit;
101
+ onThreshold;
102
+ onLimitReached;
86
103
  constructor(component, options) {
87
104
  this.component = component;
88
105
  this.defaultModel = options?.defaultModel ?? "openai/gpt-4o-mini";
89
106
  this.onSoftLimit = options?.onSoftLimit;
107
+ this.onThreshold = options?.onThreshold;
108
+ this.onLimitReached = options?.onLimitReached;
109
+ }
110
+ // Fire the soft-limit + threshold callbacks from a startRequest result.
111
+ async fireBudgetEvents(base, warnings, notices) {
112
+ if (warnings.length > 0 && this.onSoftLimit) {
113
+ try {
114
+ await this.onSoftLimit({ ...base, messages: warnings, warnings });
115
+ }
116
+ catch {
117
+ /* never let a callback error break a request */
118
+ }
119
+ }
120
+ if (notices.length > 0 && this.onThreshold) {
121
+ try {
122
+ await this.onThreshold({ ...base, messages: notices });
123
+ }
124
+ catch {
125
+ /* swallow */
126
+ }
127
+ }
90
128
  }
91
- async fireSoftLimit(info) {
92
- if (info.warnings.length === 0 || !this.onSoftLimit)
129
+ async fireLimitReached(info) {
130
+ if (!this.onLimitReached)
93
131
  return;
94
132
  try {
95
- await this.onSoftLimit(info);
133
+ await this.onLimitReached(info);
96
134
  }
97
135
  catch {
98
- // never let a callback error break a request
136
+ /* swallow */
99
137
  }
100
138
  }
101
139
  /**
@@ -110,11 +148,20 @@ export class AIBudget {
110
148
  const started = await ctx.runMutation(this.component.lib.startRequest, {
111
149
  userId,
112
150
  actionName,
151
+ tags: args.tags,
113
152
  model,
114
153
  messages,
115
154
  rerunOf: args.rerunOf,
116
155
  });
117
156
  if (!started.allowed) {
157
+ await this.fireLimitReached({
158
+ userId,
159
+ action: actionName,
160
+ tags: args.tags,
161
+ messages: [started.reason],
162
+ code: started.code,
163
+ reason: started.reason,
164
+ });
118
165
  throw new ConvexError({
119
166
  kind: "AIBudgetLimit",
120
167
  code: started.code,
@@ -123,7 +170,8 @@ export class AIBudget {
123
170
  }
124
171
  const requestId = started.requestId;
125
172
  const warnings = started.warnings;
126
- await this.fireSoftLimit({ userId, action: actionName, requestId, warnings });
173
+ const notices = started.notices;
174
+ await this.fireBudgetEvents({ userId, action: actionName, tags: args.tags, requestId }, warnings, notices);
127
175
  const start = Date.now();
128
176
  try {
129
177
  // The full chain (incl. system) is stored on the request for audit/replay,
@@ -143,9 +191,10 @@ export class AIBudget {
143
191
  requestId,
144
192
  responseText: result.text,
145
193
  ...usage,
194
+ costNanos: extractGatewayCostNanos(result),
146
195
  latencyMs: Date.now() - start,
147
196
  });
148
- return { text: result.text, requestId, costNanos, warnings, ...usage };
197
+ return { text: result.text, requestId, costNanos, warnings, notices, ...usage };
149
198
  }
150
199
  catch (e) {
151
200
  await ctx.runMutation(this.component.lib.finishRequest, {
@@ -165,29 +214,33 @@ export class AIBudget {
165
214
  languageModel(ctx, opts = {}) {
166
215
  const modelId = opts.model ?? this.defaultModel;
167
216
  const component = this.component;
168
- const fireSoftLimit = this.fireSoftLimit.bind(this);
217
+ const fireBudgetEvents = this.fireBudgetEvents.bind(this);
218
+ const fireLimitReached = this.fireLimitReached.bind(this);
169
219
  const begin = async (params) => {
170
220
  const userId = await resolveUserId(ctx, opts.userId);
171
221
  const actionName = await resolveActionName(ctx, opts.action);
222
+ const base = { userId, action: actionName, tags: opts.tags };
172
223
  const started = await ctx.runMutation(component.lib.startRequest, {
173
224
  userId,
174
225
  actionName,
226
+ tags: opts.tags,
175
227
  model: modelId,
176
228
  messages: simplifyPrompt(params.prompt),
177
229
  });
178
230
  if (!started.allowed) {
231
+ await fireLimitReached({
232
+ ...base,
233
+ messages: [started.reason],
234
+ code: started.code,
235
+ reason: started.reason,
236
+ });
179
237
  throw new ConvexError({
180
238
  kind: "AIBudgetLimit",
181
239
  code: started.code,
182
240
  reason: started.reason,
183
241
  });
184
242
  }
185
- await fireSoftLimit({
186
- userId,
187
- action: actionName,
188
- requestId: started.requestId,
189
- warnings: started.warnings,
190
- });
243
+ await fireBudgetEvents({ ...base, requestId: started.requestId }, started.warnings, started.notices);
191
244
  return started.requestId;
192
245
  };
193
246
  const finish = async (requestId, fields) => ctx.runMutation(component.lib.finishRequest, { requestId, ...fields });
@@ -202,6 +255,7 @@ export class AIBudget {
202
255
  await finish(requestId, {
203
256
  responseText: extractText(result),
204
257
  ...extractUsage(result.usage),
258
+ costNanos: extractGatewayCostNanos(result),
205
259
  latencyMs: Date.now() - start,
206
260
  });
207
261
  return result;
@@ -278,6 +332,7 @@ export class AIBudget {
278
332
  messages: args.messages ?? original.messages,
279
333
  rerunOf: args.requestId,
280
334
  action: original.actionName,
335
+ tags: original.tags,
281
336
  });
282
337
  }
283
338
  // ---------- namespaced admin API ----------
@@ -285,6 +340,7 @@ export class AIBudget {
285
340
  get requests() {
286
341
  const c = this.component;
287
342
  return {
343
+ /** Filter by userId, or by any {dimension, value} (incl. custom tags). */
288
344
  list: (ctx, args = {}) => ctx.runQuery(c.lib.listRequests, args),
289
345
  /** Ancestors up to the original, plus direct re-runs. */
290
346
  lineage: (ctx, args) => ctx.runQuery(c.lib.lineage, { requestId: args.requestId }),
@@ -292,28 +348,78 @@ export class AIBudget {
292
348
  rerun: (ctx, args) => this.rerunImpl(ctx, args),
293
349
  };
294
350
  }
295
- /** Per-user budgets and controls. */
296
- get users() {
351
+ /**
352
+ * Budgets and controls for an arbitrary attribution dimension — the
353
+ * generalization of `users`/`actions`. Give it any dimension name (team,
354
+ * project, tenant, customer, env, feature, …) and set caps per value:
355
+ *
356
+ * ai.tag("customer").setLimits(ctx, { value: "acme", monthlySpendLimitNanos });
357
+ * ai.tag("customer").history(ctx, { value: "acme", period: "day" });
358
+ *
359
+ * Attribute a call to it by passing `tags` to `chat`/`languageModel`.
360
+ */
361
+ tag(dimension) {
362
+ return this.dimensionApi(dimension, (a) => a.value);
363
+ }
364
+ // Shared implementation behind tag()/users/actions. `key` maps the namespace's
365
+ // id field (value/userId/name) to the bucket value.
366
+ dimensionApi(dimension, key) {
297
367
  const c = this.component;
298
368
  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),
369
+ /** All buckets in this dimension. */
370
+ list: (ctx) => ctx.runQuery(c.lib.listBuckets, { dimension }),
371
+ /** One bucket's limits + spend (null if it has none yet). */
372
+ get: (ctx, args) => ctx.runQuery(c.lib.getBucket, { dimension, value: key(args) }),
373
+ setLimits: (ctx, args) => {
374
+ const { value, userId, name, ...limits } = args;
375
+ return ctx.runMutation(c.lib.setBucketLimits, {
376
+ dimension,
377
+ value: key(args),
378
+ ...limits,
379
+ });
380
+ },
381
+ /** One-time "approve another $X" bump (daily/monthly reset with the window). */
382
+ bump: (ctx, args) => ctx.runMutation(c.lib.bumpBucket, {
383
+ dimension,
384
+ value: key(args),
385
+ dailyNanos: args.dailyNanos,
386
+ monthlyNanos: args.monthlyNanos,
387
+ lifetimeNanos: args.lifetimeNanos,
388
+ }),
389
+ /** Manually credit (negative) or debit (positive) this bucket. */
390
+ adjust: (ctx, args) => ctx.runMutation(c.lib.adjustBucket, {
391
+ dimension,
392
+ value: key(args),
393
+ deltaNanos: args.deltaNanos,
394
+ tokens: args.tokens,
395
+ reason: args.reason,
396
+ }),
397
+ /** Durable spend history for this bucket (per day or per month). */
398
+ history: (ctx, args) => ctx.runQuery(c.lib.usageHistory, {
399
+ dimension,
400
+ value: key(args),
401
+ period: args.period ?? "day",
402
+ limit: args.limit,
403
+ }),
404
+ /** Manual-adjustment audit log for this bucket. */
405
+ adjustments: (ctx, args) => ctx.runQuery(c.lib.listAdjustments, {
406
+ dimension,
407
+ value: key(args),
408
+ limit: args.limit,
409
+ }),
410
+ /** Delete the bucket (for "user", also its request rows). */
411
+ delete: (ctx, args) => ctx.runMutation(c.lib.deleteBucket, { dimension, value: key(args) }),
305
412
  };
306
413
  }
307
- /** Per-action (per-feature) budgets. */
414
+ /** Per-user budgets and controls — sugar over the "user" dimension. */
415
+ get users() {
416
+ return this.dimensionApi("user", (a) => a.userId);
417
+ }
418
+ /** Per-action (per-feature) budgets — sugar over the "action" dimension. */
308
419
  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
- };
420
+ return this.dimensionApi("action", (a) => a.name);
315
421
  }
316
- /** The deployment-wide budget and retention config. */
422
+ /** The deployment-wide budget, alerts, and retention config. */
317
423
  get global() {
318
424
  const c = this.component;
319
425
  return {
@@ -322,6 +428,8 @@ export class AIBudget {
322
428
  /** A killswitch spend cap across all users/actions (enforced approximately). */
323
429
  setLimits: (ctx, args) => ctx.runMutation(c.lib.setGlobalLimits, args),
324
430
  bump: (ctx, args) => ctx.runMutation(c.lib.bumpGlobal, args),
431
+ /** Default approaching-limit alert threshold (fraction of a cap, e.g. 0.8). */
432
+ setAlertDefaults: (ctx, args) => ctx.runMutation(c.lib.setAlertDefaults, args),
325
433
  /** Request-row retention window in ms (default 1h; 0 disables). */
326
434
  setRetention: (ctx, args) => ctx.runMutation(c.lib.setRetention, args),
327
435
  };
@@ -20,22 +20,28 @@ import type { FunctionReference } from "convex/server";
20
20
  */
21
21
  export type ComponentApi<Name extends string | undefined = string | undefined> = {
22
22
  lib: {
23
- bumpAction: FunctionReference<"mutation", "internal", {
23
+ adjustBucket: FunctionReference<"mutation", "internal", {
24
+ deltaNanos: number;
25
+ dimension: string;
26
+ reason?: string;
27
+ tokens?: number;
28
+ value: string;
29
+ }, null, Name>;
30
+ bumpBucket: FunctionReference<"mutation", "internal", {
24
31
  dailyNanos?: number;
32
+ dimension: string;
25
33
  lifetimeNanos?: number;
26
- name: string;
34
+ monthlyNanos?: number;
35
+ value: string;
27
36
  }, null, Name>;
28
37
  bumpGlobal: FunctionReference<"mutation", "internal", {
29
38
  dailyNanos?: number;
30
39
  lifetimeNanos?: number;
40
+ monthlyNanos?: number;
31
41
  }, null, Name>;
32
- bumpUser: FunctionReference<"mutation", "internal", {
33
- dailyNanos?: number;
34
- lifetimeNanos?: number;
35
- userId: string;
36
- }, null, Name>;
37
- deleteUser: FunctionReference<"mutation", "internal", {
38
- userId: string;
42
+ deleteBucket: FunctionReference<"mutation", "internal", {
43
+ dimension: string;
44
+ value: string;
39
45
  }, {
40
46
  deletedThisBatch: number;
41
47
  done: boolean;
@@ -43,6 +49,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
43
49
  finishRequest: FunctionReference<"mutation", "internal", {
44
50
  cachedTokens?: number;
45
51
  completionTokens?: number;
52
+ costNanos?: number;
46
53
  error?: string;
47
54
  latencyMs?: number;
48
55
  promptTokens?: number;
@@ -51,6 +58,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
51
58
  }, {
52
59
  costNanos: number;
53
60
  }, Name>;
61
+ getBucket: FunctionReference<"query", "internal", {
62
+ dimension: string;
63
+ value: string;
64
+ }, any, Name>;
54
65
  getGlobalStatus: FunctionReference<"query", "internal", {}, {
55
66
  dailySpendLimitNanos: number | null;
56
67
  enforcement: "hard" | "soft";
@@ -68,42 +79,50 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
68
79
  lineage: FunctionReference<"query", "internal", {
69
80
  requestId: string;
70
81
  }, any, Name>;
71
- listActions: FunctionReference<"query", "internal", {}, any, Name>;
82
+ listAdjustments: FunctionReference<"query", "internal", {
83
+ dimension: string;
84
+ limit?: number;
85
+ value: string;
86
+ }, any, Name>;
87
+ listBuckets: FunctionReference<"query", "internal", {
88
+ dimension?: string;
89
+ }, any, Name>;
72
90
  listPrices: FunctionReference<"query", "internal", {}, any, Name>;
73
91
  listRequests: FunctionReference<"query", "internal", {
92
+ dimension?: string;
74
93
  limit?: number;
75
94
  userId?: string;
95
+ value?: string;
76
96
  }, any, Name>;
77
- listUsers: FunctionReference<"query", "internal", {}, any, Name>;
78
- setActionLimits: FunctionReference<"mutation", "internal", {
97
+ setAlertDefaults: FunctionReference<"mutation", "internal", {
98
+ warnAtPct?: number;
99
+ }, null, Name>;
100
+ setBucketLimits: FunctionReference<"mutation", "internal", {
101
+ blocked?: boolean;
79
102
  dailySpendLimitNanos?: number;
80
103
  dailyTokenLimit?: number;
81
- disabled?: boolean;
104
+ dimension: string;
82
105
  enforcement?: "hard" | "soft";
83
106
  lifetimeSpendLimitNanos?: number;
84
107
  lifetimeTokenLimit?: number;
85
- name: string;
108
+ maxConcurrent?: number;
109
+ monthlySpendLimitNanos?: number;
110
+ monthlyTokenLimit?: number;
111
+ requestsPerMinute?: number;
112
+ value: string;
113
+ warnAtPct?: number;
86
114
  }, null, Name>;
87
115
  setGlobalLimits: FunctionReference<"mutation", "internal", {
88
116
  dailySpendLimitNanos?: number;
89
117
  enforcement?: "hard" | "soft";
90
118
  lifetimeSpendLimitNanos?: number;
91
119
  }, null, Name>;
92
- setLimits: FunctionReference<"mutation", "internal", {
93
- blocked?: boolean;
94
- dailySpendLimitNanos?: number;
95
- dailyTokenLimit?: number;
96
- enforcement?: "hard" | "soft";
97
- lifetimeSpendLimitNanos?: number;
98
- lifetimeTokenLimit?: number;
99
- requestsPerMinute?: number;
100
- userId: string;
101
- }, null, Name>;
102
120
  setModelPolicy: FunctionReference<"mutation", "internal", {
103
121
  mode: "open" | "allowlist" | "denylist";
104
122
  models: Array<string>;
105
123
  }, null, Name>;
106
124
  setPrice: FunctionReference<"mutation", "internal", {
125
+ cachedNanosPerMTok?: number;
107
126
  inputNanosPerMTok: number;
108
127
  model: string;
109
128
  outputNanosPerMTok: number;
@@ -119,9 +138,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
119
138
  }>;
120
139
  model: string;
121
140
  rerunOf?: string;
141
+ tags?: Array<{
142
+ dimension: string;
143
+ value: string;
144
+ }>;
122
145
  userId: string;
123
146
  }, {
124
147
  allowed: true;
148
+ notices: Array<string>;
125
149
  requestId: string;
126
150
  warnings: Array<string>;
127
151
  } | {
@@ -129,5 +153,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
129
153
  code: string;
130
154
  reason: string;
131
155
  }, Name>;
156
+ usageHistory: FunctionReference<"query", "internal", {
157
+ dimension: string;
158
+ limit?: number;
159
+ period: "day" | "month";
160
+ value: string;
161
+ }, any, Name>;
132
162
  };
133
163
  };