Skip to content
Source
Sign inSign up

usageMerge.test.ts

571 lines17.6 KB

1import {
2 USAGE_CONTRACT_VERSION,
3 USAGE_MERGE_COMPATIBLE_SINCE,
4 type EnvironmentId,
5 type UsageBucket,
6 type UsageDay,
7 type UsageProviderKind,
8 UsageSummary,
9} from "@t3tools/contracts";
10import { describe, expect, it } from "vite-plus/test";
11import * as Schema from "effect/Schema";
12​
13import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";
14​
15const decodeSummary = Schema.decodeUnknownSync(UsageSummary);
16​
17function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket {
18 return {
19 day: "2026-08-07" as UsageDay,
20 provider: "claude",
21 model: "claude-fable-5",
22 totals: {
23 uncachedInputTokens: 100,
24 cachedInputTokens: 1000,
25 cacheCreationTokens: 10,
26 outputTokens: 50,
27 reasoningTokens: 0,
28 },
29 costUsd: 10,
30 cacheSavingsUsd: 2,
31 costSource: "modelPriced",
32 records: 5,
33 unpricedRecords: 0,
34 sessions: 1,
35 ...overrides,
36 };
38​
39function summary(
40 buckets: readonly UsageBucket[],
41 sources: readonly {
42 provider: UsageProviderKind;
43 hostId: string;
44 homePath: string;
45 volumeId?: string;
46 distinctSessions?: number;
47 }[],
48 contractVersion: number = USAGE_CONTRACT_VERSION,
49): UsageSummary {
50 return {
51 contractVersion,
52 readAt: "2026-08-07T00:00:00.000Z",
53 timeZone: "UTC",
54 sinceDay: "2026-08-01" as UsageDay,
55 untilDay: "2026-08-31" as UsageDay,
56 buckets,
57 sources: sources.map((source) => ({
58 fingerprint: {
59 hostId: source.hostId,
60 provider: source.provider,
61 resolvedHomePath: source.homePath,
62 volumeId: source.volumeId ?? `vol-${source.hostId}`,
63 },
64 status: "ok" as const,
65 scannedFiles: 1,
66 skippedFiles: 0,
67 malformedRecords: 0,
68 distinctSessions: source.distinctSessions ?? 1,
69 message: null,
70 })),
71 pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 },
72 scanDurationMs: 1,
73 };
75​
76function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage {
77 return { environmentId: id as EnvironmentId, label: id, summary: usageSummary };
79​
80describe("mergeUsage", () => {
81 it("counts a Cursor account once across servers while retaining each server's other providers", () => {
82 const account = {
83 provider: "cursor" as const,
84 hostId: "cursor.com",
85 homePath: "cursor-account:account-hash",
86 volumeId: "account-hash",
87 };
88 const merged = mergeUsage(
89 [
90 environment(
91 "mac",
92 summary([bucket({ provider: "cursor", sourcePath: account.homePath })], [account]),
93 ),
94 environment(
95 "linux",
96 summary(
97 [
98 bucket({ provider: "cursor", sourcePath: account.homePath }),
99 bucket({ provider: "opencode", sourcePath: "/opencode" }),
100 ],
101 [account, { provider: "opencode", hostId: "linux", homePath: "/opencode" }],
102 ),
103 ),
104 ],
105 USAGE_CONTRACT_VERSION,
106 );
107 expect(
108 merged.providers.map((provider) => [provider.provider, provider.costUsd]).sort(),
109 ).toEqual([
110 ["cursor", 10],
111 ["opencode", 10],
112 ]);
113 expect(merged.duplicateSources).toHaveLength(1);
114 });
115​
116 it("counts a shared transcript directory once", () => {
117 const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
118 const merged = mergeUsage(
119 [
120 environment("env-a", summary([bucket()], [shared])),
121 environment("env-b", summary([bucket()], [shared])),
122 ],
123 USAGE_CONTRACT_VERSION,
124 );
125​
126 expect(merged.costUsd).toBe(10);
127 expect(merged.records).toBe(5);
128 expect(merged.sessions).toBe(1);
129 expect(merged.duplicateSources).toHaveLength(1);
130 expect(merged.contributingEnvironments).toEqual(["env-a"]);
131 });
132​
133 it("drops only the duplicated provider, keeping the environment's other one", () => {
134 const sharedClaude = {
135 provider: "claude" as const,
136 hostId: "mac",
137 homePath: "/home/theo/.claude",
138 };
139 const merged = mergeUsage(
140 [
141 environment("env-a", summary([bucket()], [sharedClaude])),
142 environment(
143 "env-b",
144 summary(
145 [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 4 })],
146 [sharedClaude, { provider: "codex", hostId: "mac", homePath: "/home/theo/.codex" }],
147 ),
148 ),
149 ],
150 USAGE_CONTRACT_VERSION,
151 );
152​
153 expect(merged.costUsd).toBe(14);
154 expect(merged.providers.map((provider) => provider.provider).sort()).toEqual([
155 "claude",
156 "codex",
157 ]);
158 expect(merged.sessions).toBe(2);
159 expect(
160 Object.fromEntries(
161 merged.providers.map((provider) => [provider.provider, provider.sessions]),
162 ),
163 ).toEqual({ claude: 1, codex: 1 });
164 });
165​
166 it("counts overlapping provider roots once while keeping each environment's unique root", () => {
167 const source = (homePath: string) => ({
168 provider: "opencode" as const,
169 hostId: "host",
170 homePath,
171 });
172 const usage = (sourcePath: string, costUsd: number) =>
173 bucket({ provider: "opencode", sourcePath, costUsd });
174 const merged = mergeUsage(
175 [
176 environment(
177 "env-a",
178 summary([usage("/shared", 10), usage("/a", 2)], [source("/shared"), source("/a")]),
179 ),
180 environment(
181 "env-b",
182 summary([usage("/shared", 10), usage("/b", 3)], [source("/shared"), source("/b")]),
183 ),
184 ],
185 USAGE_CONTRACT_VERSION,
186 );
187 expect(merged.costUsd).toBe(15);
188 expect(merged.sessions).toBe(3);
189 });
190​
191 it("uses the newest scan when environments share the same transcript directory", () => {
192 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
193 const environments = [
194 environment("env-a", summary([bucket({ costUsd: 4, records: 2 })], [source])),
195 environment("env-b", {
196 ...summary([bucket()], [source]),
197 readAt: "2026-08-07T01:00:00.000Z",
198 }),
199 ];
200​
201 for (const ordered of [environments, environments.toReversed()]) {
202 const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION);
203 expect(merged.costUsd).toBe(10);
204 expect(merged.records).toBe(5);
205 expect(merged.sessions).toBe(1);
206 expect(merged.contributingEnvironments).toEqual(["env-b"]);
207 expect(merged.duplicateSources).toEqual(["env-a: /home/theo/.claude"]);
208 }
209 });
210​
211 it("prefers a complete scan over a newer partial scan of the same directory", () => {
212 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
213 const incomplete = summary([bucket({ costUsd: 4, records: 2 })], [source]);
214 const partial = environment("new", {
215 ...incomplete,
216 readAt: "2026-08-07T01:00:00.000Z",
217 sources: incomplete.sources.map((entry) => ({ ...entry, status: "partial" as const })),
218 });
219 const complete = environment("old", summary([bucket()], [source]));
220​
221 for (const ordered of [
222 [partial, complete],
223 [complete, partial],
224 ]) {
225 const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION);
226 expect(merged.costUsd).toBe(10);
227 expect(merged.contributingEnvironments).toEqual(["old"]);
228 expect(merged.duplicateSources).toEqual(["new: /home/theo/.claude"]);
229 }
230 expect(mergeUsage([partial], USAGE_CONTRACT_VERSION).costUsd).toBe(4);
231 });
232​
233 it("keeps new cells from a later partial scan without recounting older cells", () => {
234 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
235 const complete = environment(
236 "old",
237 summary([bucket()], [source], USAGE_MERGE_COMPATIBLE_SINCE),
238 );
239 const partialSummary = summary(
240 [
241 bucket({ sourcePath: source.homePath, costUsd: 4, records: 2 }),
242 bucket({
243 day: "2026-08-08" as UsageDay,
244 sourcePath: source.homePath,
245 costUsd: 3,
246 records: 1,
247 }),
248 ],
249 [{ ...source, distinctSessions: 2 }],
250 );
251 const partial = environment("new", {
252 ...partialSummary,
253 readAt: "2026-08-08T01:00:00.000Z",
254 sources: partialSummary.sources.map((entry) => ({ ...entry, status: "partial" as const })),
255 });
256​
257 for (const ordered of [
258 [complete, partial],
259 [partial, complete],
260 ]) {
261 const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION);
262 expect(merged.costUsd).toBe(13);
263 expect(merged.records).toBe(6);
264 expect(merged.sessions).toBe(2);
265 expect(merged.daily.map(({ day, costUsd }) => [day, costUsd])).toEqual([
266 ["2026-08-07", 10],
267 ["2026-08-08", 3],
268 ]);
269 expect(merged.contributingEnvironments).toEqual(
270 ordered.map(({ environmentId }) => environmentId),
271 );
272 expect(merged.duplicateSources).toEqual(["new: /home/theo/.claude"]);
273 }
274​
275 const filtered = environment("new", {
276 ...partial.summary,
277 modelFilter: { exclude: [{ model: "claude-fable-5" }] },
278 });
279 const merged = mergeUsage([complete, filtered], USAGE_CONTRACT_VERSION);
280 expect(merged.costUsd).toBe(10);
281 expect(merged.sessions).toBe(1);
282 });
283​
284 it("retains a complete cell when a larger partial cell may have skipped old records", () => {
285 const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
286 const complete = environment("old", summary([bucket()], [source]));
287 const partialSummary = summary(
288 [
289 bucket({
290 costUsd: 4,
291 records: 6,
292 totals: {
293 uncachedInputTokens: 80,
294 cachedInputTokens: 500,
295 cacheCreationTokens: 10,
296 outputTokens: 30,
297 reasoningTokens: 0,
298 },
299 }),
300 ],
301 [{ ...source, distinctSessions: 2 }],
302 );
303 const partial = environment("new", {
304 ...partialSummary,
305 readAt: "2026-08-07T01:00:00.000Z",
306 sources: partialSummary.sources.map((entry) => ({ ...entry, status: "partial" as const })),
307 });
308​
309 const merged = mergeUsage([complete, partial], USAGE_CONTRACT_VERSION);
310 expect(merged.costUsd).toBe(10);
311 expect(merged.totalTokens).toBe(1160);
312 expect(merged.records).toBe(5);
313 expect(merged.sessions).toBe(1);
314 expect(merged.contributingEnvironments).toEqual(["old"]);
315 });
316​
317 it("identifies an environment reporting an older contract version", () => {
318 const merged = mergeUsage(
319 [
320 environment(
321 "env-a",
322 summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]),
323 ),
324 environment(
325 "env-b",
326 summary(
327 [bucket()],
328 [{ provider: "claude", hostId: "linux", homePath: "/b" }],
329 USAGE_MERGE_COMPATIBLE_SINCE - 1,
330 ),
331 ),
332 ],
333 USAGE_CONTRACT_VERSION,
334 );
335​
336 expect(merged.costUsd).toBe(10);
337 expect(merged.contractMismatches).toEqual([
338 {
339 environmentId: "env-b",
340 direction: "serverBehind",
341 contractVersion: USAGE_MERGE_COMPATIBLE_SINCE - 1,
342 },
343 ]);
344 });
345​
346 it("identifies an environment reporting a newer contract version", () => {
347 const merged = mergeUsage(
348 [
349 environment(
350 "env-a",
351 summary(
352 [bucket()],
353 [{ provider: "claude", hostId: "mac", homePath: "/a" }],
354 USAGE_CONTRACT_VERSION + 1,
355 ),
356 ),
357 ],
358 USAGE_CONTRACT_VERSION,
359 );
360​
361 expect(merged.costUsd).toBe(0);
362 expect(merged.contractMismatches).toEqual([
363 {
364 environmentId: "env-a",
365 direction: "clientBehind",
366 contractVersion: USAGE_CONTRACT_VERSION + 1,
367 },
368 ]);
369 });
370​
371 it("keeps the previous compatible contract version so additive provider expansions still merge", () => {
372 const merged = mergeUsage(
373 [
374 environment(
375 "env-a",
376 summary(
377 [bucket({ costUsd: 10 })],
378 [{ provider: "claude", hostId: "mac", homePath: "/a" }],
379 ),
380 ),
381 environment(
382 "env-b",
383 summary(
384 [bucket({ costUsd: 4, provider: "codex", model: "gpt-5.6-sol" })],
385 [{ provider: "codex", hostId: "linux", homePath: "/b" }],
386 USAGE_CONTRACT_VERSION - 1,
387 ),
388 ),
389 ],
390 USAGE_CONTRACT_VERSION,
391 );
392​
393 expect(merged.costUsd).toBe(14);
394 expect(merged.contractMismatches).toEqual([]);
395 });
396​
397 it("keeps known usage when newer providers and bucket variants cannot be decoded", () => {
398 const known = summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]);
399 const decoded = decodeSummary({
400 ...known,
401 buckets: [
402 ...known.buckets,
403 { ...bucket(), provider: "future-provider", costUsd: 100 },
404 { ...bucket(), costSource: "future-pricing", costUsd: 200 },
405 ],
406 sources: [
407 ...known.sources,
408 {
409 ...known.sources[0],
410 fingerprint: {
411 ...known.sources[0]?.fingerprint,
412 provider: "future-provider",
413 },
414 },
415 ],
416 });
417 expect(decoded).toEqual(known);
418 const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION);
419 expect(merged.costUsd).toBe(10);
420 expect(merged.totalTokens).toBe(1160);
421 expect(merged.contractMismatches).toEqual([]);
422 });
423​
424 it("normalizes model names during decoding before grouping usage", () => {
425 const decoded = decodeSummary(
426 summary(
427 [
428 bucket({ provider: "codex", model: " gpt-5 " }),
429 bucket({ provider: "codex", model: "gpt-5" }),
430 ],
431 [{ provider: "codex", hostId: "mac", homePath: "/a" }],
432 ),
433 );
434​
435 expect(decoded.buckets.map((entry) => entry.model)).toEqual(["gpt-5", "gpt-5"]);
436 const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION);
437 expect(merged.models).toHaveLength(1);
438 expect(merged.models[0]).toMatchObject({ model: "gpt-5", costUsd: 20, totalTokens: 2320 });
439 });
440​
441 it("marks a model with no known rates as unpriced rather than free", () => {
442 const merged = mergeUsage(
443 [
444 environment(
445 "env-a",
446 summary(
447 [
448 bucket({ costUsd: 75 }),
449 bucket({
450 provider: "codex",
451 model: "unknown-model",
452 costUsd: 0,
453 costSource: "unpriced",
454 unpricedRecords: 5,
455 }),
456 ],
457 [
458 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
459 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
460 ],
461 ),
462 ),
463 ],
464 USAGE_CONTRACT_VERSION,
465 );
466​
467 expect(merged.models.find((model) => model.model === "unknown-model")?.unpricedRecords).toBe(5);
468 expect(merged.models.filter(isModelCostUnknown).map((model) => model.model)).toEqual([
469 "unknown-model",
470 ]);
471 });
472​
473 it("splits cost by category and speed, counting older servers as unsplit standard cost", () => {
474 const merged = mergeUsage(
475 [
476 environment(
477 "env-a",
478 summary(
479 [
480 bucket({
481 costUsd: 10,
482 categoryCostUsd: { input: 1, cacheRead: 2, cacheWrite: 3, output: 4 },
483 fastCostUsd: 6,
484 speedPremiumUsd: 3,
485 }),
486 bucket({
487 provider: "codex",
488 model: "unknown-model",
489 costUsd: 0,
490 costSource: "unpriced",
491 unpricedRecords: 5,
492 }),
493 bucket({
494 provider: "codex",
495 model: "partly-reported",
496 costUsd: 0,
497 unpricedRecords: 4,
498 }),
499 ],
500 [
501 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
502 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
503 ],
504 ),
505 ),
506 environment(
507 "env-b",
508 summary(
509 [bucket({ costUsd: 5 })],
510 [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }],
511 USAGE_MERGE_COMPATIBLE_SINCE,
512 ),
513 ),
514 ],
515 USAGE_CONTRACT_VERSION,
516 );
517​
518 expect(merged.categoryCost).toEqual({
519 input: 1,
520 cacheRead: 2,
521 cacheWrite: 3,
522 output: 4,
523 unsplit: 5,
524 });
525 expect(merged.speedCost).toEqual({ standard: 9, fast: 6, ultrafast: 0, premium: 3 });
526 expect(merged.models.map(({ model, unpricedTokens }) => [model, unpricedTokens])).toEqual([
527 ["claude-fable-5", 0],
528 ["unknown-model", 1160],
529 ["partly-reported", 928],
530 ]);
531 });
532​
533 it("keeps two machines apart when hostname and home path collide", () => {
534 const shape = { provider: "claude" as const, hostId: "mac", homePath: "/Users/theo/.claude" };
535 const merged = mergeUsage(
536 [
537 environment("env-a", summary([bucket()], [{ ...shape, volumeId: "16777220:1234" }])),
538 environment("env-b", summary([bucket()], [{ ...shape, volumeId: "16777221:9999" }])),
539 ],
540 USAGE_CONTRACT_VERSION,
541 );
542​
543 expect(merged.costUsd).toBe(20);
544 expect(merged.duplicateSources).toHaveLength(0);
545 });
546​
547 it("totals sessions from per-directory distinct counts, not per-bucket sums", () => {
548 const merged = mergeUsage(
549 [
550 environment(
551 "env-a",
552 summary(
553 [bucket({ day: "2026-08-06" as UsageDay }), bucket({ day: "2026-08-07" as UsageDay })],
554 [
555 {
556 provider: "claude",
557 hostId: "mac",
558 homePath: "/a/.claude",
559 distinctSessions: 1,
560 },
561 ],
562 ),
563 ),
564 ],
565 USAGE_CONTRACT_VERSION,
566 );
567​
568 expect(merged.sessions).toBe(1);
569 expect(merged.providers[0]?.sessions).toBe(1);
570 });
571});