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

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,6 +70,7 @@ 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;
@@ -67,14 +80,23 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
67
80
  { costNanos: number },
68
81
  Name
69
82
  >;
83
+ getBucket: FunctionReference<
84
+ "query",
85
+ "internal",
86
+ { dimension: string; value: string },
87
+ any,
88
+ Name
89
+ >;
70
90
  getGlobalStatus: FunctionReference<
71
91
  "query",
72
92
  "internal",
73
93
  {},
74
94
  {
75
95
  dailySpendLimitNanos: number | null;
96
+ defaultWarnAtPct: number | null;
76
97
  enforcement: "hard" | "soft";
77
98
  lifetimeSpendLimitNanos: number | null;
99
+ retentionMs: number | null;
78
100
  spentTodayNanos: number;
79
101
  spentTotalNanos: number;
80
102
  },
@@ -101,54 +123,63 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
101
123
  any,
102
124
  Name
103
125
  >;
104
- listActions: FunctionReference<"query", "internal", {}, any, Name>;
126
+ listAdjustments: FunctionReference<
127
+ "query",
128
+ "internal",
129
+ { dimension: string; limit?: number; value: string },
130
+ any,
131
+ Name
132
+ >;
133
+ listBuckets: FunctionReference<
134
+ "query",
135
+ "internal",
136
+ { dimension?: string },
137
+ any,
138
+ Name
139
+ >;
105
140
  listPrices: FunctionReference<"query", "internal", {}, any, Name>;
106
141
  listRequests: FunctionReference<
107
142
  "query",
108
143
  "internal",
109
- { limit?: number; userId?: string },
144
+ { dimension?: string; limit?: number; userId?: string; value?: string },
110
145
  any,
111
146
  Name
112
147
  >;
113
- listUsers: FunctionReference<"query", "internal", {}, any, Name>;
114
- setActionLimits: FunctionReference<
148
+ setAlertDefaults: FunctionReference<
115
149
  "mutation",
116
150
  "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
- },
151
+ { warnAtPct?: number },
126
152
  null,
127
153
  Name
128
154
  >;
129
- setGlobalLimits: FunctionReference<
155
+ setBucketLimits: FunctionReference<
130
156
  "mutation",
131
157
  "internal",
132
158
  {
159
+ blocked?: boolean;
133
160
  dailySpendLimitNanos?: number;
161
+ dailyTokenLimit?: number;
162
+ dimension: string;
134
163
  enforcement?: "hard" | "soft";
135
164
  lifetimeSpendLimitNanos?: number;
165
+ lifetimeTokenLimit?: number;
166
+ maxConcurrent?: number;
167
+ monthlySpendLimitNanos?: number;
168
+ monthlyTokenLimit?: number;
169
+ requestsPerMinute?: number;
170
+ value: string;
171
+ warnAtPct?: number;
136
172
  },
137
173
  null,
138
174
  Name
139
175
  >;
140
- setLimits: FunctionReference<
176
+ setGlobalLimits: FunctionReference<
141
177
  "mutation",
142
178
  "internal",
143
179
  {
144
- blocked?: boolean;
145
180
  dailySpendLimitNanos?: number;
146
- dailyTokenLimit?: number;
147
181
  enforcement?: "hard" | "soft";
148
182
  lifetimeSpendLimitNanos?: number;
149
- lifetimeTokenLimit?: number;
150
- requestsPerMinute?: number;
151
- userId: string;
152
183
  },
153
184
  null,
154
185
  Name
@@ -164,6 +195,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
164
195
  "mutation",
165
196
  "internal",
166
197
  {
198
+ cachedNanosPerMTok?: number;
167
199
  inputNanosPerMTok: number;
168
200
  model: string;
169
201
  outputNanosPerMTok: number;
@@ -186,11 +218,29 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
186
218
  messages: Array<{ content: string; role: string }>;
187
219
  model: string;
188
220
  rerunOf?: string;
221
+ tags?: Array<{ dimension: string; value: string }>;
189
222
  userId: string;
190
223
  },
191
- | { allowed: true; requestId: string; warnings: Array<string> }
224
+ | {
225
+ allowed: true;
226
+ notices: Array<string>;
227
+ requestId: string;
228
+ warnings: Array<string>;
229
+ }
192
230
  | { allowed: false; code: string; reason: string },
193
231
  Name
194
232
  >;
233
+ usageHistory: FunctionReference<
234
+ "query",
235
+ "internal",
236
+ {
237
+ dimension: string;
238
+ limit?: number;
239
+ period: "day" | "month";
240
+ value: string;
241
+ },
242
+ any,
243
+ Name
244
+ >;
195
245
  };
