| 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | |
| 3 | import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; |
| 4 | import type { RateTable } from "./usagePricing.ts"; |
| 5 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; |
| 6 | |
| 7 | const 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 | |
| 26 | function 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 | |
| 47 | function 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 | |
| 71 | describe("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 | |
| 286 | describe("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 | }); |