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

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.
@@ -0,0 +1,543 @@
1
+ import type { Expand, FunctionReference } from "convex/server";
2
+ import { ConvexError, type GenericId } from "convex/values";
3
+ import { generateText, wrapLanguageModel, type LanguageModel } from "ai";
4
+ import { convexGateway } from "@convex-dev/ai-sdk-provider";
5
+ import type { api } from "../component/_generated/api";
6
+
7
+ // ---------- types ----------
8
+
9
+ // Map branded document Ids to plain strings across the component boundary.
10
+ // Note: only GenericId, not `string` — widening every string (and string-literal
11
+ // union like "hard"|"soft") to `string` breaks structural matching of returns.
12
+ type OpaqueIds<T> = T extends GenericId<infer _T>
13
+ ? string
14
+ : T extends (infer U)[]
15
+ ? OpaqueIds<U>[]
16
+ : T extends ArrayBuffer
17
+ ? ArrayBuffer
18
+ : T extends object
19
+ ? { [K in keyof T]: OpaqueIds<T[K]> }
20
+ : T;
21
+
22
+ type UseApi<API> = Expand<{
23
+ [mod in keyof API]: API[mod] extends FunctionReference<
24
+ infer FType,
25
+ "public",
26
+ infer FArgs,
27
+ infer FReturnType,
28
+ infer FComponentPath
29
+ >
30
+ ? FunctionReference<
31
+ FType,
32
+ "internal",
33
+ OpaqueIds<FArgs>,
34
+ OpaqueIds<FReturnType>,
35
+ FComponentPath
36
+ >
37
+ : UseApi<API[mod]>;
38
+ }>;
39
+
40
+ export type AIBudgetApi = UseApi<typeof api>;
41
+ /** @deprecated use AIBudgetApi */
42
+ export type AIGatewayApi = AIBudgetApi;
43
+
44
+ /** Fired when a request is admitted over a *soft* limit. */
45
+ export type SoftLimitInfo = {
46
+ userId: string;
47
+ action?: string;
48
+ requestId: string;
49
+ warnings: string[];
50
+ };
51
+ export type AIBudgetOptions = {
52
+ defaultModel?: string;
53
+ /**
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.
57
+ */
58
+ onSoftLimit?: (info: SoftLimitInfo) => void | Promise<void>;
59
+ };
60
+
61
+ type RunQueryCtx = {
62
+ runQuery: <Query extends FunctionReference<"query", "internal">>(
63
+ query: Query,
64
+ args: Query["_args"]
65
+ ) => Promise<Query["_returnType"]>;
66
+ };
67
+ type RunMutationCtx = RunQueryCtx & {
68
+ runMutation: <M extends FunctionReference<"mutation", "internal">>(
69
+ mutation: M,
70
+ args: M["_args"]
71
+ ) => Promise<M["_returnType"]>;
72
+ meta?: { getFunctionMetadata(): Promise<{ name: string }> };
73
+ auth?: { getUserIdentity(): Promise<{ subject?: string } | null> };
74
+ };
75
+
76
+ // The calling Convex action's name (e.g. "ai:sendMessage"), unless overridden.
77
+ async function resolveActionName(
78
+ ctx: RunMutationCtx,
79
+ explicit?: string
80
+ ): Promise<string | undefined> {
81
+ if (explicit !== undefined) return explicit;
82
+ try {
83
+ return (await ctx.meta?.getFunctionMetadata())?.name;
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ // The user this call is billed to. If not passed explicitly, it's the
90
+ // authenticated caller (ctx.auth.getUserIdentity().subject) — so budgets are
91
+ // server-derived by default and can't be spoofed by a client-supplied id.
92
+ async function resolveUserId(
93
+ ctx: RunMutationCtx,
94
+ explicit?: string
95
+ ): Promise<string> {
96
+ if (explicit !== undefined) return explicit;
97
+ const identity = await ctx.auth?.getUserIdentity?.();
98
+ if (identity?.subject) return identity.subject;
99
+ throw new Error(
100
+ "ai-budget: no `userId` was passed and there is no authenticated user " +
101
+ "(ctx.auth.getUserIdentity() returned null). Either authenticate the " +
102
+ "request or pass an explicit `userId`."
103
+ );
104
+ }
105
+
106
+ export type Message = { role: string; content: string };
107
+
108
+ export type ChatResult = {
109
+ text: string;
110
+ requestId: string;
111
+ costNanos: number;
112
+ promptTokens: number;
113
+ completionTokens: number;
114
+ cachedTokens: number;
115
+ /** Soft-limit warnings raised at admission (empty unless a soft cap was hit). */
116
+ warnings: string[];
117
+ };
118
+
119
+ // ---------- helpers ----------
120
+
121
+ // Token counts across AI SDK versions come as plain numbers or, in v7, as a
122
+ // structured breakdown like { reasoning, text, total }. Coerce either to a number.
123
+ function toTokenCount(x: any): number {
124
+ if (typeof x === "number") return Number.isFinite(x) ? x : 0;
125
+ if (x && typeof x === "object") return toTokenCount(x.total ?? x.text ?? 0);
126
+ return 0;
127
+ }
128
+
129
+ function extractUsage(usage: any): {
130
+ promptTokens: number;
131
+ completionTokens: number;
132
+ cachedTokens: number;
133
+ } {
134
+ return {
135
+ promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens),
136
+ completionTokens: toTokenCount(usage?.outputTokens ?? usage?.completionTokens),
137
+ // cached prompt tokens: AI SDK v5 `cachedInputTokens`, OpenAI-compat
138
+ // `prompt_tokens_details.cached_tokens` / `cached_tokens`.
139
+ cachedTokens: toTokenCount(
140
+ usage?.cachedInputTokens ??
141
+ usage?.promptTokensDetails?.cachedTokens ??
142
+ usage?.prompt_tokens_details?.cached_tokens ??
143
+ usage?.cached_tokens
144
+ ),
145
+ };
146
+ }
147
+
148
+ // Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
149
+ function simplifyPrompt(prompt: any): Message[] {
150
+ if (!Array.isArray(prompt)) return [];
151
+ return prompt.map((m: any) => {
152
+ let content: string;
153
+ if (typeof m.content === "string") {
154
+ content = m.content;
155
+ } else if (Array.isArray(m.content)) {
156
+ content = m.content
157
+ .map((part: any) =>
158
+ part?.type === "text" ? part.text : JSON.stringify(part)
159
+ )
160
+ .join("");
161
+ } else {
162
+ content = JSON.stringify(m.content);
163
+ }
164
+ return { role: String(m.role), content };
165
+ });
166
+ }
167
+
168
+ function extractText(result: any): string {
169
+ if (typeof result?.text === "string") return result.text;
170
+ if (Array.isArray(result?.content)) {
171
+ return result.content
172
+ .filter((p: any) => p?.type === "text")
173
+ .map((p: any) => p.text)
174
+ .join("");
175
+ }
176
+ return "";
177
+ }
178
+
179
+ // ---------- client ----------
180
+
181
+ export class AIBudget {
182
+ public defaultModel: string;
183
+ private onSoftLimit?: AIBudgetOptions["onSoftLimit"];
184
+ constructor(
185
+ public component: AIBudgetApi,
186
+ options?: AIBudgetOptions
187
+ ) {
188
+ this.defaultModel = options?.defaultModel ?? "openai/gpt-4o-mini";
189
+ this.onSoftLimit = options?.onSoftLimit;
190
+ }
191
+
192
+ private async fireSoftLimit(info: SoftLimitInfo) {
193
+ if (info.warnings.length === 0 || !this.onSoftLimit) return;
194
+ try {
195
+ await this.onSoftLimit(info);
196
+ } catch {
197
+ // never let a callback error break a request
198
+ }
199
+ }
200
+
201
+ /**
202
+ * One-shot chat through the AI Gateway with tracking + limits.
203
+ * Call from an action. `userId` defaults to the authenticated caller.
204
+ */
205
+ async chat(
206
+ ctx: RunMutationCtx,
207
+ args: {
208
+ /** Whom to bill. Defaults to the authenticated user (ctx.auth). */
209
+ 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
+ action?: string;
216
+ } = {}
217
+ ): 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 ?? "" }];
223
+ const started = await ctx.runMutation(this.component.lib.startRequest, {
224
+ userId,
225
+ actionName,
226
+ model,
227
+ messages,
228
+ rerunOf: args.rerunOf as any,
229
+ });
230
+ if (!started.allowed) {
231
+ throw new ConvexError({
232
+ kind: "AIBudgetLimit",
233
+ code: started.code,
234
+ reason: started.reason,
235
+ });
236
+ }
237
+ const requestId = started.requestId;
238
+ const warnings = started.warnings;
239
+ await this.fireSoftLimit({ userId, action: actionName, requestId, warnings });
240
+ const start = Date.now();
241
+ 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);
256
+ const { costNanos } = await ctx.runMutation(
257
+ this.component.lib.finishRequest,
258
+ {
259
+ requestId,
260
+ responseText: result.text,
261
+ ...usage,
262
+ latencyMs: Date.now() - start,
263
+ }
264
+ );
265
+ return { text: result.text, requestId, costNanos, warnings, ...usage };
266
+ } catch (e) {
267
+ await ctx.runMutation(this.component.lib.finishRequest, {
268
+ requestId,
269
+ error: String(e),
270
+ latencyMs: Date.now() - start,
271
+ });
272
+ throw e;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * An AI SDK LanguageModel that enforces limits and records usage/cost for
278
+ * `userId` on every call. Drop it into `generateText`, `streamText`, or the
279
+ * Convex Agent component (`new Agent(components.agent, { languageModel })`).
280
+ * `userId` defaults to the authenticated caller (ctx.auth).
281
+ */
282
+ languageModel(
283
+ ctx: RunMutationCtx,
284
+ opts: { userId?: string; model?: string; action?: string } = {}
285
+ ): LanguageModel {
286
+ const modelId = opts.model ?? this.defaultModel;
287
+ const component = this.component;
288
+ const fireSoftLimit = this.fireSoftLimit.bind(this);
289
+
290
+ const begin = async (params: any) => {
291
+ const userId = await resolveUserId(ctx, opts.userId);
292
+ const actionName = await resolveActionName(ctx, opts.action);
293
+ const started = await ctx.runMutation(component.lib.startRequest, {
294
+ userId,
295
+ actionName,
296
+ model: modelId,
297
+ messages: simplifyPrompt(params.prompt),
298
+ });
299
+ if (!started.allowed) {
300
+ throw new ConvexError({
301
+ kind: "AIBudgetLimit",
302
+ code: started.code,
303
+ reason: started.reason,
304
+ });
305
+ }
306
+ await fireSoftLimit({
307
+ userId,
308
+ action: actionName,
309
+ requestId: started.requestId,
310
+ warnings: started.warnings,
311
+ });
312
+ return started.requestId;
313
+ };
314
+ const finish = async (
315
+ requestId: any,
316
+ fields: {
317
+ responseText?: string;
318
+ error?: string;
319
+ promptTokens?: number;
320
+ completionTokens?: number;
321
+ latencyMs?: number;
322
+ }
323
+ ) => ctx.runMutation(component.lib.finishRequest, { requestId, ...fields });
324
+
325
+ return wrapLanguageModel({
326
+ model: convexGateway(modelId) as any,
327
+ middleware: {
328
+ wrapGenerate: async ({ doGenerate, params }: any) => {
329
+ const requestId = await begin(params);
330
+ const start = Date.now();
331
+ try {
332
+ const result = await doGenerate();
333
+ await finish(requestId, {
334
+ responseText: extractText(result),
335
+ ...extractUsage(result.usage),
336
+ latencyMs: Date.now() - start,
337
+ });
338
+ return result;
339
+ } catch (e) {
340
+ await finish(requestId, {
341
+ error: String(e),
342
+ latencyMs: Date.now() - start,
343
+ });
344
+ throw e;
345
+ }
346
+ },
347
+ wrapStream: async ({ doStream, params }: any) => {
348
+ const requestId = await begin(params);
349
+ const start = Date.now();
350
+ let text = "";
351
+ let usage: any = undefined;
352
+ try {
353
+ const result = await doStream();
354
+ // finishRequest is idempotent (terminal-guarded server-side), so
355
+ // settling from multiple stream outcomes — normal close, an error
356
+ // chunk, or a cancel — is safe: the first wins, the rest no-op.
357
+ // Without this an errored or abandoned stream would never settle and
358
+ // its real usage would be lost (recorded as free by the reconciler).
359
+ let settled = false;
360
+ const settle = (error?: string) => {
361
+ if (settled) return;
362
+ settled = true;
363
+ return finish(requestId, {
364
+ responseText: text,
365
+ error,
366
+ ...extractUsage(usage),
367
+ latencyMs: Date.now() - start,
368
+ });
369
+ };
370
+ const tapped = result.stream.pipeThrough(
371
+ new TransformStream({
372
+ transform(chunk: any, controller) {
373
+ if (chunk?.type === "text-delta") {
374
+ text += chunk.delta ?? chunk.textDelta ?? "";
375
+ }
376
+ if (chunk?.type === "finish") usage = chunk.usage;
377
+ if (chunk?.type === "error") void settle(String(chunk.error));
378
+ controller.enqueue(chunk);
379
+ },
380
+ async flush() {
381
+ await settle();
382
+ },
383
+ })
384
+ );
385
+ return { ...result, stream: tapped };
386
+ } catch (e) {
387
+ await finish(requestId, {
388
+ error: String(e),
389
+ latencyMs: Date.now() - start,
390
+ });
391
+ throw e;
392
+ }
393
+ },
394
+ } as any,
395
+ }) as LanguageModel;
396
+ }
397
+
398
+ private async rerunImpl(
399
+ ctx: RunMutationCtx,
400
+ args: { requestId: string; messages?: Message[]; model?: string }
401
+ ): Promise<ChatResult> {
402
+ const original = await ctx.runQuery(this.component.lib.getRequest, {
403
+ requestId: args.requestId as any,
404
+ });
405
+ if (!original) throw new Error("Unknown request");
406
+ return this.chat(ctx, {
407
+ userId: original.userId,
408
+ model: args.model ?? original.model,
409
+ messages: args.messages ?? original.messages,
410
+ rerunOf: args.requestId,
411
+ action: original.actionName,
412
+ });
413
+ }
414
+
415
+ // ---------- namespaced admin API ----------
416
+
417
+ /** The request audit log, replay, and re-run lineage. */
418
+ get requests() {
419
+ const c = this.component;
420
+ return {
421
+ list: (ctx: RunQueryCtx, args: { userId?: string; limit?: number } = {}) =>
422
+ ctx.runQuery(c.lib.listRequests, args),
423
+ /** Ancestors up to the original, plus direct re-runs. */
424
+ lineage: (ctx: RunQueryCtx, args: { requestId: string }) =>
425
+ ctx.runQuery(c.lib.lineage, { requestId: args.requestId as any }),
426
+ /** Replay a stored request, optionally with edited messages/model. */
427
+ rerun: (
428
+ ctx: RunMutationCtx,
429
+ args: { requestId: string; messages?: Message[]; model?: string }
430
+ ) => this.rerunImpl(ctx, args),
431
+ };
432
+ }
433
+
434
+ /** Per-user budgets and controls. */
435
+ get users() {
436
+ const c = this.component;
437
+ 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: (
454
+ 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),
460
+ };
461
+ }
462
+
463
+ /** Per-action (per-feature) budgets. */
464
+ 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
+ };
485
+ }
486
+
487
+ /** The deployment-wide budget and retention config. */
488
+ get global() {
489
+ const c = this.component;
490
+ return {
491
+ /** Limits + spend today/total. */
492
+ status: (ctx: RunQueryCtx) => ctx.runQuery(c.lib.getGlobalStatus, {}),
493
+ /** A killswitch spend cap across all users/actions (enforced approximately). */
494
+ setLimits: (
495
+ ctx: RunMutationCtx,
496
+ args: {
497
+ dailySpendLimitNanos?: number;
498
+ lifetimeSpendLimitNanos?: number;
499
+ enforcement?: "hard" | "soft";
500
+ }
501
+ ) => ctx.runMutation(c.lib.setGlobalLimits, args),
502
+ bump: (
503
+ ctx: RunMutationCtx,
504
+ args: { dailyNanos?: number; lifetimeNanos?: number }
505
+ ) => ctx.runMutation(c.lib.bumpGlobal, args),
506
+ /** Request-row retention window in ms (default 1h; 0 disables). */
507
+ setRetention: (ctx: RunMutationCtx, args: { retentionMs: number }) =>
508
+ ctx.runMutation(c.lib.setRetention, args),
509
+ };
510
+ }
511
+
512
+ /** Model allow/deny policy. */
513
+ get models() {
514
+ const c = this.component;
515
+ return {
516
+ getPolicy: (ctx: RunQueryCtx) => ctx.runQuery(c.lib.getModelPolicy, {}),
517
+ /** mode: "open" | "allowlist" (only these) | "denylist" (all but these). */
518
+ setPolicy: (
519
+ ctx: RunMutationCtx,
520
+ args: { mode: "open" | "allowlist" | "denylist"; models: string[] }
521
+ ) => ctx.runMutation(c.lib.setModelPolicy, args),
522
+ };
523
+ }
524
+
525
+ /** Per-model prices (cents per million tokens). */
526
+ get prices() {
527
+ const c = this.component;
528
+ return {
529
+ list: (ctx: RunQueryCtx) => ctx.runQuery(c.lib.listPrices, {}),
530
+ set: (
531
+ ctx: RunMutationCtx,
532
+ args: {
533
+ model: string;
534
+ inputNanosPerMTok: number;
535
+ outputNanosPerMTok: number;
536
+ }
537
+ ) => ctx.runMutation(c.lib.setPrice, args),
538
+ };
539
+ }
540
+ }
541
+
542
+ /** @deprecated Renamed to `AIBudget`. */
543
+ export const WorryFreeAI = AIBudget;
@@ -0,0 +1,54 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * Generated `api` utility.
4
+ *
5
+ * THIS CODE IS AUTOMATICALLY GENERATED.
6
+ *
7
+ * To regenerate, run `npx convex dev`.
8
+ * @module
9
+ */
10
+
11
+ import type * as crons from "../crons.js";
12
+ import type * as lib from "../lib.js";
13
+
14
+ import type {
15
+ ApiFromModules,
16
+ FilterApi,
17
+ FunctionReference,
18
+ } from "convex/server";
19
+ import { anyApi, componentsGeneric } from "convex/server";
20
+
21
+ const fullApi: ApiFromModules<{
22
+ crons: typeof crons;
23
+ lib: typeof lib;
24
+ }> = anyApi as any;
25
+
26
+ /**
27
+ * A utility for referencing Convex functions in your app's public API.
28
+ *
29
+ * Usage:
30
+ * ```js
31
+ * const myFunctionReference = api.myModule.myFunction;
32
+ * ```
33
+ */
34
+ export const api: FilterApi<
35
+ typeof fullApi,
36
+ FunctionReference<any, "public">
37
+ > = anyApi as any;
38
+
39
+ /**
40
+ * A utility for referencing Convex functions in your app's internal API.
41
+ *
42
+ * Usage:
43
+ * ```js
44
+ * const myFunctionReference = internal.myModule.myFunction;
45
+ * ```
46
+ */
47
+ export const internal: FilterApi<
48
+ typeof fullApi,
49
+ FunctionReference<any, "internal">
50
+ > = anyApi as any;
51
+
52
+ export const components = componentsGeneric() as unknown as {
53
+ shardedCounter: import("@convex-dev/sharded-counter/_generated/component.js").ComponentApi<"shardedCounter">;
54
+ };