@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.
@@ -24,31 +24,43 @@ import type { FunctionReference } from "convex/server";
24
24
  export type ComponentApi<Name extends string | undefined = string | undefined> =
25
25
  {
26
26
  lib: {
27
- bumpAction: FunctionReference<
27
+ adjustBucket: FunctionReference<
28
28
  "mutation",
29
29
  "internal",
30
- { dailyNanos?: number; lifetimeNanos?: number; name: string },
30
+ {
31
+ deltaNanos: number;
32
+ dimension: string;
33
+ reason?: string;
34
+ tokens?: number;
35
+ value: string;
36
+ },
31
37
  null,
32
38
  Name
33
39
  >;
34
- bumpGlobal: FunctionReference<
40
+ bumpBucket: FunctionReference<
35
41
  "mutation",
36
42
  "internal",
37
- { dailyNanos?: number; lifetimeNanos?: number },
43
+ {
44
+ dailyNanos?: number;
45
+ dimension: string;
46
+ lifetimeNanos?: number;
47
+ monthlyNanos?: number;
48
+ value: string;
49
+ },
38
50
  null,
39
51
  Name
40
52
  >;
41
- bumpUser: FunctionReference<
53
+ bumpGlobal: FunctionReference<
42
54
  "mutation",
43
55
  "internal",
44
- { dailyNanos?: number; lifetimeNanos?: number; userId: string },
56
+ { dailyNanos?: number; lifetimeNanos?: number; monthlyNanos?: number },
45
57
  null,
46
58
  Name
47
59
  >;
48
- deleteUser: FunctionReference<
60
+ deleteBucket: FunctionReference<
49
61
  "mutation",
50
62
  "internal",
51
- { userId: string },
63
+ { dimension: string; value: string },
52
64
  { deletedThisBatch: number; done: boolean },
53
65
  Name
54
66
  >;
@@ -58,23 +70,34 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
58
70
  {
59
71
  cachedTokens?: number;
60
72
  completionTokens?: number;
73
+ costNanos?: number;
61
74
  error?: string;
62
75
  latencyMs?: number;
63
76
  promptTokens?: number;
64
77
  requestId: string;
65
78
  responseText?: string;
79
+ serverToolUses?: Record<string, number>;
66
80
  },
67
81
  { costNanos: number },
68
82
  Name
69
83
  >;
84
+ getBucket: FunctionReference<
85
+ "query",
86
+ "internal",
87
+ { dimension: string; value: string },
88
+ any,
89
+ Name
90
+ >;
70
91
  getGlobalStatus: FunctionReference<
71
92
  "query",
72
93
  "internal",
73
94
  {},
74
95
  {
75
96
  dailySpendLimitNanos: number | null;
97
+ defaultWarnAtPct: number | null;
76
98
  enforcement: "hard" | "soft";
77
99
  lifetimeSpendLimitNanos: number | null;
100
+ retentionMs: number | null;
78
101
  spentTodayNanos: number;
79
102
  spentTotalNanos: number;
80
103
  },
@@ -101,54 +124,70 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
101
124
  any,
102
125
  Name
103
126
  >;
104
- listActions: FunctionReference<"query", "internal", {}, any, Name>;
127
+ listAdjustments: FunctionReference<
128
+ "query",
129
+ "internal",
130
+ { dimension: string; limit?: number; value: string },
131
+ any,
132
+ Name
133
+ >;
134
+ listBuckets: FunctionReference<
135
+ "query",
136
+ "internal",
137
+ { dimension?: string },
138
+ any,
139
+ Name
140
+ >;
105
141
  listPrices: FunctionReference<"query", "internal", {}, any, Name>;
106
142
  listRequests: FunctionReference<
107
143
  "query",
108
144
  "internal",
109
- { limit?: number; userId?: string },
145
+ { dimension?: string; limit?: number; userId?: string; value?: string },
146
+ any,
147
+ Name
148
+ >;
149
+ listServerToolPrices: FunctionReference<
150
+ "query",
151
+ "internal",
152
+ {},
110
153
  any,
111
154
  Name
112
155
  >;
113
- listUsers: FunctionReference<"query", "internal", {}, any, Name>;
114
- setActionLimits: FunctionReference<
156
+ setAlertDefaults: FunctionReference<
115
157
  "mutation",
116
158
  "internal",
117
- {
118
- dailySpendLimitNanos?: number;
119
- dailyTokenLimit?: number;
120
- disabled?: boolean;
121
- enforcement?: "hard" | "soft";
122
- lifetimeSpendLimitNanos?: number;
123
- lifetimeTokenLimit?: number;
124
- name: string;
125
- },
159
+ { warnAtPct?: number },
126
160
  null,
127
161
  Name
128
162
  >;
129
- setGlobalLimits: FunctionReference<
163
+ setBucketLimits: FunctionReference<
130
164
  "mutation",
131
165
  "internal",
132
166
  {
167
+ blocked?: boolean;
133
168
  dailySpendLimitNanos?: number;
169
+ dailyTokenLimit?: number;
170
+ dimension: string;
134
171
  enforcement?: "hard" | "soft";
135
172
  lifetimeSpendLimitNanos?: number;
173
+ lifetimeTokenLimit?: number;
174
+ maxConcurrent?: number;
175
+ monthlySpendLimitNanos?: number;
176
+ monthlyTokenLimit?: number;
177
+ requestsPerMinute?: number;
178
+ value: string;
179
+ warnAtPct?: number;
136
180
  },
137
181
  null,
138
182
  Name
139
183
  >;
140
- setLimits: FunctionReference<
184
+ setGlobalLimits: FunctionReference<
141
185
  "mutation",
142
186
  "internal",
143
187
  {
144
- blocked?: boolean;
145
188
  dailySpendLimitNanos?: number;
146
- dailyTokenLimit?: number;
147
189
  enforcement?: "hard" | "soft";
148
190
  lifetimeSpendLimitNanos?: number;
149
- lifetimeTokenLimit?: number;
150
- requestsPerMinute?: number;
151
- userId: string;
152
191
  },
153
192
  null,
154
193
  Name
@@ -164,6 +203,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
164
203
  "mutation",
165
204
  "internal",
166
205
  {
206
+ cachedNanosPerMTok?: number;
167
207
  inputNanosPerMTok: number;
168
208
  model: string;
169
209
  outputNanosPerMTok: number;
@@ -178,6 +218,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
178
218
  null,
179
219
  Name
180
220
  >;
221
+ setServerToolPrice: FunctionReference<
222
+ "mutation",
223
+ "internal",
224
+ { nanosPerCall: number; tool: string },
225
+ null,
226
+ Name
227
+ >;
181
228
  startRequest: FunctionReference<
182
229
  "mutation",
183
230
  "internal",
@@ -186,11 +233,29 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
186
233
  messages: Array<{ content: string; role: string }>;
187
234
  model: string;
188
235
  rerunOf?: string;
236
+ tags?: Array<{ dimension: string; value: string }>;
189
237
  userId: string;
190
238
  },
191
- | { allowed: true; requestId: string; warnings: Array<string> }
239
+ | {
240
+ allowed: true;
241
+ notices: Array<string>;
242
+ requestId: string;
243
+ warnings: Array<string>;
244
+ }
192
245
  | { allowed: false; code: string; reason: string },
193
246
  Name
194
247
  >;
248
+ usageHistory: FunctionReference<
249
+ "query",
250
+ "internal",
251
+ {
252
+ dimension: string;
253
+ limit?: number;
254
+ period: "day" | "month";
255
+ value: string;
256
+ },
257
+ any,
258
+ Name
259
+ >;
195
260
  };
196
261
  };
