fix(cli-proxy): preserve historical analytics across syncs

This commit is contained in:
Tam Nhu Tran committed 2026-04-26 16:52:12 -04:00
1 parent 69b75c0449
commit 01fcd77573
4 files changed
+66 -9

No files matched your search

+19 -2
View File
@@ -87,6 +87,14 @@ function buildDailyTimestamp(date: string): string {
return `${date}T00:00:00.000Z`; 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( function buildLegacyHistoryDetails(
snapshot: LegacyCliproxyUsageSnapshot snapshot: LegacyCliproxyUsageSnapshot
): CliproxyUsageHistoryDetail[] { ): CliproxyUsageHistoryDetail[] {
@@ -94,7 +102,12 @@ function buildLegacyHistoryDetails(
const coveredDailyKeys = new Set<string>(); const coveredDailyKeys = new Set<string>();
for (const hour of snapshot.hourly ?? []) { 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({ details.push({
model: breakdown.modelName, model: breakdown.modelName,
timestamp: buildHourlyTimestamp(hour.hour), timestamp: buildHourlyTimestamp(hour.hour),
@@ -103,10 +116,12 @@ function buildLegacyHistoryDetails(
inputTokens: breakdown.inputTokens, inputTokens: breakdown.inputTokens,
outputTokens: breakdown.outputTokens, outputTokens: breakdown.outputTokens,
cacheReadTokens: breakdown.cacheReadTokens, cacheReadTokens: breakdown.cacheReadTokens,
requestCount: requestCounts[index] ?? 1,
cost: breakdown.cost,
failed: false, failed: false,
}); });
coveredDailyKeys.add(`${hour.hour.slice(0, 10)}|${breakdown.modelName}`); coveredDailyKeys.add(`${hour.hour.slice(0, 10)}|${breakdown.modelName}`);
} });
} }
for (const day of snapshot.daily ?? []) { for (const day of snapshot.daily ?? []) {
@@ -124,6 +139,8 @@ function buildLegacyHistoryDetails(
inputTokens: breakdown.inputTokens, inputTokens: breakdown.inputTokens,
outputTokens: breakdown.outputTokens, outputTokens: breakdown.outputTokens,
cacheReadTokens: breakdown.cacheReadTokens, cacheReadTokens: breakdown.cacheReadTokens,
requestCount: 1,
cost: breakdown.cost,
failed: false, failed: false,
}); });
} }
@@ -22,6 +22,8 @@ export interface CliproxyUsageHistoryDetail {
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
cacheReadTokens: number; cacheReadTokens: number;
requestCount: number;
cost: number;
failed: boolean; failed: boolean;
} }
@@ -30,15 +32,12 @@ interface ModelAccumulator {
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
cacheReadTokens: number; cacheReadTokens: number;
cost: number;
} }
/** Build ModelBreakdown from accumulated token counts */ /** Build ModelBreakdown from accumulated token counts */
function buildModelBreakdown(modelName: string, acc: ModelAccumulator): ModelBreakdown { function buildModelBreakdown(modelName: string, acc: ModelAccumulator): ModelBreakdown {
const { inputTokens, outputTokens, cacheReadTokens } = acc; const { inputTokens, outputTokens, cacheReadTokens, cost } = acc;
const cost = calculateCost(
{ inputTokens, outputTokens, cacheCreationTokens: 0, cacheReadTokens },
modelName
);
return { modelName, inputTokens, outputTokens, cacheCreationTokens: 0, cacheReadTokens, cost }; return { modelName, inputTokens, outputTokens, cacheCreationTokens: 0, cacheReadTokens, cost };
} }
@@ -54,6 +53,16 @@ function createHistoryDetail(
inputTokens: detail.tokens?.input_tokens ?? 0, inputTokens: detail.tokens?.input_tokens ?? 0,
outputTokens: detail.tokens?.output_tokens ?? 0, outputTokens: detail.tokens?.output_tokens ?? 0,
cacheReadTokens: detail.tokens?.cached_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, failed: detail.failed,
}; };
} }
@@ -107,6 +116,7 @@ function createHistorySignature(detail: CliproxyUsageHistoryDetail): string {
detail.inputTokens, detail.inputTokens,
detail.outputTokens, detail.outputTokens,
detail.cacheReadTokens, detail.cacheReadTokens,
detail.requestCount,
detail.failed ? '1' : '0', detail.failed ? '1' : '0',
].join('|'); ].join('|');
} }
@@ -185,15 +195,21 @@ function aggregateByKey<T>(
for (const detail of flat) { for (const detail of flat) {
const key = keyFn(detail.timestamp); const key = keyFn(detail.timestamp);
if (!buckets.has(key)) buckets.set(key, new Map()); 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<string, ModelAccumulator>; const modelMap = buckets.get(key) as Map<string, ModelAccumulator>;
if (!modelMap.has(detail.model)) { 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; const acc = modelMap.get(detail.model) as ModelAccumulator;
acc.inputTokens += detail.inputTokens; acc.inputTokens += detail.inputTokens;
acc.outputTokens += detail.outputTokens; acc.outputTokens += detail.outputTokens;
acc.cacheReadTokens += detail.cacheReadTokens; acc.cacheReadTokens += detail.cacheReadTokens;
acc.cost += detail.cost;
} }
const records: T[] = []; const records: T[] = [];
@@ -211,6 +211,7 @@ describe('cliproxy usage syncer', () => {
{ {
hour: '2026-03-01 12:00', hour: '2026-03-01 12:00',
source: 'cliproxy', source: 'cliproxy',
requestCount: 7,
inputTokens: 100, inputTokens: 100,
outputTokens: 20, outputTokens: 20,
cacheCreationTokens: 0, cacheCreationTokens: 0,
@@ -260,6 +261,21 @@ describe('cliproxy usage syncer', () => {
const cached = await loadCachedCliproxyData(); const cached = await loadCachedCliproxyData();
expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']); 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);
}); });
}); });
@@ -134,6 +134,14 @@ describe('cliproxy usage transformer', () => {
expect(merged).toHaveLength(2); 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', () => { it('rebuilds daily history aggregates from merged detail history', () => {
const details = extractCliproxyUsageHistoryDetails(sampleResponse); const details = extractCliproxyUsageHistoryDetails(sampleResponse);
const { daily } = buildCliproxyUsageHistoryAggregates(details); const { daily } = buildCliproxyUsageHistoryAggregates(details);