@convex-dev/ai-budget 0.0.2-alpha.4 → 0.0.2-alpha.6

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,6 +24,19 @@ import type { FunctionReference } from "convex/server";
24
24
  export type ComponentApi<Name extends string | undefined = string | undefined> =
25
25
  {
26
26
  lib: {
27
+ adjustBucket: FunctionReference<
28
+ "mutation",
29
+ "internal",
30
+ {
31
+ deltaNanos: number;
32
+ dimension: string;
33
+ reason?: string;
34
+ tokens?: number;
35
+ value: string;
36
+ },
37
+ null,
38
+ Name
39
+ >;
27
40
  bumpBucket: FunctionReference<
28
41
  "mutation",
29
42
  "internal",
@@ -31,6 +44,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
31
44
  dailyNanos?: number;
32
45
  dimension: string;
33
46
  lifetimeNanos?: number;
47
+ monthlyNanos?: number;
34
48
  value: string;
35
49
  },
36
50
  null,
@@ -39,7 +53,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
39
53
  bumpGlobal: FunctionReference<
40
54
  "mutation",
41
55
  "internal",
42
- { dailyNanos?: number; lifetimeNanos?: number },
56
+ { dailyNanos?: number; lifetimeNanos?: number; monthlyNanos?: number },
43
57
  null,
44
58
  Name
45
59
  >;
@@ -56,6 +70,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
56
70
  {
57
71
  cachedTokens?: number;
58
72
  completionTokens?: number;
73
+ costNanos?: number;
59
74
  error?: string;
60
75
  latencyMs?: number;
61
76
  promptTokens?: number;
@@ -78,8 +93,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
78
93
  {},
79
94
  {
80
95
  dailySpendLimitNanos: number | null;
96
+ defaultWarnAtPct: number | null;
81
97
  enforcement: "hard" | "soft";
82
98
  lifetimeSpendLimitNanos: number | null;
99
+ retentionMs: number | null;
83
100
  spentTodayNanos: number;
84
101
  spentTotalNanos: number;
85
102
  },
@@ -106,6 +123,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
106
123
  any,
107
124
  Name
108
125
  >;
126
+ listAdjustments: FunctionReference<
127
+ "query",
128
+ "internal",
129
+ { dimension: string; limit?: number; value: string },
130
+ any,
131
+ Name
132
+ >;
109
133
  listBuckets: FunctionReference<
110
134
  "query",
111
135
  "internal",
@@ -117,10 +141,17 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
117
141
  listRequests: FunctionReference<
118
142
  "query",
119
143
  "internal",
120
- { limit?: number; userId?: string },
144
+ { dimension?: string; limit?: number; userId?: string; value?: string },
121
145
  any,
122
146
  Name
123
147
  >;
148
+ setAlertDefaults: FunctionReference<
149
+ "mutation",
150
+ "internal",
151
+ { warnAtPct?: number },
152
+ null,
153
+ Name
154
+ >;
124
155
  setBucketLimits: FunctionReference<
125
156
  "mutation",
126
157
  "internal",
@@ -132,8 +163,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
132
163
  enforcement?: "hard" | "soft";
133
164
  lifetimeSpendLimitNanos?: number;
134
165
  lifetimeTokenLimit?: number;
166
+ maxConcurrent?: number;
167
+ monthlySpendLimitNanos?: number;
168
+ monthlyTokenLimit?: number;
135
169
  requestsPerMinute?: number;
136
170
  value: string;
171
+ warnAtPct?: number;
137
172
  },
138
173
  null,
139
174
  Name
@@ -160,6 +195,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
160
195
  "mutation",
161
196
  "internal",
162
197
  {
198
+ cachedNanosPerMTok?: number;
163
199
  inputNanosPerMTok: number;
164
200
  model: string;
165
201
  outputNanosPerMTok: number;
@@ -185,9 +221,26 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
185
221
  tags?: Array<{ dimension: string; value: string }>;
186
222
  userId: string;
187
223
  },
188
- | { allowed: true; requestId: string; warnings: Array<string> }
224
+ | {
225
+ allowed: true;
226
+ notices: Array<string>;
227
+ requestId: string;
228
+ warnings: Array<string>;
229
+ }
189
230
  | { allowed: false; code: string; reason: string },
190
231
  Name
191
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
+ >;
192
245
  };
193
246
  };
@@ -70,6 +70,124 @@ describe("reserve / settle spend caps", () => {
70
70
  });
71
71
  });
72
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
+
73
191
  describe("tagged attribution buckets", () => {
74
192
  test("a cap on a custom tag blocks, and settlement accrues to every bucket", async () => {
75
193
  const t = convexTest(schema, modules);