@@ -32,18 +32,24 @@ async function settle(t: any, requestId: any, p = 10, c = 5) {
32
32
  await t.finishAllScheduledFunctions(vi.runAllTimers);
33
33
  vi.useRealTimers();
34
34
  }
35
- const userOf = async (t: any, userId: string) =>
36
- (await t.query(api.lib.listUsers, {})).find((u: any) => u.userId === userId);
35
+ const setUserLimits = (t: any, userId: string, limits: any) =>
36
+ t.mutation(api.lib.setBucketLimits, {
37
+ dimension: "user",
38
+ value: userId,
39
+ ...limits,
40
+ });
41
+ const bucketOf = async (t: any, dimension: string, value: string) =>
42
+ (await t.query(api.lib.listBuckets, { dimension })).find(
43
+ (b: any) => b.value === value
44
+ );
45
+ const userOf = (t: any, userId: string) => bucketOf(t, "user", userId);
37
46
 
38
47
  describe("reserve / settle spend caps", () => {
39
48
  test("a daily cap below one request's reservation blocks up front", async () => {
40
49
  const t = convexTest(schema, modules);
41
50
  // one gpt-4o-mini request reserves ~480_000 nanodollars ($0.00048); a
42
51
  // 1_000-nano ($0.000001) cap can't fit it.
43
- await t.mutation(api.lib.setLimits, {
44
- userId: "u",
45
- dailySpendLimitNanos: 1_000,
46
- });
52
+ await setUserLimits(t, "u", { dailySpendLimitNanos: 1_000 });
47
53
  const r = await start(t, { userId: "u" });
48
54
  expect(r.allowed).toBe(false);
49
55
  expect(r.code).toBe("user_daily_spend_limit");
@@ -51,10 +57,7 @@ describe("reserve / settle spend caps", () => {
51
57
 
52
58
  test("reservation is released and settled to the real cost", async () => {
53
59
  const t = convexTest(schema, modules);
54
- await t.mutation(api.lib.setLimits, {
55
- userId: "u",
56
- dailySpendLimitNanos: 1_000_000_000, // $1/day
57
- });
60
+ await setUserLimits(t, "u", { dailySpendLimitNanos: 1_000_000_000 }); // $1/day
58
61
  const r = await start(t, { userId: "u" });
59
62
  expect(r.allowed).toBe(true);
60
63
  await settle(t, r.requestId, 1_000_000, 1_000_000); // 1M in, 1M out
@@ -67,6 +70,242 @@ describe("reserve / settle spend caps", () => {
67
70
  });
68
71
  });
69
72
 
73
+ async function settleWith(t: any, requestId: any, fields: any) {
74
+ await t.mutation(api.lib.finishRequest, { requestId, ...fields });
75
+ vi.useFakeTimers();
76
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
77
+ vi.useRealTimers();
78
+ }
79
+
80
+ describe("monthly budgets", () => {
81
+ test("a tiny monthly cap blocks up front", async () => {
82
+ const t = convexTest(schema, modules);
83
+ await setUserLimits(t, "u", { monthlySpendLimitNanos: 1_000 });
84
+ const r = await start(t, { userId: "u" });
85
+ expect(r.allowed).toBe(false);
86
+ expect(r.code).toBe("user_monthly_spend_limit");
87
+ });
88
+ });
89
+
90
+ describe("cache-aware pricing", () => {
91
+ test("cached prompt tokens are billed at the discount, not full input", async () => {
92
+ const t = convexTest(schema, modules);
93
+ const r = await start(t, { userId: "u" });
94
+ // 1M prompt, ALL cached, 0 completion. gpt-4o-mini input $0.15/Mtok; the
95
+ // cache default is 10% of input → 0.1 * 150_000_000 = 15_000_000 nano.
96
+ await settleWith(t, r.requestId, {
97
+ promptTokens: 1_000_000,
98
+ completionTokens: 0,
99
+ cachedTokens: 1_000_000,
100
+ });
101
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
102
+ expect(req.costNanos).toBe(15_000_000);
103
+ expect(req.cachedTokens).toBe(1_000_000);
104
+ });
105
+
106
+ test("an authoritative gateway cost overrides the token estimate", async () => {
107
+ const t = convexTest(schema, modules);
108
+ const r = await start(t, { userId: "u" });
109
+ await settleWith(t, r.requestId, {
110
+ promptTokens: 1_000_000,
111
+ completionTokens: 1_000_000,
112
+ costNanos: 12_345,
113
+ });
114
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
115
+ expect(req.costNanos).toBe(12_345);
116
+ });
117
+ });
118
+
119
+ describe("server-tool pricing", () => {
120
+ test("server-tool uses add a per-call fee on top of tokens", async () => {
121
+ const t = convexTest(schema, modules);
122
+ const r = await start(t, { userId: "u" });
123
+ // 0 tokens; 3 web searches at the $0.01 default = 30_000_000 nano.
124
+ await settleWith(t, r.requestId, {
125
+ promptTokens: 0,
126
+ completionTokens: 0,
127
+ serverToolUses: { web_search: 3 },
128
+ });
129
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
130
+ expect(req.costNanos).toBe(30_000_000);
131
+ expect(req.serverToolUses).toEqual({ web_search: 3 });
132
+ });
133
+
134
+ test("an override price is applied", async () => {
135
+ const t = convexTest(schema, modules);
136
+ await t.mutation(api.lib.setServerToolPrice, {
137
+ tool: "web_search",
138
+ nanosPerCall: 12_000_000,
139
+ });
140
+ const r = await start(t, { userId: "u" });
141
+ await settleWith(t, r.requestId, {
142
+ promptTokens: 0,
143
+ completionTokens: 0,
144
+ serverToolUses: { web_search: 2 },
145
+ });
146
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
147
+ expect(req.costNanos).toBe(24_000_000);
148
+ });
149
+
150
+ test("an authoritative cost already includes tool fees (not double-charged)", async () => {
151
+ const t = convexTest(schema, modules);
152
+ const r = await start(t, { userId: "u" });
153
+ await settleWith(t, r.requestId, {
154
+ promptTokens: 1_000_000,
155
+ completionTokens: 0,
156
+ serverToolUses: { web_search: 5 },
157
+ costNanos: 999,
158
+ });
159
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
160
+ expect(req.costNanos).toBe(999);
161
+ });
162
+ });
163
+
164
+ describe("durable usage history", () => {
165
+ test("settled spend lands in a per-day usage row", async () => {
166
+ const t = convexTest(schema, modules);
167
+ const r = await start(t, { userId: "u" });
168
+ await settleWith(t, r.requestId, { promptTokens: 1_000_000, completionTokens: 1_000_000 });
169
+ const hist = await t.query(api.lib.usageHistory, {
170
+ dimension: "user",
171
+ value: "u",
172
+ period: "day",
173
+ });
174
+ expect(hist.length).toBe(1);
175
+ expect(hist[0].spendNanos).toBe(750_000_000); // $0.75
176
+ expect(hist[0].requests).toBe(1);
177
+ });
178
+ });
179
+
180
+ describe("manual adjustments", () => {
181
+ test("a credit reduces spend and is logged", async () => {
182
+ const t = convexTest(schema, modules);
183
+ const r = await start(t, { userId: "u" });
184
+ await settleWith(t, r.requestId, { promptTokens: 1_000_000, completionTokens: 1_000_000 });
185
+ await t.mutation(api.lib.adjustBucket, {
186
+ dimension: "user",
187
+ value: "u",
188
+ deltaNanos: -250_000_000,
189
+ reason: "goodwill credit",
190
+ });
191
+ const u = await userOf(t, "u");
192
+ expect(u.totalSpendNanos).toBe(500_000_000); // 750M - 250M
193
+ const log = await t.query(api.lib.listAdjustments, { dimension: "user", value: "u" });
194
+ expect(log.length).toBe(1);
195
+ expect(log[0].deltaNanos).toBe(-250_000_000);
196
+ });
197
+ });
198
+
199
+ describe("threshold alerts", () => {
200
+ test("crossing warnAtPct returns a notice but still admits", async () => {
201
+ const t = convexTest(schema, modules);
202
+ // One "hi" estimate is ~480_150 nano. Cap 800_000, warn at 50% (400_000).
203
+ await setUserLimits(t, "u", { dailySpendLimitNanos: 800_000, warnAtPct: 0.5 });
204
+ const r = await start(t, { userId: "u" });
205
+ expect(r.allowed).toBe(true);
206
+ expect(r.notices.length).toBeGreaterThan(0);
207
+ });
208
+ });
209
+
210
+ describe("concurrency cap", () => {
211
+ test("maxConcurrent blocks a second in-flight request", async () => {
212
+ const t = convexTest(schema, modules);
213
+ await setUserLimits(t, "u", { maxConcurrent: 1 });
214
+ const first = await start(t, { userId: "u" });
215
+ expect(first.allowed).toBe(true); // reserved, still pending
216
+ const second = await start(t, { userId: "u" });
217
+ expect(second.allowed).toBe(false);
218
+ expect(second.code).toBe("user_max_concurrent");
219
+ });
220
+ });
221
+
222
+ describe("per-bucket rate limits", () => {
223
+ test("the existing user rate limit remains compatible", async () => {
224
+ const t = convexTest(schema, modules);
225
+ await setUserLimits(t, "u", { requestsPerMinute: 1 });
226
+ const first = await start(t, { userId: "u" });
227
+ expect(first.allowed).toBe(true);
228
+ const second = await start(t, { userId: "u" });
229
+ expect(second.allowed).toBe(false);
230
+ expect(second.code).toBe("rate_limit");
231
+ });
232
+
233
+ test("an action rate limit blocks the next request for that action", async () => {
234
+ const t = convexTest(schema, modules);
235
+ await t.mutation(api.lib.setBucketLimits, {
236
+ dimension: "action",
237
+ value: "ai:summarize",
238
+ requestsPerMinute: 1,
239
+ });
240
+ const first = await start(t, { userId: "u1", actionName: "ai:summarize" });
241
+ expect(first.allowed).toBe(true);
242
+ const second = await start(t, { userId: "u2", actionName: "ai:summarize" });
243
+ expect(second.allowed).toBe(false);
244
+ expect(second.code).toBe("action_rate_limit");
245
+ });
246
+
247
+ test("a custom-tag rate limit blocks the next request for that value", async () => {
248
+ const t = convexTest(schema, modules);
249
+ await t.mutation(api.lib.setBucketLimits, {
250
+ dimension: "customer",
251
+ value: "acme",
252
+ requestsPerMinute: 1,
253
+ });
254
+ const tags = [{ dimension: "customer", value: "acme" }];
255
+ const first = await start(t, { userId: "u1", tags });
256
+ expect(first.allowed).toBe(true);
257
+ const second = await start(t, { userId: "u2", tags });
258
+ expect(second.allowed).toBe(false);
259
+ expect(second.code).toBe("customer_rate_limit");
260
+ });
261
+ });
262
+
263
+ describe("tag-filtered request log", () => {
264
+ test("listRequests filters by a custom tag dimension", async () => {
265
+ const t = convexTest(schema, modules);
266
+ await start(t, { userId: "u", tags: [{ dimension: "customer", value: "acme" }] });
267
+ await start(t, { userId: "u", tags: [{ dimension: "customer", value: "globex" }] });
268
+ const acme = await t.query(api.lib.listRequests, {
269
+ dimension: "customer",
270
+ value: "acme",
271
+ });
272
+ expect(acme.length).toBe(1);
273
+ expect(acme[0].userId).toBe("u");
274
+ });
275
+ });
276
+
277
+ describe("tagged attribution buckets", () => {
278
+ test("a cap on a custom tag blocks, and settlement accrues to every bucket", async () => {
279
+ const t = convexTest(schema, modules);
280
+ // A tiny cap on customer "acme" — the user is uncapped.
281
+ await t.mutation(api.lib.setBucketLimits, {
282
+ dimension: "customer",
283
+ value: "acme",
284
+ dailySpendLimitNanos: 1_000,
285
+ });
286
+ const blocked = await start(t, {
287
+ userId: "u",
288
+ tags: [{ dimension: "customer", value: "acme" }],
289
+ });
290
+ expect(blocked.allowed).toBe(false);
291
+ expect(blocked.code).toBe("customer_daily_spend_limit");
292
+
293
+ // A different customer with no cap goes through, and the spend lands on
294
+ // BOTH the user bucket and the customer bucket.
295
+ const ok = await start(t, {
296
+ userId: "u",
297
+ tags: [{ dimension: "customer", value: "globex" }],
298
+ });
299
+ expect(ok.allowed).toBe(true);
300
+ await settle(t, ok.requestId, 1_000_000, 1_000_000); // $0.75
301
+ const user = await userOf(t, "u");
302
+ const cust = await bucketOf(t, "customer", "globex");
303
+ expect(user.totalSpendNanos).toBe(750_000_000);
304
+ expect(cust.totalSpendNanos).toBe(750_000_000);
305
+ expect(cust.totalRequests).toBe(1);
306
+ });
307
+ });
308
+
70
309
  describe("D-00 exactly-once settlement", () => {
71
310
  test("a duplicate finishRequest does not double-count", async () => {
72
311
  const t = convexTest(schema, modules);
@@ -85,7 +324,7 @@ describe("D-00 exactly-once settlement", () => {
85
324
  describe("token quotas", () => {
86
325
  test("a tiny daily token cap blocks (estimate exceeds it)", async () => {
87
326
  const t = convexTest(schema, modules);
88
- await t.mutation(api.lib.setLimits, { userId: "u", dailyTokenLimit: 10 });
327
+ await setUserLimits(t, "u", { dailyTokenLimit: 10 });
89
328
  const r = await start(t, { userId: "u" });
90
329
  expect(r.allowed).toBe(false);
91
330
  expect(r.code).toBe("user_daily_token_limit");
@@ -95,15 +334,14 @@ describe("token quotas", () => {
95
334
  describe("soft enforcement", () => {
96
335
  test("over a soft budget: allowed, warned, flagged overBudget", async () => {
97
336
  const t = convexTest(schema, modules);
98
- await t.mutation(api.lib.setLimits, {
99
- userId: "u",
337
+ await setUserLimits(t, "u", {
100
338
  dailySpendLimitNanos: 1, // 1 nanodollar — one estimate blows past it
101
339
  enforcement: "soft",
102
340
  });
103
341
  const r = await start(t, { userId: "u" });
104
342
  expect(r.allowed).toBe(true);
105
343
  expect(r.warnings.length).toBeGreaterThan(0);
106
- const req = await t.query(api.lib.getRequest, { requestId: r.requestId });
344
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
107
345
  expect(req.overBudget).toBe(true);
108
346
  });
109
347
  });
@@ -145,7 +383,7 @@ describe("F-04 fail-closed pricing", () => {
145
383
  const u = await userOf(t, "u");
146
384
  // conservative = max over table = {$3 in, $15 out}/Mtok => $18 = 18e9 nano.
147
385
  expect(u.totalSpendNanos).toBe(18_000_000_000);
148
- const req = await t.query(api.lib.getRequest, { requestId: r.requestId });
386
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
149
387
  expect(req.unpricedModel).toBe(true);
150
388
  });
151
389
  });