From 01fcd775734671c3947c67d3a4458e62394590a9 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 26 Apr 2026 16:52:12 -0400 Subject: [PATCH] fix(cli-proxy): preserve historical analytics across syncs --- src/web-server/usage/cliproxy-usage-syncer.ts | 21 +++++++++++-- .../usage/cliproxy-usage-transformer.ts | 30 ++++++++++++++----- .../web-server/cliproxy-usage-syncer.test.ts | 16 ++++++++++ .../cliproxy-usage-transformer.test.ts | 8 +++++ 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts index fe5cd23a..ce616399 100644 --- a/src/web-server/usage/cliproxy-usage-syncer.ts +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -87,6 +87,14 @@ function buildDailyTimestamp(date: string): string { return `${date}T00:00:00.000Z`; } +function distributeRequestCounts(total: number, buckets: number): number[] { + if (buckets <= 0) return []; + const normalizedTotal = Math.max(buckets, total); + const base = Math.floor(normalizedTotal / buckets); + const remainder = normalizedTotal % buckets; + return Array.from({ length: buckets }, (_value, index) => base + (index < remainder ? 1 : 0)); +} + function buildLegacyHistoryDetails( snapshot: LegacyCliproxyUsageSnapshot ): CliproxyUsageHistoryDetail[] { @@ -94,7 +102,12 @@ function buildLegacyHistoryDetails( const coveredDailyKeys = new Set(); for (const hour of snapshot.hourly ?? []) { - for (const breakdown of hour.modelBreakdowns) { + const requestCounts = distributeRequestCounts( + hour.requestCount ?? hour.modelBreakdowns.length, + hour.modelBreakdowns.length + ); + + hour.modelBreakdowns.forEach((breakdown, index) => { details.push({ model: breakdown.modelName, timestamp: buildHourlyTimestamp(hour.hour), @@ -103,10 +116,12 @@ function buildLegacyHistoryDetails( inputTokens: breakdown.inputTokens, outputTokens: breakdown.outputTokens, cacheReadTokens: breakdown.cacheReadTokens, + requestCount: requestCounts[index] ?? 1, + cost: breakdown.cost, failed: false, }); coveredDailyKeys.add(`${hour.hour.slice(0, 10)}|${breakdown.modelName}`); - } + }); } for (const day of snapshot.daily ?? []) { @@ -124,6 +139,8 @@ function buildLegacyHistoryDetails( inputTokens: breakdown.inputTokens, outputTokens: breakdown.outputTokens, cacheReadTokens: breakdown.cacheReadTokens, + requestCount: 1, + cost: breakdown.cost, failed: false, }); } diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts index c1b398d0..2ae608e5 100644 --- a/src/web-server/usage/cliproxy-usage-transformer.ts +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -22,6 +22,8 @@ export interface CliproxyUsageHistoryDetail { inputTokens: number; outputTokens: number; cacheReadTokens: number; + requestCount: number; + cost: number; failed: boolean; } @@ -30,15 +32,12 @@ interface ModelAccumulator { inputTokens: number; outputTokens: number; cacheReadTokens: number; + cost: number; } /** Build ModelBreakdown from accumulated token counts */ function buildModelBreakdown(modelName: string, acc: ModelAccumulator): ModelBreakdown { - const { inputTokens, outputTokens, cacheReadTokens } = acc; - const cost = calculateCost( - { inputTokens, outputTokens, cacheCreationTokens: 0, cacheReadTokens }, - modelName - ); + const { inputTokens, outputTokens, cacheReadTokens, cost } = acc; return { modelName, inputTokens, outputTokens, cacheCreationTokens: 0, cacheReadTokens, cost }; } @@ -54,6 +53,16 @@ function createHistoryDetail( inputTokens: detail.tokens?.input_tokens ?? 0, outputTokens: detail.tokens?.output_tokens ?? 0, cacheReadTokens: detail.tokens?.cached_tokens ?? 0, + requestCount: 1, + cost: calculateCost( + { + inputTokens: detail.tokens?.input_tokens ?? 0, + outputTokens: detail.tokens?.output_tokens ?? 0, + cacheCreationTokens: 0, + cacheReadTokens: detail.tokens?.cached_tokens ?? 0, + }, + model + ), failed: detail.failed, }; } @@ -107,6 +116,7 @@ function createHistorySignature(detail: CliproxyUsageHistoryDetail): string { detail.inputTokens, detail.outputTokens, detail.cacheReadTokens, + detail.requestCount, detail.failed ? '1' : '0', ].join('|'); } @@ -185,15 +195,21 @@ function aggregateByKey( for (const detail of flat) { const key = keyFn(detail.timestamp); if (!buckets.has(key)) buckets.set(key, new Map()); - requestCounts.set(key, (requestCounts.get(key) ?? 0) + 1); + requestCounts.set(key, (requestCounts.get(key) ?? 0) + detail.requestCount); const modelMap = buckets.get(key) as Map; if (!modelMap.has(detail.model)) { - modelMap.set(detail.model, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }); + modelMap.set(detail.model, { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cost: 0, + }); } const acc = modelMap.get(detail.model) as ModelAccumulator; acc.inputTokens += detail.inputTokens; acc.outputTokens += detail.outputTokens; acc.cacheReadTokens += detail.cacheReadTokens; + acc.cost += detail.cost; } const records: T[] = []; diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index 54e687b8..c5edf9f0 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -211,6 +211,7 @@ describe('cliproxy usage syncer', () => { { hour: '2026-03-01 12:00', source: 'cliproxy', + requestCount: 7, inputTokens: 100, outputTokens: 20, cacheCreationTokens: 0, @@ -260,6 +261,21 @@ describe('cliproxy usage syncer', () => { const cached = await loadCachedCliproxyData(); expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']); + expect(cached.hourly.find((entry) => entry.hour === '2026-03-01 12:00')?.requestCount).toBe(7); + }); + }); + + it('prunes history details older than the configured retention window', async () => { + await runWithScopedConfigDir(ccsDir, async () => { + await syncCliproxyUsage(() => + Promise.resolve(buildResponse(100, '2024-01-01T12:00:00.000Z')) + ); + await syncCliproxyUsage(() => + Promise.resolve(buildResponse(200, new Date().toISOString())) + ); + + const cached = await loadCachedCliproxyData(); + expect(cached.daily.some((entry) => entry.date === '2024-01-01')).toBe(false); }); }); diff --git a/tests/unit/web-server/cliproxy-usage-transformer.test.ts b/tests/unit/web-server/cliproxy-usage-transformer.test.ts index c17185fb..d533865b 100644 --- a/tests/unit/web-server/cliproxy-usage-transformer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-transformer.test.ts @@ -134,6 +134,14 @@ describe('cliproxy usage transformer', () => { expect(merged).toHaveLength(2); }); + it('uses persisted cost from history instead of recomputing from current pricing', () => { + const details = extractCliproxyUsageHistoryDetails(sampleResponse); + const seeded = details.map((detail) => ({ ...detail, cost: 999 })); + const { daily } = buildCliproxyUsageHistoryAggregates(seeded); + + expect(daily[0].modelBreakdowns[0]?.cost).toBe(999); + }); + it('rebuilds daily history aggregates from merged detail history', () => { const details = extractCliproxyUsageHistoryDetails(sampleResponse); const { daily } = buildCliproxyUsageHistoryAggregates(details);