@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.
@@ -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,156 @@ 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("tag-filtered request log", () => {
178
+ test("listRequests filters by a custom tag dimension", async () => {
179
+ const t = convexTest(schema, modules);
180
+ await start(t, { userId: "u", tags: [{ dimension: "customer", value: "acme" }] });
181
+ await start(t, { userId: "u", tags: [{ dimension: "customer", value: "globex" }] });
182
+ const acme = await t.query(api.lib.listRequests, {
183
+ dimension: "customer",
184
+ value: "acme",
185
+ });
186
+ expect(acme.length).toBe(1);
187
+ expect(acme[0].userId).toBe("u");
188
+ });
189
+ });
190
+
191
+ describe("tagged attribution buckets", () => {
192
+ test("a cap on a custom tag blocks, and settlement accrues to every bucket", async () => {
193
+ const t = convexTest(schema, modules);
194
+ // A tiny cap on customer "acme" — the user is uncapped.
195
+ await t.mutation(api.lib.setBucketLimits, {
196
+ dimension: "customer",
197
+ value: "acme",
198
+ dailySpendLimitNanos: 1_000,
199
+ });
200
+ const blocked = await start(t, {
201
+ userId: "u",
202
+ tags: [{ dimension: "customer", value: "acme" }],
203
+ });
204
+ expect(blocked.allowed).toBe(false);
205
+ expect(blocked.code).toBe("customer_daily_spend_limit");
206
+
207
+ // A different customer with no cap goes through, and the spend lands on
208
+ // BOTH the user bucket and the customer bucket.
209
+ const ok = await start(t, {
210
+ userId: "u",
211
+ tags: [{ dimension: "customer", value: "globex" }],
212
+ });
213
+ expect(ok.allowed).toBe(true);
214
+ await settle(t, ok.requestId, 1_000_000, 1_000_000); // $0.75
215
+ const user = await userOf(t, "u");
216
+ const cust = await bucketOf(t, "customer", "globex");
217
+ expect(user.totalSpendNanos).toBe(750_000_000);
218
+ expect(cust.totalSpendNanos).toBe(750_000_000);
219
+ expect(cust.totalRequests).toBe(1);
220
+ });
221
+ });
222
+
70
223
  describe("D-00 exactly-once settlement", () => {
71
224
  test("a duplicate finishRequest does not double-count", async () => {
72
225
  const t = convexTest(schema, modules);
@@ -85,7 +238,7 @@ describe("D-00 exactly-once settlement", () => {
85
238
  describe("token quotas", () => {
86
239
  test("a tiny daily token cap blocks (estimate exceeds it)", async () => {
87
240
  const t = convexTest(schema, modules);
88
- await t.mutation(api.lib.setLimits, { userId: "u", dailyTokenLimit: 10 });
241
+ await setUserLimits(t, "u", { dailyTokenLimit: 10 });
89
242
  const r = await start(t, { userId: "u" });
90
243
  expect(r.allowed).toBe(false);
91
244
  expect(r.code).toBe("user_daily_token_limit");
@@ -95,15 +248,14 @@ describe("token quotas", () => {
95
248
  describe("soft enforcement", () => {
96
249
  test("over a soft budget: allowed, warned, flagged overBudget", async () => {
97
250
  const t = convexTest(schema, modules);
98
- await t.mutation(api.lib.setLimits, {
99
- userId: "u",
251
+ await setUserLimits(t, "u", {
100
252
  dailySpendLimitNanos: 1, // 1 nanodollar — one estimate blows past it
101
253
  enforcement: "soft",
102
254
  });
103
255
  const r = await start(t, { userId: "u" });
104
256
  expect(r.allowed).toBe(true);
105
257
  expect(r.warnings.length).toBeGreaterThan(0);
106
- const req = await t.query(api.lib.getRequest, { requestId: r.requestId });
258
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
107
259
  expect(req.overBudget).toBe(true);
108
260
  });
109
261
  });
@@ -145,7 +297,7 @@ describe("F-04 fail-closed pricing", () => {
145
297
  const u = await userOf(t, "u");
146
298
  // conservative = max over table = {$3 in, $15 out}/Mtok => $18 = 18e9 nano.
147
299
  expect(u.totalSpendNanos).toBe(18_000_000_000);
148
- const req = await t.query(api.lib.getRequest, { requestId: r.requestId });
300
+ const req = (await t.query(api.lib.getRequest, { requestId: r.requestId }))!;
149
301
  expect(req.unpricedModel).toBe(true);
150
302
  });
151
303
  });