1import { describe, expect, it } from "@effect/vitest";
2
3import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
4import type { RateTable } from "./usagePricing.ts";
5import type { UsageRecord } from "@t3tools/provider-core/server/usage";
6
7const rates: RateTable = new Map([
8 [
9 "claude-fable-5",
10 {
11 inputCostPerToken: 1e-5,
12 outputCostPerToken: 5e-5,
13 cacheReadCostPerToken: 1e-6,
14 cacheCreationCostPerToken: 1.25e-5,
15 fast: {
16 inputCostPerToken: 2e-5,
17 outputCostPerToken: 1e-4,
18 cacheReadCostPerToken: 2e-6,
19 cacheCreationCostPerToken: 2.5e-5,
20 },
21 ultrafast: null,
22 },
23 ],
24]);
25
26function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
27 return {
28 provider: "claude",
29 // 2026-08-07T04:05Z is still Aug 6 in Los Angeles.
30 timestampMs: Date.parse("2026-08-07T04:05:13.944Z"),
31 model: "claude-fable-5",
32 sessionId: "session-a",
33 totals: {
34 uncachedInputTokens: 100,
35 cachedInputTokens: 1000,
36 cacheCreationTokens: 10,
37 outputTokens: 50,
38 reasoningTokens: 0,
39 },
40 reportedCostUsd: null,
41 speed: "standard",
42 dedupeKey: null,
43 ...overrides,
44 };
45}
46
47function aggregate(
48 records: readonly UsageRecord[],
49 timeZone = "UTC",
50 resolution: "day" | "hour" = "day",
51) {
52 const hourlyBounds =
53 resolution === "hour"
54 ? {
55 sinceTimeMs: Date.parse("2026-08-06T04:37:00.000Z"),
56 untilTimeMs: Date.parse("2026-08-07T04:37:00.000Z"),
57 }
58 : {};
59 const aggregator = new UsageAggregator({
60 timeZone,
61 sinceDay: "2026-08-01",
62 untilDay: "2026-08-31",
63 resolution,
64 ...hourlyBounds,
65 rates,
66 });
67 for (const item of records) aggregator.add(item);
68 return aggregator.finish();
69}
70
71describe("UsageAggregator", () => {
72 it("requires exact bounds for hourly aggregation", () => {
73 expect(
74 () =>
75 new UsageAggregator({
76 timeZone: "UTC",
77 sinceDay: "2026-08-01",
78 untilDay: "2026-08-31",
79 resolution: "hour",
80 rates,
81 }),
82 ).toThrow("requires exact time bounds");
83 });
84
85 it("splits a bucket's cost by category and speed", () => {
86 const [bucket] = aggregate([record(), record({ speed: "fast" })]).buckets;
87
88 // Standard costs $0.005625 and fast twice that.
89 expect(bucket).toMatchObject({
90 costUsd: expect.closeTo(0.016875),
91 categoryCostUsd: {
92 input: expect.closeTo(0.003),
93 cacheRead: expect.closeTo(0.003),
94 cacheWrite: expect.closeTo(0.000375),
95 output: expect.closeTo(0.0075),
96 },
97 fastCostUsd: expect.closeTo(0.01125),
98 speedPremiumUsd: expect.closeTo(0.005625),
99 });
100 expect(bucket).not.toHaveProperty("ultrafastCostUsd");
101 });
102
103 it("keeps only the first record for a repeated dedupe key", () => {
104 const result = aggregate([
105 record({ dedupeKey: "msg_1:" }),
106 record({ dedupeKey: "msg_1:" }),
107 record({ dedupeKey: "msg_1:" }),
108 ]);
109
110 expect(result.duplicatesDropped).toBe(2);
111 expect(result.buckets).toHaveLength(1);
112 expect(result.buckets[0]?.records).toBe(1);
113 expect(result.buckets[0]?.totals.outputTokens).toBe(50);
114 });
115
116 it("still sums records that carry no dedupe key", () => {
117 const result = aggregate([record(), record()]);
118
119 expect(result.duplicatesDropped).toBe(0);
120 expect(result.buckets[0]?.totals.outputTokens).toBe(100);
121 });
122
123 it("buckets by the day in the requested time zone", () => {
124 const utc = aggregate([record()], "UTC");
125 const losAngeles = aggregate([record()], "America/Los_Angeles");
126
127 expect(utc.buckets[0]?.day).toBe("2026-08-07");
128 expect(losAngeles.buckets[0]?.day).toBe("2026-08-06");
129 });
130
131 it("finds the day at a quarter-hour zone's midnight across interleaved buckets", () => {
132 // Kathmandu is UTC+5:45, so its midnight falls at 18:15 UTC.
133 const result = aggregate(
134 [
135 record({ timestampMs: Date.parse("2026-08-01T18:14:59.999Z") }),
136 record({ timestampMs: Date.parse("2026-08-01T18:15:00.000Z") }),
137 record({ timestampMs: Date.parse("2026-08-01T18:15:00.000Z"), model: "claude-opus-5" }),
138 record({ timestampMs: Date.parse("2026-08-01T18:16:00.000Z") }),
139 ],
140 "Asia/Kathmandu",
141 );
142
143 expect(result.buckets.map((bucket) => [bucket.day, bucket.model, bucket.records])).toEqual([
144 ["2026-08-01", "claude-fable-5", 1],
145 ["2026-08-02", "claude-fable-5", 2],
146 ["2026-08-02", "claude-opus-5", 1],
147 ]);
148 });
149
150 it("finds the day at a fixed offset's midnight between quarter hours", () => {
151 // At +00:01, midnight falls at 23:59 UTC.
152 const result = aggregate(
153 [
154 record({ timestampMs: Date.parse("2026-08-01T23:58:59.999Z") }),
155 record({ timestampMs: Date.parse("2026-08-01T23:59:00.000Z") }),
156 ],
157 "+00:01",
158 );
159
160 expect(result.buckets.map((bucket) => [bucket.day, bucket.records])).toEqual([
161 ["2026-08-01", 1],
162 ["2026-08-02", 1],
163 ]);
164 });
165
166 it("splits an hourly request into fixed buckets anchored to its exact start", () => {
167 const result = aggregate(
168 [
169 record({ timestampMs: Date.parse("2026-08-07T02:40:13.944Z") }),
170 record({ timestampMs: Date.parse("2026-08-07T03:40:13.944Z") }),
171 ],
172 "America/Los_Angeles",
173 "hour",
174 );
175
176 expect(result.buckets.map((bucket) => [bucket.day, bucket.hourStart])).toEqual([
177 ["2026-08-06", "2026-08-07T02:37:00.000Z"],
178 ["2026-08-06", "2026-08-07T03:37:00.000Z"],
179 ]);
180 });
181
182 it("uses an inclusive start and exclusive end for rolling windows", () => {
183 const result = aggregate(
184 [
185 record({ timestampMs: Date.parse("2026-08-06T04:36:59.999Z") }),
186 record({ timestampMs: Date.parse("2026-08-06T04:37:00.000Z") }),
187 record({ timestampMs: Date.parse("2026-08-07T04:36:59.999Z") }),
188 record({ timestampMs: Date.parse("2026-08-07T04:37:00.000Z") }),
189 ],
190 "UTC",
191 "hour",
192 );
193
194 expect(result.outOfWindow).toBe(2);
195 expect(result.buckets.map((bucket) => bucket.hourStart)).toEqual([
196 "2026-08-06T04:37:00.000Z",
197 "2026-08-07T03:37:00.000Z",
198 ]);
199 });
200
201 it("keeps daily payloads collapsed when hourly resolution is not requested", () => {
202 const result = aggregate([
203 record({ timestampMs: Date.parse("2026-08-07T04:05:13.944Z") }),
204 record({ timestampMs: Date.parse("2026-08-07T05:05:13.944Z") }),
205 ]);
206
207 expect(result.buckets).toHaveLength(1);
208 expect(result.buckets[0]?.hourStart).toBeUndefined();
209 expect(result.buckets[0]?.records).toBe(2);
210 });
211
212 it("prices against the rate table", () => {
213 const result = aggregate([record()]);
214
215 // 100*1e-5 + 1000*1e-6 + 10*1.25e-5 + 50*5e-5
216 expect(result.buckets[0]?.costUsd).toBeCloseTo(0.004625, 9);
217 expect(result.buckets[0]?.costSource).toBe("modelPriced");
218 });
219
220 it("counts tokens but not cost for a model with no rate", () => {
221 const result = aggregate([record({ model: "kimi-k3" })]);
222
223 expect(result.buckets[0]?.costUsd).toBe(0);
224 expect(result.buckets[0]?.costSource).toBe("unpriced");
225 expect(result.buckets[0]?.unpricedRecords).toBe(1);
226 expect(result.buckets[0]?.totals.outputTokens).toBe(50);
227 });
228
229 it("prefers a reported cost over the rate table", () => {
230 const result = aggregate([record({ reportedCostUsd: 1.25 })]);
231
232 expect(result.buckets[0]?.costUsd).toBe(1.25);
233 expect(result.buckets[0]?.costSource).toBe("providerReported");
234 });
235
236 it("drops records outside the window", () => {
237 const result = aggregate([record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") })]);
238
239 expect(result.outOfWindow).toBe(1);
240 expect(result.buckets).toHaveLength(0);
241 });
242
243 it("reports whether a record contributed", () => {
244 const aggregator = new UsageAggregator({
245 timeZone: "UTC",
246 sinceDay: "2026-08-01",
247 untilDay: "2026-08-31",
248 rates,
249 });
250
251 expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true);
252 expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(false);
253 expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false);
254 });
255
256 it("folds a mapped model into its target and prices it there", () => {
257 const aggregator = new UsageAggregator({
258 timeZone: "UTC",
259 sinceDay: "2026-08-01",
260 untilDay: "2026-08-31",
261 rates,
262 modelAliases: resolveModelAliases({ "example-preview": "claude-fable-5" }),
263 });
264 aggregator.add(record());
265 aggregator.add(record({ model: "example-preview", rateModel: "example-preview-high" }));
266 const result = aggregator.finish();
267
268 expect(result.buckets).toHaveLength(1);
269 expect(result.buckets[0]?.model).toBe("claude-fable-5");
270 expect(result.buckets[0]?.records).toBe(2);
271 expect(result.buckets[0]?.costUsd).toBeCloseTo(0.00925, 9);
272 expect(result.buckets[0]?.unpricedRecords).toBe(0);
273 });
274
275 it("separates providers and models into their own buckets", () => {
276 const result = aggregate([
277 record(),
278 record({ provider: "codex", model: "gpt-5.6-sol" }),
279 record({ model: "claude-opus-5" }),
280 ]);
281
282 expect(result.buckets).toHaveLength(3);
283 });
284});
285
286describe("resolveModelAliases", () => {
287 it("follows chains to the final model and drops chains that enter a loop", () => {
288 expect(
289 resolveModelAliases({
290 "preview[1m]": "preview",
291 preview: "example-model",
292 loop: "back",
293 back: "loop",
294 intoLoop: "loop",
295 self: "self",
296 }),
297 ).toEqual(
298 new Map([
299 ["preview[1m]", "example-model"],
300 ["preview", "example-model"],
301 ]),
302 );
303 });
304});