@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,333 @@
1
+ import type { Expand, FunctionReference } from "convex/server";
2
+ import { type GenericId } from "convex/values";
3
+ import { type LanguageModel } from "ai";
4
+ import type { api } from "../component/_generated/api";
5
+ type OpaqueIds<T> = T extends GenericId<infer _T> ? string : T extends (infer U)[] ? OpaqueIds<U>[] : T extends ArrayBuffer ? ArrayBuffer : T extends object ? {
6
+ [K in keyof T]: OpaqueIds<T[K]>;
7
+ } : T;
8
+ type UseApi<API> = Expand<{
9
+ [mod in keyof API]: API[mod] extends FunctionReference<infer FType, "public", infer FArgs, infer FReturnType, infer FComponentPath> ? FunctionReference<FType, "internal", OpaqueIds<FArgs>, OpaqueIds<FReturnType>, FComponentPath> : UseApi<API[mod]>;
10
+ }>;
11
+ export type AIBudgetApi = UseApi<typeof api>;
12
+ /** @deprecated use AIBudgetApi */
13
+ export type AIGatewayApi = AIBudgetApi;
14
+ /** Fired when a request is admitted over a *soft* limit. */
15
+ export type SoftLimitInfo = {
16
+ userId: string;
17
+ action?: string;
18
+ requestId: string;
19
+ warnings: string[];
20
+ };
21
+ export type AIBudgetOptions = {
22
+ defaultModel?: string;
23
+ /**
24
+ * Called when a soft limit is exceeded (the request is still allowed). Lets
25
+ * you surface budget warnings even on the languageModel/Agent path, where
26
+ * they can't be returned. Errors thrown here are swallowed.
27
+ */
28
+ onSoftLimit?: (info: SoftLimitInfo) => void | Promise<void>;
29
+ };
30
+ type RunQueryCtx = {
31
+ runQuery: <Query extends FunctionReference<"query", "internal">>(query: Query, args: Query["_args"]) => Promise<Query["_returnType"]>;
32
+ };
33
+ type RunMutationCtx = RunQueryCtx & {
34
+ runMutation: <M extends FunctionReference<"mutation", "internal">>(mutation: M, args: M["_args"]) => Promise<M["_returnType"]>;
35
+ meta?: {
36
+ getFunctionMetadata(): Promise<{
37
+ name: string;
38
+ }>;
39
+ };
40
+ auth?: {
41
+ getUserIdentity(): Promise<{
42
+ subject?: string;
43
+ } | null>;
44
+ };
45
+ };
46
+ export type Message = {
47
+ role: string;
48
+ content: string;
49
+ };
50
+ export type ChatResult = {
51
+ text: string;
52
+ requestId: string;
53
+ costNanos: number;
54
+ promptTokens: number;
55
+ completionTokens: number;
56
+ cachedTokens: number;
57
+ /** Soft-limit warnings raised at admission (empty unless a soft cap was hit). */
58
+ warnings: string[];
59
+ };
60
+ export declare class AIBudget {
61
+ component: AIBudgetApi;
62
+ defaultModel: string;
63
+ private onSoftLimit?;
64
+ constructor(component: AIBudgetApi, options?: AIBudgetOptions);
65
+ private fireSoftLimit;
66
+ /**
67
+ * One-shot chat through the AI Gateway with tracking + limits.
68
+ * Call from an action. `userId` defaults to the authenticated caller.
69
+ */
70
+ chat(ctx: RunMutationCtx, args?: {
71
+ /** Whom to bill. Defaults to the authenticated user (ctx.auth). */
72
+ userId?: string;
73
+ prompt?: string;
74
+ messages?: Message[];
75
+ model?: string;
76
+ rerunOf?: string;
77
+ /** Attribute spend to this action name. Defaults to the calling Convex action. */
78
+ action?: string;
79
+ }): Promise<ChatResult>;
80
+ /**
81
+ * An AI SDK LanguageModel that enforces limits and records usage/cost for
82
+ * `userId` on every call. Drop it into `generateText`, `streamText`, or the
83
+ * Convex Agent component (`new Agent(components.agent, { languageModel })`).
84
+ * `userId` defaults to the authenticated caller (ctx.auth).
85
+ */
86
+ languageModel(ctx: RunMutationCtx, opts?: {
87
+ userId?: string;
88
+ model?: string;
89
+ action?: string;
90
+ }): LanguageModel;
91
+ private rerunImpl;
92
+ /** The request audit log, replay, and re-run lineage. */
93
+ get requests(): {
94
+ list: (ctx: RunQueryCtx, args?: {
95
+ userId?: string;
96
+ limit?: number;
97
+ }) => Promise<{
98
+ _id: string;
99
+ _creationTime: number;
100
+ actionName?: string | undefined;
101
+ estimatedNanos?: number | undefined;
102
+ estimatedTokens?: number | undefined;
103
+ unpricedModel?: boolean | undefined;
104
+ overBudget?: boolean | undefined;
105
+ settled?: boolean | undefined;
106
+ error?: string | undefined;
107
+ responseText?: string | undefined;
108
+ promptTokens?: number | undefined;
109
+ completionTokens?: number | undefined;
110
+ cachedTokens?: number | undefined;
111
+ costNanos?: number | undefined;
112
+ latencyMs?: number | undefined;
113
+ rerunOf?: string | undefined;
114
+ userId: string;
115
+ model: string;
116
+ messages: {
117
+ role: string;
118
+ content: string;
119
+ }[];
120
+ status: "blocked" | "pending" | "success" | "error";
121
+ }[]>;
122
+ /** Ancestors up to the original, plus direct re-runs. */
123
+ lineage: (ctx: RunQueryCtx, args: {
124
+ requestId: string;
125
+ }) => Promise<{
126
+ ancestors: {
127
+ _id: string;
128
+ _creationTime: number;
129
+ actionName?: string | undefined;
130
+ estimatedNanos?: number | undefined;
131
+ estimatedTokens?: number | undefined;
132
+ unpricedModel?: boolean | undefined;
133
+ overBudget?: boolean | undefined;
134
+ settled?: boolean | undefined;
135
+ error?: string | undefined;
136
+ responseText?: string | undefined;
137
+ promptTokens?: number | undefined;
138
+ completionTokens?: number | undefined;
139
+ cachedTokens?: number | undefined;
140
+ costNanos?: number | undefined;
141
+ latencyMs?: number | undefined;
142
+ rerunOf?: string | undefined;
143
+ userId: string;
144
+ model: string;
145
+ messages: {
146
+ role: string;
147
+ content: string;
148
+ }[];
149
+ status: "blocked" | "pending" | "success" | "error";
150
+ }[];
151
+ reruns: {
152
+ _id: string;
153
+ _creationTime: number;
154
+ actionName?: string | undefined;
155
+ estimatedNanos?: number | undefined;
156
+ estimatedTokens?: number | undefined;
157
+ unpricedModel?: boolean | undefined;
158
+ overBudget?: boolean | undefined;
159
+ settled?: boolean | undefined;
160
+ error?: string | undefined;
161
+ responseText?: string | undefined;
162
+ promptTokens?: number | undefined;
163
+ completionTokens?: number | undefined;
164
+ cachedTokens?: number | undefined;
165
+ costNanos?: number | undefined;
166
+ latencyMs?: number | undefined;
167
+ rerunOf?: string | undefined;
168
+ userId: string;
169
+ model: string;
170
+ messages: {
171
+ role: string;
172
+ content: string;
173
+ }[];
174
+ status: "blocked" | "pending" | "success" | "error";
175
+ }[];
176
+ }>;
177
+ /** Replay a stored request, optionally with edited messages/model. */
178
+ rerun: (ctx: RunMutationCtx, args: {
179
+ requestId: string;
180
+ messages?: Message[];
181
+ model?: string;
182
+ }) => Promise<ChatResult>;
183
+ };
184
+ /** Per-user budgets and controls. */
185
+ get users(): {
186
+ list: (ctx: RunQueryCtx) => Promise<{
187
+ spendTodayNanos: number;
188
+ _id: string;
189
+ _creationTime: number;
190
+ requestsPerMinute?: number | undefined;
191
+ dailySpendLimitNanos?: number | undefined;
192
+ lifetimeSpendLimitNanos?: number | undefined;
193
+ dailyTokenLimit?: number | undefined;
194
+ lifetimeTokenLimit?: number | undefined;
195
+ blocked?: boolean | undefined;
196
+ enforcement?: "hard" | "soft" | undefined;
197
+ dailyBumpNanos?: number | undefined;
198
+ lifetimeBumpNanos?: number | undefined;
199
+ bumpDayStamp?: string | undefined;
200
+ tokensToday?: number | undefined;
201
+ reservedTodayNanos?: number | undefined;
202
+ reservedTotalNanos?: number | undefined;
203
+ reservedTodayTokens?: number | undefined;
204
+ reservedTotalTokens?: number | undefined;
205
+ pendingCount?: number | undefined;
206
+ userId: string;
207
+ totalSpendNanos: number;
208
+ totalRequests: number;
209
+ totalTokens: number;
210
+ dayStamp: string;
211
+ }[]>;
212
+ setLimits: (ctx: RunMutationCtx, args: {
213
+ userId: string;
214
+ requestsPerMinute?: number;
215
+ dailySpendLimitNanos?: number;
216
+ lifetimeSpendLimitNanos?: number;
217
+ dailyTokenLimit?: number;
218
+ lifetimeTokenLimit?: number;
219
+ enforcement?: "hard" | "soft";
220
+ blocked?: boolean;
221
+ }) => Promise<null>;
222
+ /** One-time "approve another $X" bump (daily is today-only). */
223
+ bump: (ctx: RunMutationCtx, args: {
224
+ userId: string;
225
+ dailyNanos?: number;
226
+ lifetimeNanos?: number;
227
+ }) => Promise<null>;
228
+ /** Delete a user and all their request rows. */
229
+ delete: (ctx: RunMutationCtx, args: {
230
+ userId: string;
231
+ }) => Promise<{
232
+ deletedThisBatch: number;
233
+ done: boolean;
234
+ }>;
235
+ };
236
+ /** Per-action (per-feature) budgets. */
237
+ get actions(): {
238
+ list: (ctx: RunQueryCtx) => Promise<{
239
+ spendTodayNanos: number;
240
+ _id: string;
241
+ _creationTime: number;
242
+ dailySpendLimitNanos?: number | undefined;
243
+ lifetimeSpendLimitNanos?: number | undefined;
244
+ dailyTokenLimit?: number | undefined;
245
+ lifetimeTokenLimit?: number | undefined;
246
+ enforcement?: "hard" | "soft" | undefined;
247
+ dailyBumpNanos?: number | undefined;
248
+ lifetimeBumpNanos?: number | undefined;
249
+ bumpDayStamp?: string | undefined;
250
+ tokensToday?: number | undefined;
251
+ reservedTodayNanos?: number | undefined;
252
+ reservedTotalNanos?: number | undefined;
253
+ reservedTodayTokens?: number | undefined;
254
+ reservedTotalTokens?: number | undefined;
255
+ pendingCount?: number | undefined;
256
+ disabled?: boolean | undefined;
257
+ totalSpendNanos: number;
258
+ totalRequests: number;
259
+ totalTokens: number;
260
+ dayStamp: string;
261
+ name: string;
262
+ }[]>;
263
+ setLimits: (ctx: RunMutationCtx, args: {
264
+ name: string;
265
+ dailySpendLimitNanos?: number;
266
+ lifetimeSpendLimitNanos?: number;
267
+ dailyTokenLimit?: number;
268
+ lifetimeTokenLimit?: number;
269
+ enforcement?: "hard" | "soft";
270
+ disabled?: boolean;
271
+ }) => Promise<null>;
272
+ bump: (ctx: RunMutationCtx, args: {
273
+ name: string;
274
+ dailyNanos?: number;
275
+ lifetimeNanos?: number;
276
+ }) => Promise<null>;
277
+ };
278
+ /** The deployment-wide budget and retention config. */
279
+ get global(): {
280
+ /** Limits + spend today/total. */
281
+ status: (ctx: RunQueryCtx) => Promise<{
282
+ dailySpendLimitNanos: number | null;
283
+ lifetimeSpendLimitNanos: number | null;
284
+ enforcement: "hard" | "soft";
285
+ spentTodayNanos: number;
286
+ spentTotalNanos: number;
287
+ }>;
288
+ /** A killswitch spend cap across all users/actions (enforced approximately). */
289
+ setLimits: (ctx: RunMutationCtx, args: {
290
+ dailySpendLimitNanos?: number;
291
+ lifetimeSpendLimitNanos?: number;
292
+ enforcement?: "hard" | "soft";
293
+ }) => Promise<null>;
294
+ bump: (ctx: RunMutationCtx, args: {
295
+ dailyNanos?: number;
296
+ lifetimeNanos?: number;
297
+ }) => Promise<null>;
298
+ /** Request-row retention window in ms (default 1h; 0 disables). */
299
+ setRetention: (ctx: RunMutationCtx, args: {
300
+ retentionMs: number;
301
+ }) => Promise<null>;
302
+ };
303
+ /** Model allow/deny policy. */
304
+ get models(): {
305
+ getPolicy: (ctx: RunQueryCtx) => Promise<{
306
+ mode: "open" | "allowlist" | "denylist";
307
+ models: string[];
308
+ }>;
309
+ /** mode: "open" | "allowlist" (only these) | "denylist" (all but these). */
310
+ setPolicy: (ctx: RunMutationCtx, args: {
311
+ mode: "open" | "allowlist" | "denylist";
312
+ models: string[];
313
+ }) => Promise<null>;
314
+ };
315
+ /** Per-model prices (cents per million tokens). */
316
+ get prices(): {
317
+ list: (ctx: RunQueryCtx) => Promise<{
318
+ [x: string]: {
319
+ input: number;
320
+ output: number;
321
+ overridden: boolean;
322
+ };
323
+ }>;
324
+ set: (ctx: RunMutationCtx, args: {
325
+ model: string;
326
+ inputNanosPerMTok: number;
327
+ outputNanosPerMTok: number;
328
+ }) => Promise<null>;
329
+ };
330
+ }
331
+ /** @deprecated Renamed to `AIBudget`. */
332
+ export declare const WorryFreeAI: typeof AIBudget;
333
+ export {};
@@ -0,0 +1,348 @@
1
+ import { ConvexError } from "convex/values";
2
+ import { generateText, wrapLanguageModel } from "ai";
3
+ import { convexGateway } from "@convex-dev/ai-sdk-provider";
4
+ // The calling Convex action's name (e.g. "ai:sendMessage"), unless overridden.
5
+ async function resolveActionName(ctx, explicit) {
6
+ if (explicit !== undefined)
7
+ return explicit;
8
+ try {
9
+ return (await ctx.meta?.getFunctionMetadata())?.name;
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ }
15
+ // The user this call is billed to. If not passed explicitly, it's the
16
+ // authenticated caller (ctx.auth.getUserIdentity().subject) — so budgets are
17
+ // server-derived by default and can't be spoofed by a client-supplied id.
18
+ async function resolveUserId(ctx, explicit) {
19
+ if (explicit !== undefined)
20
+ return explicit;
21
+ const identity = await ctx.auth?.getUserIdentity?.();
22
+ if (identity?.subject)
23
+ return identity.subject;
24
+ throw new Error("ai-budget: no `userId` was passed and there is no authenticated user " +
25
+ "(ctx.auth.getUserIdentity() returned null). Either authenticate the " +
26
+ "request or pass an explicit `userId`.");
27
+ }
28
+ // ---------- helpers ----------
29
+ // Token counts across AI SDK versions come as plain numbers or, in v7, as a
30
+ // structured breakdown like { reasoning, text, total }. Coerce either to a number.
31
+ function toTokenCount(x) {
32
+ if (typeof x === "number")
33
+ return Number.isFinite(x) ? x : 0;
34
+ if (x && typeof x === "object")
35
+ return toTokenCount(x.total ?? x.text ?? 0);
36
+ return 0;
37
+ }
38
+ function extractUsage(usage) {
39
+ return {
40
+ promptTokens: toTokenCount(usage?.inputTokens ?? usage?.promptTokens),
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 ??
45
+ usage?.promptTokensDetails?.cachedTokens ??
46
+ usage?.prompt_tokens_details?.cached_tokens ??
47
+ usage?.cached_tokens),
48
+ };
49
+ }
50
+ // Flatten an AI SDK prompt (roles + content parts) into simple storable messages.
51
+ function simplifyPrompt(prompt) {
52
+ if (!Array.isArray(prompt))
53
+ return [];
54
+ return prompt.map((m) => {
55
+ let content;
56
+ if (typeof m.content === "string") {
57
+ content = m.content;
58
+ }
59
+ else if (Array.isArray(m.content)) {
60
+ content = m.content
61
+ .map((part) => part?.type === "text" ? part.text : JSON.stringify(part))
62
+ .join("");
63
+ }
64
+ else {
65
+ content = JSON.stringify(m.content);
66
+ }
67
+ return { role: String(m.role), content };
68
+ });
69
+ }
70
+ function extractText(result) {
71
+ if (typeof result?.text === "string")
72
+ return result.text;
73
+ if (Array.isArray(result?.content)) {
74
+ return result.content
75
+ .filter((p) => p?.type === "text")
76
+ .map((p) => p.text)
77
+ .join("");
78
+ }
79
+ return "";
80
+ }
81
+ // ---------- client ----------
82
+ export class AIBudget {
83
+ component;
84
+ defaultModel;
85
+ onSoftLimit;
86
+ constructor(component, options) {
87
+ this.component = component;
88
+ this.defaultModel = options?.defaultModel ?? "openai/gpt-4o-mini";
89
+ this.onSoftLimit = options?.onSoftLimit;
90
+ }
91
+ async fireSoftLimit(info) {
92
+ if (info.warnings.length === 0 || !this.onSoftLimit)
93
+ return;
94
+ try {
95
+ await this.onSoftLimit(info);
96
+ }
97
+ catch {
98
+ // never let a callback error break a request
99
+ }
100
+ }
101
+ /**
102
+ * One-shot chat through the AI Gateway with tracking + limits.
103
+ * Call from an action. `userId` defaults to the authenticated caller.
104
+ */
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 ?? "" }];
110
+ const started = await ctx.runMutation(this.component.lib.startRequest, {
111
+ userId,
112
+ actionName,
113
+ model,
114
+ messages,
115
+ rerunOf: args.rerunOf,
116
+ });
117
+ if (!started.allowed) {
118
+ throw new ConvexError({
119
+ kind: "AIBudgetLimit",
120
+ code: started.code,
121
+ reason: started.reason,
122
+ });
123
+ }
124
+ const requestId = started.requestId;
125
+ const warnings = started.warnings;
126
+ await this.fireSoftLimit({ userId, action: actionName, requestId, warnings });
127
+ const start = Date.now();
128
+ 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);
142
+ const { costNanos } = await ctx.runMutation(this.component.lib.finishRequest, {
143
+ requestId,
144
+ responseText: result.text,
145
+ ...usage,
146
+ latencyMs: Date.now() - start,
147
+ });
148
+ return { text: result.text, requestId, costNanos, warnings, ...usage };
149
+ }
150
+ catch (e) {
151
+ await ctx.runMutation(this.component.lib.finishRequest, {
152
+ requestId,
153
+ error: String(e),
154
+ latencyMs: Date.now() - start,
155
+ });
156
+ throw e;
157
+ }
158
+ }
159
+ /**
160
+ * An AI SDK LanguageModel that enforces limits and records usage/cost for
161
+ * `userId` on every call. Drop it into `generateText`, `streamText`, or the
162
+ * Convex Agent component (`new Agent(components.agent, { languageModel })`).
163
+ * `userId` defaults to the authenticated caller (ctx.auth).
164
+ */
165
+ languageModel(ctx, opts = {}) {
166
+ const modelId = opts.model ?? this.defaultModel;
167
+ const component = this.component;
168
+ const fireSoftLimit = this.fireSoftLimit.bind(this);
169
+ const begin = async (params) => {
170
+ const userId = await resolveUserId(ctx, opts.userId);
171
+ const actionName = await resolveActionName(ctx, opts.action);
172
+ const started = await ctx.runMutation(component.lib.startRequest, {
173
+ userId,
174
+ actionName,
175
+ model: modelId,
176
+ messages: simplifyPrompt(params.prompt),
177
+ });
178
+ if (!started.allowed) {
179
+ throw new ConvexError({
180
+ kind: "AIBudgetLimit",
181
+ code: started.code,
182
+ reason: started.reason,
183
+ });
184
+ }
185
+ await fireSoftLimit({
186
+ userId,
187
+ action: actionName,
188
+ requestId: started.requestId,
189
+ warnings: started.warnings,
190
+ });
191
+ return started.requestId;
192
+ };
193
+ const finish = async (requestId, fields) => ctx.runMutation(component.lib.finishRequest, { requestId, ...fields });
194
+ return wrapLanguageModel({
195
+ model: convexGateway(modelId),
196
+ middleware: {
197
+ wrapGenerate: async ({ doGenerate, params }) => {
198
+ const requestId = await begin(params);
199
+ const start = Date.now();
200
+ try {
201
+ const result = await doGenerate();
202
+ await finish(requestId, {
203
+ responseText: extractText(result),
204
+ ...extractUsage(result.usage),
205
+ latencyMs: Date.now() - start,
206
+ });
207
+ return result;
208
+ }
209
+ catch (e) {
210
+ await finish(requestId, {
211
+ error: String(e),
212
+ latencyMs: Date.now() - start,
213
+ });
214
+ throw e;
215
+ }
216
+ },
217
+ wrapStream: async ({ doStream, params }) => {
218
+ const requestId = await begin(params);
219
+ const start = Date.now();
220
+ let text = "";
221
+ let usage = undefined;
222
+ try {
223
+ const result = await doStream();
224
+ // finishRequest is idempotent (terminal-guarded server-side), so
225
+ // settling from multiple stream outcomes — normal close, an error
226
+ // chunk, or a cancel — is safe: the first wins, the rest no-op.
227
+ // Without this an errored or abandoned stream would never settle and
228
+ // its real usage would be lost (recorded as free by the reconciler).
229
+ let settled = false;
230
+ const settle = (error) => {
231
+ if (settled)
232
+ return;
233
+ settled = true;
234
+ return finish(requestId, {
235
+ responseText: text,
236
+ error,
237
+ ...extractUsage(usage),
238
+ latencyMs: Date.now() - start,
239
+ });
240
+ };
241
+ const tapped = result.stream.pipeThrough(new TransformStream({
242
+ transform(chunk, controller) {
243
+ if (chunk?.type === "text-delta") {
244
+ text += chunk.delta ?? chunk.textDelta ?? "";
245
+ }
246
+ if (chunk?.type === "finish")
247
+ usage = chunk.usage;
248
+ if (chunk?.type === "error")
249
+ void settle(String(chunk.error));
250
+ controller.enqueue(chunk);
251
+ },
252
+ async flush() {
253
+ await settle();
254
+ },
255
+ }));
256
+ return { ...result, stream: tapped };
257
+ }
258
+ catch (e) {
259
+ await finish(requestId, {
260
+ error: String(e),
261
+ latencyMs: Date.now() - start,
262
+ });
263
+ throw e;
264
+ }
265
+ },
266
+ },
267
+ });
268
+ }
269
+ async rerunImpl(ctx, args) {
270
+ const original = await ctx.runQuery(this.component.lib.getRequest, {
271
+ requestId: args.requestId,
272
+ });
273
+ if (!original)
274
+ throw new Error("Unknown request");
275
+ return this.chat(ctx, {
276
+ userId: original.userId,
277
+ model: args.model ?? original.model,
278
+ messages: args.messages ?? original.messages,
279
+ rerunOf: args.requestId,
280
+ action: original.actionName,
281
+ });
282
+ }
283
+ // ---------- namespaced admin API ----------
284
+ /** The request audit log, replay, and re-run lineage. */
285
+ get requests() {
286
+ const c = this.component;
287
+ return {
288
+ list: (ctx, args = {}) => ctx.runQuery(c.lib.listRequests, args),
289
+ /** Ancestors up to the original, plus direct re-runs. */
290
+ lineage: (ctx, args) => ctx.runQuery(c.lib.lineage, { requestId: args.requestId }),
291
+ /** Replay a stored request, optionally with edited messages/model. */
292
+ rerun: (ctx, args) => this.rerunImpl(ctx, args),
293
+ };
294
+ }
295
+ /** Per-user budgets and controls. */
296
+ get users() {
297
+ const c = this.component;
298
+ 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),
305
+ };
306
+ }
307
+ /** Per-action (per-feature) budgets. */
308
+ 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
+ };
315
+ }
316
+ /** The deployment-wide budget and retention config. */
317
+ get global() {
318
+ const c = this.component;
319
+ return {
320
+ /** Limits + spend today/total. */
321
+ status: (ctx) => ctx.runQuery(c.lib.getGlobalStatus, {}),
322
+ /** A killswitch spend cap across all users/actions (enforced approximately). */
323
+ setLimits: (ctx, args) => ctx.runMutation(c.lib.setGlobalLimits, args),
324
+ bump: (ctx, args) => ctx.runMutation(c.lib.bumpGlobal, args),
325
+ /** Request-row retention window in ms (default 1h; 0 disables). */
326
+ setRetention: (ctx, args) => ctx.runMutation(c.lib.setRetention, args),
327
+ };
328
+ }
329
+ /** Model allow/deny policy. */
330
+ get models() {
331
+ const c = this.component;
332
+ return {
333
+ getPolicy: (ctx) => ctx.runQuery(c.lib.getModelPolicy, {}),
334
+ /** mode: "open" | "allowlist" (only these) | "denylist" (all but these). */
335
+ setPolicy: (ctx, args) => ctx.runMutation(c.lib.setModelPolicy, args),
336
+ };
337
+ }
338
+ /** Per-model prices (cents per million tokens). */
339
+ get prices() {
340
+ const c = this.component;
341
+ return {
342
+ list: (ctx) => ctx.runQuery(c.lib.listPrices, {}),
343
+ set: (ctx, args) => ctx.runMutation(c.lib.setPrice, args),
344
+ };
345
+ }
346
+ }
347
+ /** @deprecated Renamed to `AIBudget`. */
348
+ export const WorryFreeAI = AIBudget;