1import { describe, expect, it } from "vitest";
2
3import { DEFAULT_BUDGETS } from "./config.js";
4import {
5 budgetExceededError,
6 BudgetService,
7 AiErrorCodes,
8 UsageLedger,
9} from "./usage.js";
10
11const NOW = Date.UTC(2025, 5, 15, 12, 0, 0);
12
13describe("UsageLedger", () => {
14 it("records and sums tokens by workspace and time", async () => {
15 const ledger = new UsageLedger({ now: () => NOW });
16 await ledger.record({
17 workspaceId: "ws1",
18 kind: "chat",
19 model: "gpt-4o",
20 provider: "openai",
21 tokensIn: 100,
22 tokensOut: 50,
23 latencyMs: 10,
24 costUsd: 0.01,
25 requestKind: "stream",
26 status: "ok",
27 });
28 await ledger.record({
29 workspaceId: "ws2",
30 kind: "chat",
31 model: "gpt-4o",
32 provider: "openai",
33 tokensIn: 999,
34 tokensOut: 999,
35 latencyMs: 10,
36 costUsd: 0.01,
37 requestKind: "stream",
38 status: "ok",
39 });
40 expect(ledger.tokensSince("ws1", 0)).toBe(150);
41 expect(ledger.tokensSince("ws2", 0)).toBe(1998);
42 expect(ledger.callsSince("ws1", 0)).toBe(1);
43 });
44
45 it("sums only rows since the given timestamp", async () => {
46 const ledger = new UsageLedger();
47 const early = Date.UTC(2025, 4, 31);
48 const late = Date.UTC(2025, 5, 1);
49
50
51 ledger.records.push({
52 id: "early",
53 workspaceId: "ws1",
54 kind: "chat",
55 model: "m",
56 provider: "openai",
57 tokensIn: 10,
58 tokensOut: 10,
59 latencyMs: 1,
60 costUsd: 0,
61 requestKind: "stream",
62 status: "ok",
63 createdAt: early,
64 });
65 ledger.records.push({
66 id: "late",
67 workspaceId: "ws1",
68 kind: "chat",
69 model: "m",
70 provider: "openai",
71 tokensIn: 100,
72 tokensOut: 100,
73 latencyMs: 1,
74 costUsd: 0,
75 requestKind: "stream",
76 status: "ok",
77 createdAt: late,
78 });
79 expect(ledger.tokensSince("ws1", late)).toBe(200);
80 expect(ledger.tokensSince("ws1", 0)).toBe(220);
81 });
82
83 it("monthlyRollup returns tokens/cost/calls", async () => {
84 const ledger = new UsageLedger({ now: () => NOW });
85 const monthStart = Date.UTC(2025, 5, 1);
86 await ledger.record({
87 workspaceId: "ws1",
88 kind: "chat",
89 model: "gpt-4o",
90 provider: "openai",
91 tokensIn: 10,
92 tokensOut: 5,
93 latencyMs: 1,
94 costUsd: 0.05,
95 requestKind: "stream",
96 status: "ok",
97 });
98 const rollup = ledger.monthlyRollup("ws1", monthStart);
99 expect(rollup).toEqual({ tokens: 15, costUsd: 0.05, calls: 1 });
100 });
101});
102
103describe("BudgetService", () => {
104 it("passes under budget", () => {
105 const ledger = new UsageLedger({ now: () => NOW });
106 const svc = new BudgetService(ledger, () => NOW);
107 expect(() => svc.check("ws1", { ...DEFAULT_BUDGETS, tokensPerMonth: 1000 })).not.toThrow();
108 });
109
110 it("exactly at the limit passes (>= comparison is against used+estimate)", () => {
111
112
113
114
115 const ledger = new UsageLedger({ now: () => NOW });
116 ledger.records.push({
117 id: "r1",
118 workspaceId: "ws1",
119 kind: "chat",
120 model: "m",
121 provider: "openai",
122 tokensIn: 60,
123 tokensOut: 40,
124 latencyMs: 1,
125 costUsd: 0,
126 requestKind: "stream",
127 status: "ok",
128 createdAt: NOW - 1000,
129 });
130 const svc = new BudgetService(ledger, () => NOW);
131
132 expect(() => svc.check("ws1", { ...DEFAULT_BUDGETS, tokensPerMonth: 100 })).toThrow();
133
134 const ledger2 = new UsageLedger({ now: () => NOW });
135 ledger2.records.push({
136 id: "r1",
137 workspaceId: "ws1",
138 kind: "chat",
139 model: "m",
140 provider: "openai",
141 tokensIn: 59,
142 tokensOut: 40,
143 latencyMs: 1,
144 costUsd: 0,
145 requestKind: "stream",
146 status: "ok",
147 createdAt: NOW - 1000,
148 });
149 const svc2 = new BudgetService(ledger2, () => NOW);
150 expect(() => svc2.check("ws1", { ...DEFAULT_BUDGETS, tokensPerMonth: 100 })).not.toThrow();
151 });
152
153 it("over budget throws 402 AI_BUDGET_EXCEEDED with used/limit details", () => {
154 const ledger = new UsageLedger({ now: () => NOW });
155 ledger.records.push({
156 id: "r1",
157 workspaceId: "ws1",
158 kind: "chat",
159 model: "gpt-4o",
160 provider: "openai",
161 tokensIn: 200,
162 tokensOut: 100,
163 latencyMs: 1,
164 costUsd: 0,
165 requestKind: "stream",
166 status: "ok",
167 createdAt: NOW - 1000,
168 });
169 const svc = new BudgetService(ledger, () => NOW);
170 const err = (() => {
171 try {
172 svc.check("ws1", { ...DEFAULT_BUDGETS, tokensPerMonth: 100 });
173 return null;
174 } catch (e) {
175 return e as Error & { code?: string; status?: number; details?: Record<string, unknown> };
176 }
177 })();
178 expect(err).not.toBeNull();
179 expect(err!.status).toBe(402);
180 expect((err!.details as Record<string, unknown>).code).toBe(AiErrorCodes.AI_BUDGET_EXCEEDED);
181 expect((err!.details as Record<string, unknown>).reason).toBe("monthly_token_budget_exceeded");
182 expect((err!.details as Record<string, unknown>).used).toBe(300);
183 expect((err!.details as Record<string, unknown>).limit).toBe(100);
184 });
185
186 it("zero budget = unlimited", () => {
187 const ledger = new UsageLedger({ now: () => NOW });
188 ledger.records.push({
189 id: "r1",
190 workspaceId: "ws1",
191 kind: "chat",
192 model: "m",
193 provider: "openai",
194 tokensIn: 1_000_000,
195 tokensOut: 1_000_000,
196 latencyMs: 1,
197 costUsd: 0,
198 requestKind: "stream",
199 status: "ok",
200 createdAt: NOW - 1000,
201 });
202 const svc = new BudgetService(ledger, () => NOW);
203 expect(() => svc.check("ws1", DEFAULT_BUDGETS)).not.toThrow();
204 });
205
206 it("daily spend budget rejects over the line", () => {
207 const ledger = new UsageLedger({ now: () => NOW });
208 ledger.records.push({
209 id: "r1",
210 workspaceId: "ws1",
211 kind: "chat",
212 model: "gpt-4o",
213 provider: "openai",
214 tokensIn: 1000,
215 tokensOut: 1000,
216 latencyMs: 1,
217 costUsd: 5,
218 requestKind: "stream",
219 status: "ok",
220 createdAt: NOW - 1000,
221 });
222 const svc = new BudgetService(ledger, () => NOW);
223 const err = (() => {
224 try {
225 svc.check("ws1", { ...DEFAULT_BUDGETS, dailySpendUsd: 4 });
226 return null;
227 } catch (e) {
228 return e as { details?: Record<string, unknown> };
229 }
230 })();
231 expect((err!.details as Record<string, unknown>).reason).toBe("daily_spend_budget_exceeded");
232 });
233
234 it("rate limit: N+1 calls in a minute are rejected", () => {
235 const ledger = new UsageLedger({ now: () => NOW });
236 let t = NOW;
237 const svc = new BudgetService(ledger, () => t);
238 for (let i = 0; i < 30; i++) {
239 svc.check("ws1", DEFAULT_BUDGETS);
240 t += 1000;
241 }
242 const err = (() => {
243 try {
244 svc.check("ws1", DEFAULT_BUDGETS);
245 return null;
246 } catch (e) {
247 return e as { code?: string };
248 }
249 })();
250 expect(err!.code).toBe("RATE_LIMITED");
251 });
252
253 it("rate limit window slides", () => {
254 const ledger = new UsageLedger({ now: () => NOW });
255 let t = NOW;
256 const svc = new BudgetService(ledger, () => t);
257 for (let i = 0; i < 30; i++) {
258 svc.check("ws1", DEFAULT_BUDGETS);
259 t += 1000;
260 }
261 t += 61_000;
262 expect(() => svc.check("ws1", DEFAULT_BUDGETS)).not.toThrow();
263 });
264
265 it("maxOutputTokens clamp", () => {
266 const ledger = new UsageLedger({ now: () => NOW });
267 const svc = new BudgetService(ledger, () => NOW);
268 expect(svc.clampMaxTokens(8192, DEFAULT_BUDGETS)).toBe(4096);
269 expect(svc.clampMaxTokens(1000, DEFAULT_BUDGETS)).toBe(1000);
270 expect(svc.clampMaxTokens(undefined, { ...DEFAULT_BUDGETS, maxOutputTokens: 100 })).toBe(100);
271 });
272
273 it("budget notification is throttled to 24 h per workspace", () => {
274 const ledger = new UsageLedger({ now: () => NOW });
275 let t = NOW;
276 const svc = new BudgetService(ledger, () => t);
277 let emitted = 0;
278 expect(svc.notifyBudgetExceeded("ws1", () => (emitted += 1))).toBe(true);
279 expect(svc.notifyBudgetExceeded("ws1", () => (emitted += 1))).toBe(false);
280 t += 25 * 3600 * 1000;
281 expect(svc.notifyBudgetExceeded("ws1", () => (emitted += 1))).toBe(true);
282 expect(emitted).toBe(2);
283 });
284});
285
286describe("budgetExceededError", () => {
287 it("has 402 status and the recorded code", () => {
288 const err = budgetExceededError("monthly_token_budget_exceeded", 10, 5);
289 expect(err.status).toBe(402);
290 expect(err.code).toBe("AI_ERROR");
291 expect((err.details as Record<string, unknown>).code).toBe("AI_BUDGET_EXCEEDED");
292 });
293});
294