diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts index 9dd3d5a1..fe5cd23a 100644 --- a/src/web-server/usage/cliproxy-usage-syncer.ts +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -12,30 +12,43 @@ import * as fs from 'fs'; import * as path from 'path'; import { fetchCliproxyUsageRaw } from '../../cliproxy/stats-fetcher'; import { - transformCliproxyToDailyUsage, - transformCliproxyToHourlyUsage, - transformCliproxyToMonthlyUsage, + buildCliproxyUsageHistoryAggregates, + extractCliproxyUsageHistoryDetails, + mergeCliproxyUsageHistoryDetails, + pruneCliproxyUsageHistoryDetails, + type CliproxyUsageHistoryDetail, } from './cliproxy-usage-transformer'; import type { DailyUsage, HourlyUsage, MonthlyUsage } from './types'; import { getCcsDir } from '../../utils/config-manager'; import { ok, info, warn } from '../../utils/ui'; -// --------------------------------------------------------------------------- -// Snapshot format -// --------------------------------------------------------------------------- - interface CliproxyUsageSnapshot { version: number; timestamp: number; + details: CliproxyUsageHistoryDetail[]; daily: DailyUsage[]; hourly: HourlyUsage[]; monthly: MonthlyUsage[]; } +type LegacyCliproxyUsageSnapshot = { + version?: number; + timestamp?: number; + daily?: DailyUsage[]; + hourly?: HourlyUsage[]; + monthly?: MonthlyUsage[]; +}; + type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw; -const SNAPSHOT_VERSION = 2; +const SNAPSHOT_VERSION = 3; 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) */ 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 ); -// Module-level interval ID let syncIntervalId: ReturnType | null = null; let snapshotTimestampOrdinal = 0; -// --------------------------------------------------------------------------- -// Cache directory helpers -// --------------------------------------------------------------------------- - function getCliproxyCacheDir(): string { return path.join(getCcsDir(), 'cache', 'cliproxy-usage'); } @@ -71,6 +79,88 @@ function getSnapshotTimestamp(): number { 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(); + + 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 { try { const snapshotPath = getLatestSnapshotPath(); @@ -79,23 +169,34 @@ function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null { } 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 (emitWarnings) { - console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync')); + if (snapshot.version === SNAPSHOT_VERSION) { + if (!Number.isFinite(snapshot.timestamp)) { + 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 (emitWarnings) { - console.log(info('CLIProxy snapshot timestamp invalid, will refresh on next sync')); - } - return null; + if (snapshot.version === 2) { + return migrateLegacySnapshot(snapshot as LegacyCliproxyUsageSnapshot, emitWarnings); } - return snapshot; + if (emitWarnings) { + console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync')); + } + return null; } catch (err) { if (emitWarnings) { console.log(warn('Failed to read CLIProxy snapshot:') + ` ${(err as Error).message}`); @@ -104,14 +205,55 @@ function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null { } } -// --------------------------------------------------------------------------- -// Load cached data -// --------------------------------------------------------------------------- +function buildSnapshot( + 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 { + 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<{ daily: DailyUsage[]; hourly: HourlyUsage[]; @@ -132,18 +274,9 @@ export async function loadCachedCliproxyData(): Promise<{ 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( fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw ): Promise { - const syncStartedAt = getSnapshotTimestamp(); const raw = await fetchRaw(); if (raw === null) { @@ -152,53 +285,12 @@ export async function syncCliproxyUsage( } try { - ensureCliproxyCacheDir(); - - 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')); + await writeSnapshotWithMerge(extractCliproxyUsageHistoryDetails(raw)); } catch (err) { - // Non-fatal - stale snapshot will continue to be served 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 = () => syncCliproxyUsage()): void { if (syncIntervalId !== null) { return; @@ -207,7 +299,6 @@ export function startCliproxySync(syncNow: () => Promise = () => syncClipr const intervalMin = Math.round(SYNC_INTERVAL_MS / 60_000); console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`)); - // Fire-and-forget initial sync void syncNow(); syncIntervalId = setInterval(() => { @@ -215,9 +306,6 @@ export function startCliproxySync(syncNow: () => Promise = () => syncClipr }, SYNC_INTERVAL_MS); } -/** - * Stop periodic CLIProxy usage sync. - */ export function stopCliproxySync(): void { if (syncIntervalId !== null) { clearInterval(syncIntervalId); diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts index cdd4ecb7..c1b398d0 100644 --- a/src/web-server/usage/cliproxy-usage-transformer.ts +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -13,10 +13,16 @@ import type { ModelBreakdown, DailyUsage, HourlyUsage, MonthlyUsage } from './ty // INTERNAL HELPERS // ============================================================================ -/** Flat entry pairing a model name with its request detail */ -interface FlatDetail { +/** Persisted request detail used to rebuild historical CLIProxy analytics buckets */ +export interface CliproxyUsageHistoryDetail { 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 */ @@ -36,6 +42,22 @@ function buildModelBreakdown(modelName: string, acc: ModelAccumulator): ModelBre 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 // ============================================================================ @@ -51,14 +73,16 @@ function hasTrackedUsage(detail: CliproxyRequestDetail): boolean { /** * Flatten the nested response.usage.apis[provider].models[model].details[] - * structure into a flat array. Failed requests are retained only when they - * still report tracked token usage that analytics can account for. + * structure into normalized history details. Failed requests are retained only + * 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; if (!apis) return []; - const results: FlatDetail[] = []; + const results: CliproxyUsageHistoryDetail[] = []; for (const providerData of Object.values(apis)) { const models = providerData?.models; if (!models) continue; @@ -67,20 +91,89 @@ export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): Flat if (!details) continue; for (const detail of details) { if (detail.failed && !hasTrackedUsage(detail)) continue; - results.push({ model, detail }); + results.push(createHistoryDetail(model, detail)); } } } 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(); + 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(); + 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 // ============================================================================ /** Group flat details by a time key extractor, return sorted DailyUsage-like records */ function aggregateByKey( - flat: FlatDetail[], + flat: CliproxyUsageHistoryDetail[], keyFn: (timestamp: string) => string, buildRecord: (key: string, breakdowns: ModelBreakdown[], requestCount: number) => T, sortFn: (a: T, b: T) => number @@ -89,18 +182,18 @@ function aggregateByKey( const buckets = new Map>(); const requestCounts = new Map(); - for (const { model, detail } of flat) { + 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); const modelMap = buckets.get(key) as Map; - if (!modelMap.has(model)) { - modelMap.set(model, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }); + if (!modelMap.has(detail.model)) { + modelMap.set(detail.model, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }); } - const acc = modelMap.get(model) as ModelAccumulator; - acc.inputTokens += detail.tokens?.input_tokens ?? 0; - acc.outputTokens += detail.tokens?.output_tokens ?? 0; - acc.cacheReadTokens += detail.tokens?.cached_tokens ?? 0; + const acc = modelMap.get(detail.model) as ModelAccumulator; + acc.inputTokens += detail.inputTokens; + acc.outputTokens += detail.outputTokens; + acc.cacheReadTokens += detail.cacheReadTokens; } 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) */ export function transformCliproxyToDailyUsage(response: CliproxyUsageApiResponse): DailyUsage[] { - const flat = flattenCliproxyDetails(response); + const flat = extractCliproxyUsageHistoryDetails(response); return aggregateByKey( flat, (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) */ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiResponse): HourlyUsage[] { - const flat = flattenCliproxyDetails(response); + const flat = extractCliproxyUsageHistoryDetails(response); return aggregateByKey( flat, (ts) => { @@ -182,7 +275,7 @@ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiRespons export function transformCliproxyToMonthlyUsage( response: CliproxyUsageApiResponse ): MonthlyUsage[] { - const flat = flattenCliproxyDetails(response); + const flat = extractCliproxyUsageHistoryDetails(response); return aggregateByKey( flat, (ts) => ts.slice(0, 7), @@ -200,3 +293,73 @@ export function transformCliproxyToMonthlyUsage( (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) + ), + }; +} diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index e2077bdb..54e687b8 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -154,8 +154,8 @@ describe('cliproxy usage syncer', () => { }); }); - it('keeps the newer overlapping snapshot when an older sync finishes last', async () => { - const olderSync = createDeferredFetch(buildResponse(100, '2026-03-02T10:00:00.000Z')); + it('merges unique history from overlapping syncs even when the older sync finishes last', async () => { + const olderSync = createDeferredFetch(buildResponse(100, '2026-03-01T10:00:00.000Z')); const newerSync = createDeferredFetch(buildResponse(200, '2026-03-02T11:00:00.000Z')); await runWithScopedConfigDir(ccsDir, async () => { @@ -169,8 +169,127 @@ describe('cliproxy usage syncer', () => { await Promise.all([olderWrite, newerWrite]); 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); }); }); + + 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); + }); + }); }); diff --git a/tests/unit/web-server/cliproxy-usage-transformer.test.ts b/tests/unit/web-server/cliproxy-usage-transformer.test.ts index cf5eecbf..c17185fb 100644 --- a/tests/unit/web-server/cliproxy-usage-transformer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-transformer.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'bun:test'; import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; import { - flattenCliproxyDetails, + buildCliproxyUsageHistoryAggregates, + extractCliproxyUsageHistoryDetails, + mergeCliproxyUsageHistoryDetails, transformCliproxyToDailyUsage, transformCliproxyToHourlyUsage, transformCliproxyToMonthlyUsage, @@ -97,26 +99,50 @@ const sampleResponse: CliproxyUsageApiResponse = { describe('cliproxy usage transformer', () => { 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.some( (entry) => - entry.detail.failed === true && - entry.detail.tokens.input_tokens === 40 && - entry.detail.tokens.output_tokens === 10 + entry.failed === true && + entry.inputTokens === 40 && + entry.outputTokens === 10 ) ).toBe(true); expect( flat.some( (entry) => - entry.detail.failed === true && - entry.detail.tokens.input_tokens === 0 && - entry.detail.tokens.output_tokens === 0 + entry.failed === true && + entry.inputTokens === 0 && + entry.outputTokens === 0 ) ).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', () => { const daily = transformCliproxyToDailyUsage(sampleResponse); diff --git a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts index d82cb16e..7f4525e5 100644 --- a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts +++ b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts @@ -38,8 +38,20 @@ function writeCliproxySnapshotFixture(): void { fs.mkdirSync(snapshotDir, { recursive: true }); const snapshot = { - version: 2, + version: 3, 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: [ { date: '2026-03-02',