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

This commit is contained in:
Tam Nhu Tran
2026-04-26 16:52:12 -04:00
parent 69b75c0449
commit 01fcd77573
4 changed files with 66 additions and 9 deletions
+19 -2
View File
@@ -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<string>();
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,
});
}
@@ -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<T>(
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<string, ModelAccumulator>;
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[] = [];
@@ -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);
});
});
@@ -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);