@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.
- package/README.md +98 -6
- package/dist/client/dashboard.d.ts +1 -0
- package/dist/client/dashboard.js +215 -0
- package/dist/client/index.d.ts +347 -44
- package/dist/client/index.js +222 -68
- package/dist/component/_generated/component.d.ts +34 -0
- package/dist/component/lib.d.ts +75 -0
- package/dist/component/lib.js +309 -32
- package/dist/component/schema.d.ts +76 -3
- package/dist/component/schema.js +56 -2
- package/package.json +1 -1
- package/src/client/dashboard.ts +215 -0
- package/src/client/index.ts +334 -132
- package/src/component/_generated/component.ts +56 -3
- package/src/component/lib.test.ts +118 -0
- package/src/component/lib.ts +380 -48
- package/src/component/schema.ts +59 -2
|
@@ -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
|
-
| {
|
|
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);
|