fix(cli-proxy): durable analytics history persistence and dedupe migration

This commit is contained in:
Tam Nhu Tran committed 2026-04-26 16:44:39 -04:00
1 parent 286b5781a7
commit 69b75c0449
5 files changed
+525 -117

No files matched your search

+174 -86
View File
@@ -12,30 +12,43 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { fetchCliproxyUsageRaw } from '../../cliproxy/stats-fetcher'; import { fetchCliproxyUsageRaw } from '../../cliproxy/stats-fetcher';
import { import {
transformCliproxyToDailyUsage, buildCliproxyUsageHistoryAggregates,
transformCliproxyToHourlyUsage, extractCliproxyUsageHistoryDetails,
transformCliproxyToMonthlyUsage, mergeCliproxyUsageHistoryDetails,
pruneCliproxyUsageHistoryDetails,
type CliproxyUsageHistoryDetail,
} from './cliproxy-usage-transformer'; } from './cliproxy-usage-transformer';
import type { DailyUsage, HourlyUsage, MonthlyUsage } from './types'; import type { DailyUsage, HourlyUsage, MonthlyUsage } from './types';
import { getCcsDir } from '../../utils/config-manager'; import { getCcsDir } from '../../utils/config-manager';
import { ok, info, warn } from '../../utils/ui'; import { ok, info, warn } from '../../utils/ui';
// ---------------------------------------------------------------------------
// Snapshot format
// ---------------------------------------------------------------------------
interface CliproxyUsageSnapshot { interface CliproxyUsageSnapshot {
version: number; version: number;
timestamp: number; timestamp: number;
details: CliproxyUsageHistoryDetail[];
daily: DailyUsage[]; daily: DailyUsage[];
hourly: HourlyUsage[]; hourly: HourlyUsage[];
monthly: MonthlyUsage[]; monthly: MonthlyUsage[];
} }
type LegacyCliproxyUsageSnapshot = {
version?: number;
timestamp?: number;
daily?: DailyUsage[];
hourly?: HourlyUsage[];
monthly?: MonthlyUsage[];
};
type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw; type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw;
const SNAPSHOT_VERSION = 2; const SNAPSHOT_VERSION = 3;
const SNAPSHOT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const SNAPSHOT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const HISTORY_RETENTION_DAYS = Math.max(
30,
parseInt(process.env.CCS_CLIPROXY_HISTORY_RETENTION_DAYS ?? '365', 10) || 365
);
const HISTORY_RETENTION_MS = HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000;
const MAX_WRITE_ATTEMPTS = 3;
/** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */ /** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */
const SYNC_INTERVAL_MS = Math.max( const SYNC_INTERVAL_MS = Math.max(
@@ -43,14 +56,9 @@ const SYNC_INTERVAL_MS = Math.max(
parseInt(process.env.CCS_CLIPROXY_SYNC_INTERVAL ?? '300000', 10) || 300_000 parseInt(process.env.CCS_CLIPROXY_SYNC_INTERVAL ?? '300000', 10) || 300_000
); );
// Module-level interval ID
let syncIntervalId: ReturnType<typeof setInterval> | null = null; let syncIntervalId: ReturnType<typeof setInterval> | null = null;
let snapshotTimestampOrdinal = 0; let snapshotTimestampOrdinal = 0;
// ---------------------------------------------------------------------------
// Cache directory helpers
// ---------------------------------------------------------------------------
function getCliproxyCacheDir(): string { function getCliproxyCacheDir(): string {
return path.join(getCcsDir(), 'cache', 'cliproxy-usage'); return path.join(getCcsDir(), 'cache', 'cliproxy-usage');
} }
@@ -71,6 +79,88 @@ function getSnapshotTimestamp(): number {
return Date.now() + snapshotTimestampOrdinal / 1000; return Date.now() + snapshotTimestampOrdinal / 1000;
} }
function buildHourlyTimestamp(hour: string): string {
return `${hour.replace(' ', 'T')}:00.000Z`;
}
function buildDailyTimestamp(date: string): string {
return `${date}T00:00:00.000Z`;
}
function buildLegacyHistoryDetails(
snapshot: LegacyCliproxyUsageSnapshot
): CliproxyUsageHistoryDetail[] {
const details: CliproxyUsageHistoryDetail[] = [];
const coveredDailyKeys = new Set<string>();
for (const hour of snapshot.hourly ?? []) {
for (const breakdown of hour.modelBreakdowns) {
details.push({
model: breakdown.modelName,
timestamp: buildHourlyTimestamp(hour.hour),
source: hour.source,
authIndex: 'legacy-hourly',
inputTokens: breakdown.inputTokens,
outputTokens: breakdown.outputTokens,
cacheReadTokens: breakdown.cacheReadTokens,
failed: false,
});
coveredDailyKeys.add(`${hour.hour.slice(0, 10)}|${breakdown.modelName}`);
}
}
for (const day of snapshot.daily ?? []) {
for (const breakdown of day.modelBreakdowns) {
const key = `${day.date}|${breakdown.modelName}`;
if (coveredDailyKeys.has(key)) {
continue;
}
details.push({
model: breakdown.modelName,
timestamp: buildDailyTimestamp(day.date),
source: day.source,
authIndex: 'legacy-daily',
inputTokens: breakdown.inputTokens,
outputTokens: breakdown.outputTokens,
cacheReadTokens: breakdown.cacheReadTokens,
failed: false,
});
}
}
return details;
}
function migrateLegacySnapshot(
snapshot: LegacyCliproxyUsageSnapshot,
emitWarnings: boolean
): CliproxyUsageSnapshot | null {
const details = buildLegacyHistoryDetails(snapshot);
if (details.length === 0) {
if (emitWarnings) {
console.log(
info('CLIProxy legacy snapshot had no migratable history, will refresh on next sync')
);
}
return null;
}
if (emitWarnings) {
console.log(info('Loaded legacy CLIProxy snapshot into historical format'));
}
const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(details);
return {
version: SNAPSHOT_VERSION,
timestamp: Number.isFinite(snapshot.timestamp) ? Number(snapshot.timestamp) : Date.now(),
details,
daily,
hourly,
monthly,
};
}
function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null { function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null {
try { try {
const snapshotPath = getLatestSnapshotPath(); const snapshotPath = getLatestSnapshotPath();
@@ -79,23 +169,34 @@ function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null {
} }
const raw = fs.readFileSync(snapshotPath, 'utf-8'); const raw = fs.readFileSync(snapshotPath, 'utf-8');
const snapshot = JSON.parse(raw) as CliproxyUsageSnapshot; const snapshot = JSON.parse(raw) as CliproxyUsageSnapshot | LegacyCliproxyUsageSnapshot;
if (snapshot.version !== SNAPSHOT_VERSION) { if (snapshot.version === SNAPSHOT_VERSION) {
if (emitWarnings) { if (!Number.isFinite(snapshot.timestamp)) {
console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync')); if (emitWarnings) {
console.log(info('CLIProxy snapshot timestamp invalid, will refresh on next sync'));
}
return null;
} }
return null;
if (!Array.isArray((snapshot as CliproxyUsageSnapshot).details)) {
if (emitWarnings) {
console.log(info('CLIProxy snapshot details missing, will refresh on next sync'));
}
return null;
}
return snapshot as CliproxyUsageSnapshot;
} }
if (!Number.isFinite(snapshot.timestamp)) { if (snapshot.version === 2) {
if (emitWarnings) { return migrateLegacySnapshot(snapshot as LegacyCliproxyUsageSnapshot, emitWarnings);
console.log(info('CLIProxy snapshot timestamp invalid, will refresh on next sync'));
}
return null;
} }
return snapshot; if (emitWarnings) {
console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync'));
}
return null;
} catch (err) { } catch (err) {
if (emitWarnings) { if (emitWarnings) {
console.log(warn('Failed to read CLIProxy snapshot:') + ` ${(err as Error).message}`); console.log(warn('Failed to read CLIProxy snapshot:') + ` ${(err as Error).message}`);
@@ -104,14 +205,55 @@ function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null {
} }
} }
// --------------------------------------------------------------------------- function buildSnapshot(
// Load cached data baseDetails: CliproxyUsageHistoryDetail[],
// --------------------------------------------------------------------------- incomingDetails: CliproxyUsageHistoryDetail[]
): CliproxyUsageSnapshot {
const mergedDetails = pruneCliproxyUsageHistoryDetails(
mergeCliproxyUsageHistoryDetails(baseDetails, incomingDetails),
Date.now() - HISTORY_RETENTION_MS
);
const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(mergedDetails);
return {
version: SNAPSHOT_VERSION,
timestamp: getSnapshotTimestamp(),
details: mergedDetails,
daily,
hourly,
monthly,
};
}
async function writeSnapshotWithMerge(
incomingDetails: CliproxyUsageHistoryDetail[]
): Promise<void> {
ensureCliproxyCacheDir();
const snapshotPath = getLatestSnapshotPath();
for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
const baseSnapshot = readSnapshot(false);
const baseTimestamp = baseSnapshot?.timestamp ?? -Infinity;
const snapshot = buildSnapshot(baseSnapshot?.details ?? [], incomingDetails);
const tempFile = `${snapshotPath}.${process.pid}.${snapshot.timestamp}.tmp`;
fs.writeFileSync(tempFile, JSON.stringify(snapshot), 'utf-8');
const latestSnapshot = readSnapshot(false);
const latestTimestamp = latestSnapshot?.timestamp ?? -Infinity;
if (latestTimestamp > baseTimestamp) {
fs.rmSync(tempFile, { force: true });
continue;
}
fs.renameSync(tempFile, snapshotPath);
console.log(ok('CLIProxy usage snapshot updated'));
return;
}
throw new Error('Failed to write CLIProxy snapshot after repeated overlap retries');
}
/**
* Read the latest CLIProxy usage snapshot from disk.
* Returns empty arrays on failure (file not found, parse error, version mismatch).
*/
export async function loadCachedCliproxyData(): Promise<{ export async function loadCachedCliproxyData(): Promise<{
daily: DailyUsage[]; daily: DailyUsage[];
hourly: HourlyUsage[]; hourly: HourlyUsage[];
@@ -132,18 +274,9 @@ export async function loadCachedCliproxyData(): Promise<{
return { daily: snapshot.daily, hourly: snapshot.hourly, monthly: snapshot.monthly }; return { daily: snapshot.daily, hourly: snapshot.hourly, monthly: snapshot.monthly };
} }
// ---------------------------------------------------------------------------
// Sync
// ---------------------------------------------------------------------------
/**
* Fetch latest CLIProxy usage data and persist a snapshot to disk.
* Non-fatal: logs warning and returns early if CLIProxy is unavailable.
*/
export async function syncCliproxyUsage( export async function syncCliproxyUsage(
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw
): Promise<void> { ): Promise<void> {
const syncStartedAt = getSnapshotTimestamp();
const raw = await fetchRaw(); const raw = await fetchRaw();
if (raw === null) { if (raw === null) {
@@ -152,53 +285,12 @@ export async function syncCliproxyUsage(
} }
try { try {
ensureCliproxyCacheDir(); await writeSnapshotWithMerge(extractCliproxyUsageHistoryDetails(raw));
const daily = transformCliproxyToDailyUsage(raw);
const hourly = transformCliproxyToHourlyUsage(raw);
const monthly = transformCliproxyToMonthlyUsage(raw);
const snapshot: CliproxyUsageSnapshot = {
version: SNAPSHOT_VERSION,
timestamp: syncStartedAt,
daily,
hourly,
monthly,
};
const snapshotPath = getLatestSnapshotPath();
const tempFile = `${snapshotPath}.${process.pid}.${syncStartedAt}.tmp`;
const currentSnapshot = readSnapshot(false);
if (currentSnapshot && currentSnapshot.timestamp > snapshot.timestamp) {
console.log(info('Skipping stale CLIProxy snapshot write'));
return;
}
fs.writeFileSync(tempFile, JSON.stringify(snapshot), 'utf-8');
const latestSnapshot = readSnapshot(false);
if (latestSnapshot && latestSnapshot.timestamp > snapshot.timestamp) {
fs.rmSync(tempFile, { force: true });
console.log(info('Skipping stale CLIProxy snapshot write'));
return;
}
fs.renameSync(tempFile, snapshotPath);
console.log(ok('CLIProxy usage snapshot updated'));
} catch (err) { } catch (err) {
// Non-fatal - stale snapshot will continue to be served
console.log(warn('Failed to write CLIProxy snapshot:') + ` ${(err as Error).message}`); console.log(warn('Failed to write CLIProxy snapshot:') + ` ${(err as Error).message}`);
} }
} }
// ---------------------------------------------------------------------------
// Interval management
// ---------------------------------------------------------------------------
/**
* Start periodic CLIProxy usage sync (every 5 minutes).
* Performs an immediate sync on startup.
*/
export function startCliproxySync(syncNow: () => Promise<void> = () => syncCliproxyUsage()): void { export function startCliproxySync(syncNow: () => Promise<void> = () => syncCliproxyUsage()): void {
if (syncIntervalId !== null) { if (syncIntervalId !== null) {
return; return;
@@ -207,7 +299,6 @@ export function startCliproxySync(syncNow: () => Promise<void> = () => syncClipr
const intervalMin = Math.round(SYNC_INTERVAL_MS / 60_000); const intervalMin = Math.round(SYNC_INTERVAL_MS / 60_000);
console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`)); console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`));
// Fire-and-forget initial sync
void syncNow(); void syncNow();
syncIntervalId = setInterval(() => { syncIntervalId = setInterval(() => {
@@ -215,9 +306,6 @@ export function startCliproxySync(syncNow: () => Promise<void> = () => syncClipr
}, SYNC_INTERVAL_MS); }, SYNC_INTERVAL_MS);
} }
/**
* Stop periodic CLIProxy usage sync.
*/
export function stopCliproxySync(): void { export function stopCliproxySync(): void {
if (syncIntervalId !== null) { if (syncIntervalId !== null) {
clearInterval(syncIntervalId); clearInterval(syncIntervalId);
@@ -13,10 +13,16 @@ import type { ModelBreakdown, DailyUsage, HourlyUsage, MonthlyUsage } from './ty
// INTERNAL HELPERS // INTERNAL HELPERS
// ============================================================================ // ============================================================================
/** Flat entry pairing a model name with its request detail */ /** Persisted request detail used to rebuild historical CLIProxy analytics buckets */
interface FlatDetail { export interface CliproxyUsageHistoryDetail {
model: string; model: string;
detail: CliproxyRequestDetail; timestamp: string;
source: string;
authIndex: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
failed: boolean;
} }
/** Accumulator for token counts per model per time bucket */ /** Accumulator for token counts per model per time bucket */
@@ -36,6 +42,22 @@ function buildModelBreakdown(modelName: string, acc: ModelAccumulator): ModelBre
return { modelName, inputTokens, outputTokens, cacheCreationTokens: 0, cacheReadTokens, cost }; return { modelName, inputTokens, outputTokens, cacheCreationTokens: 0, cacheReadTokens, cost };
} }
function createHistoryDetail(
model: string,
detail: CliproxyRequestDetail
): CliproxyUsageHistoryDetail {
return {
model,
timestamp: detail.timestamp,
source: detail.source,
authIndex: String(detail.auth_index),
inputTokens: detail.tokens?.input_tokens ?? 0,
outputTokens: detail.tokens?.output_tokens ?? 0,
cacheReadTokens: detail.tokens?.cached_tokens ?? 0,
failed: detail.failed,
};
}
// ============================================================================ // ============================================================================
// FLATTEN // FLATTEN
// ============================================================================ // ============================================================================
@@ -51,14 +73,16 @@ function hasTrackedUsage(detail: CliproxyRequestDetail): boolean {
/** /**
* Flatten the nested response.usage.apis[provider].models[model].details[] * Flatten the nested response.usage.apis[provider].models[model].details[]
* structure into a flat array. Failed requests are retained only when they * structure into normalized history details. Failed requests are retained only
* still report tracked token usage that analytics can account for. * when they still report tracked token usage that analytics can account for.
*/ */
export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): FlatDetail[] { export function extractCliproxyUsageHistoryDetails(
response: CliproxyUsageApiResponse
): CliproxyUsageHistoryDetail[] {
const apis = response?.usage?.apis; const apis = response?.usage?.apis;
if (!apis) return []; if (!apis) return [];
const results: FlatDetail[] = []; const results: CliproxyUsageHistoryDetail[] = [];
for (const providerData of Object.values(apis)) { for (const providerData of Object.values(apis)) {
const models = providerData?.models; const models = providerData?.models;
if (!models) continue; if (!models) continue;
@@ -67,20 +91,89 @@ export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): Flat
if (!details) continue; if (!details) continue;
for (const detail of details) { for (const detail of details) {
if (detail.failed && !hasTrackedUsage(detail)) continue; if (detail.failed && !hasTrackedUsage(detail)) continue;
results.push({ model, detail }); results.push(createHistoryDetail(model, detail));
} }
} }
} }
return results; return results;
} }
function createHistorySignature(detail: CliproxyUsageHistoryDetail): string {
return [
detail.model,
detail.timestamp,
detail.source,
detail.authIndex,
detail.inputTokens,
detail.outputTokens,
detail.cacheReadTokens,
detail.failed ? '1' : '0',
].join('|');
}
export function mergeCliproxyUsageHistoryDetails(
existing: CliproxyUsageHistoryDetail[],
incoming: CliproxyUsageHistoryDetail[]
): CliproxyUsageHistoryDetail[] {
const existingCounts = new Map<string, { detail: CliproxyUsageHistoryDetail; count: number }>();
for (const detail of existing) {
const signature = createHistorySignature(detail);
const entry = existingCounts.get(signature);
if (entry) {
entry.count += 1;
} else {
existingCounts.set(signature, { detail, count: 1 });
}
}
const incomingCounts = new Map<string, { detail: CliproxyUsageHistoryDetail; count: number }>();
for (const detail of incoming) {
const signature = createHistorySignature(detail);
const entry = incomingCounts.get(signature);
if (entry) {
entry.count += 1;
} else {
incomingCounts.set(signature, { detail, count: 1 });
}
}
for (const [signature, incomingEntry] of incomingCounts) {
const existingEntry = existingCounts.get(signature);
if (!existingEntry || incomingEntry.count > existingEntry.count) {
existingCounts.set(signature, {
detail: incomingEntry.detail,
count: incomingEntry.count,
});
}
}
const merged: CliproxyUsageHistoryDetail[] = [];
for (const { detail, count } of existingCounts.values()) {
for (let index = 0; index < count; index++) {
merged.push({ ...detail });
}
}
return merged.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
}
export function pruneCliproxyUsageHistoryDetails(
details: CliproxyUsageHistoryDetail[],
oldestTimestamp: number
): CliproxyUsageHistoryDetail[] {
return details.filter((detail) => {
const timestamp = Date.parse(detail.timestamp);
return Number.isFinite(timestamp) && timestamp >= oldestTimestamp;
});
}
// ============================================================================ // ============================================================================
// GENERIC AGGREGATOR // GENERIC AGGREGATOR
// ============================================================================ // ============================================================================
/** Group flat details by a time key extractor, return sorted DailyUsage-like records */ /** Group flat details by a time key extractor, return sorted DailyUsage-like records */
function aggregateByKey<T>( function aggregateByKey<T>(
flat: FlatDetail[], flat: CliproxyUsageHistoryDetail[],
keyFn: (timestamp: string) => string, keyFn: (timestamp: string) => string,
buildRecord: (key: string, breakdowns: ModelBreakdown[], requestCount: number) => T, buildRecord: (key: string, breakdowns: ModelBreakdown[], requestCount: number) => T,
sortFn: (a: T, b: T) => number sortFn: (a: T, b: T) => number
@@ -89,18 +182,18 @@ function aggregateByKey<T>(
const buckets = new Map<string, Map<string, ModelAccumulator>>(); const buckets = new Map<string, Map<string, ModelAccumulator>>();
const requestCounts = new Map<string, number>(); const requestCounts = new Map<string, number>();
for (const { model, 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) + 1);
const modelMap = buckets.get(key) as Map<string, ModelAccumulator>; const modelMap = buckets.get(key) as Map<string, ModelAccumulator>;
if (!modelMap.has(model)) { if (!modelMap.has(detail.model)) {
modelMap.set(model, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }); modelMap.set(detail.model, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 });
} }
const acc = modelMap.get(model) as ModelAccumulator; const acc = modelMap.get(detail.model) as ModelAccumulator;
acc.inputTokens += detail.tokens?.input_tokens ?? 0; acc.inputTokens += detail.inputTokens;
acc.outputTokens += detail.tokens?.output_tokens ?? 0; acc.outputTokens += detail.outputTokens;
acc.cacheReadTokens += detail.tokens?.cached_tokens ?? 0; acc.cacheReadTokens += detail.cacheReadTokens;
} }
const records: T[] = []; const records: T[] = [];
@@ -125,7 +218,7 @@ function sumField(breakdowns: ModelBreakdown[], field: keyof ModelBreakdown): nu
/** Transform CLIProxy usage response into DailyUsage array (sorted descending by date) */ /** Transform CLIProxy usage response into DailyUsage array (sorted descending by date) */
export function transformCliproxyToDailyUsage(response: CliproxyUsageApiResponse): DailyUsage[] { export function transformCliproxyToDailyUsage(response: CliproxyUsageApiResponse): DailyUsage[] {
const flat = flattenCliproxyDetails(response); const flat = extractCliproxyUsageHistoryDetails(response);
return aggregateByKey( return aggregateByKey(
flat, flat,
(ts) => ts.slice(0, 10), (ts) => ts.slice(0, 10),
@@ -150,7 +243,7 @@ export function transformCliproxyToDailyUsage(response: CliproxyUsageApiResponse
/** Transform CLIProxy usage response into HourlyUsage array (sorted descending by hour) */ /** Transform CLIProxy usage response into HourlyUsage array (sorted descending by hour) */
export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiResponse): HourlyUsage[] { export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiResponse): HourlyUsage[] {
const flat = flattenCliproxyDetails(response); const flat = extractCliproxyUsageHistoryDetails(response);
return aggregateByKey( return aggregateByKey(
flat, flat,
(ts) => { (ts) => {
@@ -182,7 +275,7 @@ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiRespons
export function transformCliproxyToMonthlyUsage( export function transformCliproxyToMonthlyUsage(
response: CliproxyUsageApiResponse response: CliproxyUsageApiResponse
): MonthlyUsage[] { ): MonthlyUsage[] {
const flat = flattenCliproxyDetails(response); const flat = extractCliproxyUsageHistoryDetails(response);
return aggregateByKey( return aggregateByKey(
flat, flat,
(ts) => ts.slice(0, 7), (ts) => ts.slice(0, 7),
@@ -200,3 +293,73 @@ export function transformCliproxyToMonthlyUsage(
(a, b) => b.month.localeCompare(a.month) (a, b) => b.month.localeCompare(a.month)
); );
} }
export function buildCliproxyUsageHistoryAggregates(details: CliproxyUsageHistoryDetail[]): {
daily: DailyUsage[];
hourly: HourlyUsage[];
monthly: MonthlyUsage[];
} {
return {
daily: aggregateByKey(
details,
(timestamp) => timestamp.slice(0, 10),
(date, breakdowns) => {
const totalCost = sumField(breakdowns, 'cost');
return {
date,
source: 'cliproxy',
inputTokens: sumField(breakdowns, 'inputTokens'),
outputTokens: sumField(breakdowns, 'outputTokens'),
cacheCreationTokens: 0,
cacheReadTokens: sumField(breakdowns, 'cacheReadTokens'),
cost: totalCost,
totalCost,
modelsUsed: breakdowns.map((breakdown) => breakdown.modelName),
modelBreakdowns: breakdowns,
};
},
(a, b) => b.date.localeCompare(a.date)
),
hourly: aggregateByKey(
details,
(timestamp) => {
const date = timestamp.slice(0, 10);
const hour = timestamp.slice(11, 13) || '00';
return `${date} ${hour}:00`;
},
(hour, breakdowns, requestCount) => {
const totalCost = sumField(breakdowns, 'cost');
return {
hour,
source: 'cliproxy',
inputTokens: sumField(breakdowns, 'inputTokens'),
outputTokens: sumField(breakdowns, 'outputTokens'),
cacheCreationTokens: 0,
cacheReadTokens: sumField(breakdowns, 'cacheReadTokens'),
cost: totalCost,
totalCost,
modelsUsed: breakdowns.map((breakdown) => breakdown.modelName),
modelBreakdowns: breakdowns,
requestCount,
};
},
(a, b) => b.hour.localeCompare(a.hour)
),
monthly: aggregateByKey(
details,
(timestamp) => timestamp.slice(0, 7),
(month, breakdowns) => ({
month,
source: 'cliproxy',
inputTokens: sumField(breakdowns, 'inputTokens'),
outputTokens: sumField(breakdowns, 'outputTokens'),
cacheCreationTokens: 0,
cacheReadTokens: sumField(breakdowns, 'cacheReadTokens'),
totalCost: sumField(breakdowns, 'cost'),
modelsUsed: breakdowns.map((breakdown) => breakdown.modelName),
modelBreakdowns: breakdowns,
}),
(a, b) => b.month.localeCompare(a.month)
),
};
}
@@ -154,8 +154,8 @@ describe('cliproxy usage syncer', () => {
}); });
}); });
it('keeps the newer overlapping snapshot when an older sync finishes last', async () => { it('merges unique history from overlapping syncs even when the older sync finishes last', async () => {
const olderSync = createDeferredFetch(buildResponse(100, '2026-03-02T10:00:00.000Z')); const olderSync = createDeferredFetch(buildResponse(100, '2026-03-01T10:00:00.000Z'));
const newerSync = createDeferredFetch(buildResponse(200, '2026-03-02T11:00:00.000Z')); const newerSync = createDeferredFetch(buildResponse(200, '2026-03-02T11:00:00.000Z'));
await runWithScopedConfigDir(ccsDir, async () => { await runWithScopedConfigDir(ccsDir, async () => {
@@ -169,8 +169,127 @@ describe('cliproxy usage syncer', () => {
await Promise.all([olderWrite, newerWrite]); await Promise.all([olderWrite, newerWrite]);
const cached = await loadCachedCliproxyData(); const cached = await loadCachedCliproxyData();
expect(cached.daily).toHaveLength(1); expect(cached.daily).toHaveLength(2);
expect(cached.daily.find((entry) => entry.date === '2026-03-01')?.inputTokens).toBe(100);
expect(cached.daily[0].inputTokens).toBe(200); expect(cached.daily[0].inputTokens).toBe(200);
}); });
}); });
it('migrates legacy v2 snapshots forward before merging new history', async () => {
await runWithScopedConfigDir(ccsDir, async () => {
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
fs.writeFileSync(
snapshotPath,
JSON.stringify({
version: 2,
timestamp: Date.now() - 60_000,
daily: [
{
date: '2026-03-01',
source: 'cliproxy',
inputTokens: 100,
outputTokens: 20,
cacheCreationTokens: 0,
cacheReadTokens: 10,
cost: 0.2,
totalCost: 0.2,
modelsUsed: ['gemini-2.5-pro'],
modelBreakdowns: [
{
modelName: 'gemini-2.5-pro',
inputTokens: 100,
outputTokens: 20,
cacheCreationTokens: 0,
cacheReadTokens: 10,
cost: 0.2,
},
],
},
],
hourly: [
{
hour: '2026-03-01 12:00',
source: 'cliproxy',
inputTokens: 100,
outputTokens: 20,
cacheCreationTokens: 0,
cacheReadTokens: 10,
cost: 0.2,
totalCost: 0.2,
modelsUsed: ['gemini-2.5-pro'],
modelBreakdowns: [
{
modelName: 'gemini-2.5-pro',
inputTokens: 100,
outputTokens: 20,
cacheCreationTokens: 0,
cacheReadTokens: 10,
cost: 0.2,
},
],
},
],
monthly: [
{
month: '2026-03',
source: 'cliproxy',
inputTokens: 100,
outputTokens: 20,
cacheCreationTokens: 0,
cacheReadTokens: 10,
totalCost: 0.2,
modelsUsed: ['gemini-2.5-pro'],
modelBreakdowns: [
{
modelName: 'gemini-2.5-pro',
inputTokens: 100,
outputTokens: 20,
cacheCreationTokens: 0,
cacheReadTokens: 10,
cost: 0.2,
},
],
},
],
}),
'utf-8'
);
await syncCliproxyUsage(() => Promise.resolve(buildResponse(200, '2026-03-02T12:00:00.000Z')));
const cached = await loadCachedCliproxyData();
expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']);
});
});
it('preserves prior-day history when a later sync only returns the current window', async () => {
await runWithScopedConfigDir(ccsDir, async () => {
await syncCliproxyUsage(() =>
Promise.resolve(buildResponse(100, '2026-03-01T12:00:00.000Z'))
);
await syncCliproxyUsage(() =>
Promise.resolve(buildResponse(200, '2026-03-02T12:00:00.000Z'))
);
const cached = await loadCachedCliproxyData();
expect(cached.daily).toHaveLength(2);
expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']);
expect(cached.daily.find((entry) => entry.date === '2026-03-01')?.inputTokens).toBe(100);
expect(cached.daily.find((entry) => entry.date === '2026-03-02')?.inputTokens).toBe(200);
});
});
it('does not double count when the same snapshot window is synced twice', async () => {
await runWithScopedConfigDir(ccsDir, async () => {
const repeatedResponse = buildResponse(250, '2026-03-02T12:00:00.000Z');
await syncCliproxyUsage(() => Promise.resolve(repeatedResponse));
await syncCliproxyUsage(() => Promise.resolve(repeatedResponse));
const cached = await loadCachedCliproxyData();
expect(cached.daily).toHaveLength(1);
expect(cached.daily[0].inputTokens).toBe(250);
expect(cached.hourly[0].requestCount).toBe(1);
});
});
}); });
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'bun:test';
import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher';
import { import {
flattenCliproxyDetails, buildCliproxyUsageHistoryAggregates,
extractCliproxyUsageHistoryDetails,
mergeCliproxyUsageHistoryDetails,
transformCliproxyToDailyUsage, transformCliproxyToDailyUsage,
transformCliproxyToHourlyUsage, transformCliproxyToHourlyUsage,
transformCliproxyToMonthlyUsage, transformCliproxyToMonthlyUsage,
@@ -97,26 +99,50 @@ const sampleResponse: CliproxyUsageApiResponse = {
describe('cliproxy usage transformer', () => { describe('cliproxy usage transformer', () => {
it('retains failed requests when they carry usage and skips zero-usage failures', () => { it('retains failed requests when they carry usage and skips zero-usage failures', () => {
const flat = flattenCliproxyDetails(sampleResponse); const flat = extractCliproxyUsageHistoryDetails(sampleResponse);
expect(flat).toHaveLength(4); expect(flat).toHaveLength(4);
expect( expect(
flat.some( flat.some(
(entry) => (entry) =>
entry.detail.failed === true && entry.failed === true &&
entry.detail.tokens.input_tokens === 40 && entry.inputTokens === 40 &&
entry.detail.tokens.output_tokens === 10 entry.outputTokens === 10
) )
).toBe(true); ).toBe(true);
expect( expect(
flat.some( flat.some(
(entry) => (entry) =>
entry.detail.failed === true && entry.failed === true &&
entry.detail.tokens.input_tokens === 0 && entry.inputTokens === 0 &&
entry.detail.tokens.output_tokens === 0 entry.outputTokens === 0
) )
).toBe(false); ).toBe(false);
}); });
it('deduplicates repeated snapshot details when merging history', () => {
const details = extractCliproxyUsageHistoryDetails(sampleResponse);
const merged = mergeCliproxyUsageHistoryDetails(details, details);
expect(merged).toHaveLength(details.length);
});
it('preserves legitimate duplicate requests when the incoming batch has more occurrences', () => {
const details = extractCliproxyUsageHistoryDetails(sampleResponse);
const repeated = [details[0], { ...details[0] }];
const merged = mergeCliproxyUsageHistoryDetails([details[0]], repeated);
expect(merged).toHaveLength(2);
});
it('rebuilds daily history aggregates from merged detail history', () => {
const details = extractCliproxyUsageHistoryDetails(sampleResponse);
const { daily } = buildCliproxyUsageHistoryAggregates(details);
expect(daily).toHaveLength(2);
expect(daily[0].date).toBe('2026-03-02');
expect(daily[1].date).toBe('2026-03-01');
});
it('transforms daily usage with aggregated model totals', () => { it('transforms daily usage with aggregated model totals', () => {
const daily = transformCliproxyToDailyUsage(sampleResponse); const daily = transformCliproxyToDailyUsage(sampleResponse);
@@ -38,8 +38,20 @@ function writeCliproxySnapshotFixture(): void {
fs.mkdirSync(snapshotDir, { recursive: true }); fs.mkdirSync(snapshotDir, { recursive: true });
const snapshot = { const snapshot = {
version: 2, version: 3,
timestamp: Date.now(), timestamp: Date.now(),
details: [
{
model: 'gemini-2.5-pro',
timestamp: '2026-03-02T10:00:00.000Z',
source: 'account-a',
authIndex: '0',
inputTokens: 50,
outputTokens: 10,
cacheReadTokens: 5,
failed: false,
},
],
daily: [ daily: [
{ {
date: '2026-03-02', date: '2026-03-02',