196
246
  };
@@ -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,197 @@ 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("durable usage history", () => {
120
+ test("settled spend lands in a per-day usage row", async () => {
121
+ const t = convexTest(schema, modules);
122
+ const r = await start(t, { userId: "u" });
123
+ await settleWith(t, r.requestId, { promptTokens: 1_000_000, completionTokens: 1_000_000 });
124
+ const hist = await t.query(api.lib.usageHistory, {
125
+ dimension: "user",
126
+ value: "u",
127
+ period: "day",
128
+ });
129
+ expect(hist.length).toBe(1);
130
+ expect(hist[0].spendNanos).toBe(750_000_000); // $0.75
131
+ expect(hist[0].requests).toBe(1);
132
+ });
133
+ });
134
+
135
+ describe("manual adjustments", () => {
136
+ test("a credit reduces spend and is logged", async () => {
137
+ const t = convexTest(schema, modules);
138
+ const r = await start(t, { userId: "u" });
139
+ await settleWith(t, r.requestId, { promptTokens: 1_000_000, completionTokens: 1_000_000 });
140
+ await t.mutation(api.lib.adjustBucket, {
141
+ dimension: "user",
142
+ value: "u",
143
+ deltaNanos: -250_000_000,
144
+ reason: "goodwill credit",
145
+ });
146
+ const u = await userOf(t, "u");
147
+ expect(u.totalSpendNanos).toBe(500_000_000); // 750M - 250M
148
+ const log = await t.query(api.lib.listAdjustments, { dimension: "user", value: "u" });
149
+ expect(log.length).toBe(1);
150
+ expect(log[0].deltaNanos).toBe(-250_000_000);
151
+ });
152
+ });
153
+
154
+ describe("threshold alerts", () => {
155
+ test("crossing warnAtPct returns a notice but still admits", async () => {
156
+ const t = convexTest(schema, modules);
157
+ // One "hi" estimate is ~480_150 nano. Cap 800_000, warn at 50% (400_000).
158
+ await setUserLimits(t, "u", { dailySpendLimitNanos: 800_000, warnAtPct: 0.5 });
159
+ const r = await start(t, { userId: "u" });
160
+ expect(r.allowed).toBe(true);
161
+ expect(r.notices.length).toBeGreaterThan(0);
162
+ });
163
+ });
164
+
165
+ describe("concurrency cap", () => {
166
+ test("maxConcurrent blocks a second in-flight request", async () => {
167
+ const t = convexTest(schema, modules);
168
+ await setUserLimits(t, "u", { maxConcurrent: 1 });
169
+ const first = await start(t, { userId: "u" });
170
+ expect(first.allowed).toBe(true); // reserved, still pending
171
+ const second = await start(t, { userId: "u" });
172
+ expect(second.allowed).toBe(false);
173
+ expect(second.code).toBe("user_max_concurrent");
174
+ });
175
+ });
176
+
177
+ describe("per-bucket rate limits", () => {
178
+ test("the existing user rate limit remains compatible", async () => {
179
+ const t = convexTest(schema, modules);
180
+ await setUserLimits(t, "u", { requestsPerMinute: 1 });
181
+ const first = await start(t, { userId: "u" });
182
+ expect(first.allowed).toBe(true);
183
+ const second = await start(t, { userId: "u" });
184
+ expect(second.allowed).toBe(false);
185
+ expect(second.code).toBe("rate_limit");
186
+ });
187
+
188
+ test("an action rate limit blocks the next request for that action", async () => {
189
+ const t = convexTest(schema, modules);
190
+ await t.mutation(api.lib.setBucketLimits, {
191
+ dimension: "action",
192
+ value: "ai:summarize",
193
+ requestsPerMinute: 1,
194
+ });
195
+ const first = await start(t, { userId: "u1", actionName: "ai:summarize" });
196
+ expect(first.allowed).toBe(true);
197
+ const second = await start(t, { userId: "u2", actionName: "ai:summarize" });
198
+ expect(second.allowed).toBe(false);
199
+ expect(second.code).toBe("action_rate_limit");
200
+ });
201
+
202
+ test("a custom-tag rate limit blocks the next request for that value", async () => {
203
+ const t = convexTest(schema, modules);
204
+ await t.mutation(api.lib.setBucketLimits, {
205
+ dimension: "customer",
206
+ value: "acme",
207
+ requestsPerMinute: 1,
208
+ });
209
+ const tags = [{ dimension: "customer", value: "acme" }];
210
+ const first = await start(t, { userId: "u1", tags });
211
+ expect(first.allowed).toBe(true);
212
+ const second = await start(t, { userId: "u2", tags });
213
+ expect(second.allowed).toBe(false);
214
+ expect(second.code).toBe("customer_rate_limit");
215
+ });
216
+ });
217
+
218
+ describe("tag-filtered request log", () => {
219
+ test("listRequests filters by a custom tag dimension", async () => {
220
+ const t = convexTest(schema, modules);
221
+ await start(t, { userId: "u", tags: [{ dimension: "customer", value: "acme" }] });
222
+ await start(t, { userId: "u", tags: [{ dimension: "customer", value: "globex" }] });
223
+ const acme = await t.query(api.lib.listRequests, {
224
+ dimension: "customer",
225
+ value: "acme",
226
+ });
227
+ expect(acme.length).toBe(1);
228
+ expect(acme[0].userId).toBe("u");
229
+ });
230
+ });
231
+
232
+ describe("tagged attribution buckets", () => {
233
+ test("a cap on a custom tag blocks, and settlement accrues to every bucket", async () => {
234
+ const t = convexTest(schema, modules);
235
+ // A tiny cap on customer "acme" — the user is uncapped.
236
+ await t.mutation(api.lib.setBucketLimits, {
237
+ dimension: "customer",
238
+ value: "acme",
239
+ dailySpendLimitNanos: 1_000,
240
+ });
241
+ const blocked = await start(t, {
242
+ userId: "u",
243
+ tags: [{ dimension: "customer", value: "acme" }],
244
+ });
245
+ expect(blocked.allowed).toBe(false);
246
+ expect(blocked.code).toBe("customer_daily_spend_limit");
247
+
248
+ // A different customer with no cap goes through, and the spend lands on
249
+ // BOTH the user bucket and the customer bucket.
250
+ const ok = await start(t, {
251
+ userId: "u",
252
+ tags: [{ dimension: "customer", value: "globex" }],
253
+ });
254
+ expect(ok.allowed).toBe(true);
255
+ await settle(t, ok.requestId, 1_000_000, 1_000_000); // $0.75
256
+ const user = await userOf(t, "u");
257
+ const cust = await bucketOf(t, "customer", "globex");
258
+ expect(user.totalSpendNanos).toBe(750_000_000);
259
+ expect(cust.totalSpendNanos).toBe(750_000_000);
260
+ expect(cust.totalRequests).toBe(1);
261
+ });
262
+ });
263
+
70
264
  describe("D-00 exactly-once settlement", () => {
71
265
  test("a duplicate finishRequest does not double-count", async () => {
72
266
  const t = convexTest(schema, modules);
@@ -85,7 +279,7 @@ describe("D-00 exactly-once settlement", () => {
85
279
  describe("token quotas", () => {
86
280
  test("a tiny daily token cap blocks (estimate exceeds it)", async () => {
87
281
  const t = convexTest(schema, modules);
88
- await t.mutation(api.lib.setLimits, { userId: "u", dailyTokenLimit: 10 });
282
+ await setUserLimits(t, "u", { dailyTokenLimit: 10 });
89
283
  const r = await start(t, { userId: "u" });
90
284
  expect(r.allowed).toBe(false);
91
285
  expect(r.code).toBe("user_daily_token_limit");
@@ -95,15 +289,14 @@ describe("token quotas", () => {
95
289
  describe("soft enforcement", () => {
96
290
  test("over a soft budget: allowed, warned, flagged overBudget", async () => {
97
291
  const t = convexTest(schema, modules);
98
- await t.mutation(api.lib.setLimits, {
99
- userId: "u",
292
+ await setUserLimits(t, "u", {
100
293
  dailySpendLimitNanos: 1, // 1 nanodollar — one estimate blows past it
101
294
  enforcement: "soft",
102
295
  });
103
296
  const r = await start(t, { userId: "u" });
104
297
  expect(r.allowed).toBe(true);
105
298
  expect(r.warnings.length).toBeGreaterThan(0);
106
- const req = await t.query(api.lib.getRequest, { requestId: r.requestId });
299
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
107
300
  expect(req.overBudget).toBe(true);
108
301
  });
109
302
  });
@@ -145,7 +338,7 @@ describe("F-04 fail-closed pricing", () => {
145
338
  const u = await userOf(t, "u");
146
339
  // conservative = max over table = {$3 in, $15 out}/Mtok => $18 = 18e9 nano.
147
340
  expect(u.totalSpendNanos).toBe(18_000_000_000);
148
- const req = await t.query(api.lib.getRequest, { requestId: r.requestId });
341
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
149
342
  expect(req.unpricedModel).toBe(true);
150
343
  });
151
344
  });