diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index 6acaf8b7..72aacd0c 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -56,7 +56,7 @@ export interface CliproxyStats { } /** Request detail from CLIProxyAPI */ -interface RequestDetail { +export interface CliproxyRequestDetail { timestamp: string; source: string; auth_index: number; @@ -70,8 +70,11 @@ interface RequestDetail { failed: boolean; } +/** @deprecated Use CliproxyRequestDetail instead */ +type RequestDetail = CliproxyRequestDetail; + /** Usage API response from CLIProxyAPI /v0/management/usage endpoint */ -interface UsageApiResponse { +export interface CliproxyUsageApiResponse { failed_requests?: number; usage?: { total_requests?: number; @@ -130,7 +133,7 @@ export async function fetchCliproxyStats(port?: number): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const target = getProxyTarget(); + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/v0/management/usage'); + + const headers = target.isRemote + ? buildManagementHeaders(target) + : { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` }; + + const response = await fetch(url, { + signal: controller.signal, + headers, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + return (await response.json()) as CliproxyUsageApiResponse; + } catch { + return null; + } +} + /** OpenAI-compatible model object from /v1/models endpoint */ export interface CliproxyModel { id: string; diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index 5d30676e..ca84bc5e 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -25,6 +25,11 @@ import { } from './disk-cache'; import { ok, info, fail } from '../../utils/ui'; import { getCcsDir } from '../../utils/config-manager'; +import { + loadCachedCliproxyData, + startCliproxySync, + stopCliproxySync, +} from './cliproxy-usage-syncer'; // ============================================================================ // Multi-Instance Support - Aggregate usage from CCS profiles @@ -327,6 +332,19 @@ async function refreshFromSource(): Promise<{ console.log(info(`Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`)); } + // Load CLIProxy usage data (from local snapshot cache) + try { + const cliproxyData = await loadCachedCliproxyData(); + if (cliproxyData.daily.length > 0) { + allDailySources.push(cliproxyData.daily); + allHourlySources.push(cliproxyData.hourly); + allMonthlySources.push(cliproxyData.monthly); + console.log(info('Included CLIProxy usage data')); + } + } catch (err) { + console.error(fail(`Failed to load CLIProxy usage data: ${err}`)); + } + // Merge all data sources const daily = mergeDailyData(allDailySources); const hourly = mergeHourlyData(allHourlySources); @@ -479,6 +497,9 @@ export async function prewarmUsageCache(): Promise<{ console.log(info('Pre-warming usage cache...')); try { + // Start CLIProxy usage syncer early (runs in background every 5 min) + startCliproxySync(); + const diskCache = readDiskCache(); // Fresh disk cache - use it directly @@ -539,3 +560,10 @@ export async function prewarmUsageCache(): Promise<{ throw err; } } + +/** + * Shutdown usage aggregator cleanly (stops background syncer) + */ +export function shutdownUsageAggregator(): void { + stopCliproxySync(); +} diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts new file mode 100644 index 00000000..6d894e49 --- /dev/null +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -0,0 +1,169 @@ +/** + * CLIProxy Usage Syncer + * + * Periodically fetches CLIProxy usage data, transforms it, and persists + * snapshots locally so analytics data survives CLIProxy restarts. + * + * Snapshot location: ~/.ccs/cache/cliproxy-usage/latest.json + * Sync interval: 5 minutes + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { fetchCliproxyUsageRaw } from '../../cliproxy/stats-fetcher'; +import { + transformCliproxyToDailyUsage, + transformCliproxyToHourlyUsage, + transformCliproxyToMonthlyUsage, +} 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; + daily: DailyUsage[]; + hourly: HourlyUsage[]; + monthly: MonthlyUsage[]; +} + +const SNAPSHOT_VERSION = 1; + +// Module-level interval ID +let syncIntervalId: ReturnType | null = null; + +// --------------------------------------------------------------------------- +// Cache directory helpers +// --------------------------------------------------------------------------- + +function getCliproxyCacheDir(): string { + return path.join(getCcsDir(), 'cache', 'cliproxy-usage'); +} + +function getLatestSnapshotPath(): string { + return path.join(getCliproxyCacheDir(), 'latest.json'); +} + +function ensureCliproxyCacheDir(): void { + const dir = getCliproxyCacheDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +// --------------------------------------------------------------------------- +// Load cached data +// --------------------------------------------------------------------------- + +/** + * 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[]; + monthly: MonthlyUsage[]; +}> { + const empty = { daily: [], hourly: [], monthly: [] }; + + try { + const snapshotPath = getLatestSnapshotPath(); + if (!fs.existsSync(snapshotPath)) { + return empty; + } + + const raw = fs.readFileSync(snapshotPath, 'utf-8'); + const snapshot: CliproxyUsageSnapshot = JSON.parse(raw); + + if (snapshot.version !== SNAPSHOT_VERSION) { + console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync')); + return empty; + } + + return { daily: snapshot.daily, hourly: snapshot.hourly, monthly: snapshot.monthly }; + } catch (err) { + console.log(warn('Failed to read CLIProxy snapshot:') + ` ${(err as Error).message}`); + return empty; + } +} + +// --------------------------------------------------------------------------- +// 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(): Promise { + const raw = await fetchCliproxyUsageRaw(); + + if (raw === null) { + console.log(warn('CLIProxy usage sync skipped: proxy unavailable')); + return; + } + + try { + ensureCliproxyCacheDir(); + + const daily = transformCliproxyToDailyUsage(raw); + const hourly = transformCliproxyToHourlyUsage(raw); + const monthly = transformCliproxyToMonthlyUsage(raw); + + const snapshot: CliproxyUsageSnapshot = { + version: SNAPSHOT_VERSION, + timestamp: Date.now(), + daily, + hourly, + monthly, + }; + + // Atomic write: temp file + rename + const snapshotPath = getLatestSnapshotPath(); + const tempFile = snapshotPath + '.tmp'; + fs.writeFileSync(tempFile, JSON.stringify(snapshot), 'utf-8'); + fs.renameSync(tempFile, snapshotPath); + + console.log(ok('CLIProxy usage snapshot updated')); + } 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(): void { + console.log(info('Starting CLIProxy usage sync (interval: 5 min)')); + + // Fire-and-forget initial sync + void syncCliproxyUsage(); + + syncIntervalId = setInterval( + () => { + void syncCliproxyUsage(); + }, + 5 * 60 * 1000 + ); +} + +/** + * Stop periodic CLIProxy usage sync. + */ +export function stopCliproxySync(): void { + if (syncIntervalId !== null) { + clearInterval(syncIntervalId); + syncIntervalId = null; + } +} diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts new file mode 100644 index 00000000..aff50311 --- /dev/null +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -0,0 +1,189 @@ +/** + * CLIProxy Usage Transformer + * + * Transforms CLIProxy's usage API response into DailyUsage/HourlyUsage/MonthlyUsage + * types compatible with the CCS analytics dashboard. + */ + +import type { CliproxyUsageApiResponse, CliproxyRequestDetail } from '../../cliproxy/stats-fetcher'; +import { calculateCost } from '../model-pricing'; +import type { ModelBreakdown, DailyUsage, HourlyUsage, MonthlyUsage } from './types'; + +// ============================================================================ +// INTERNAL HELPERS +// ============================================================================ + +/** Flat entry pairing a model name with its request detail */ +interface FlatDetail { + model: string; + detail: CliproxyRequestDetail; +} + +/** Accumulator for token counts per model per time bucket */ +interface ModelAccumulator { + inputTokens: number; + outputTokens: number; + cacheReadTokens: 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 + ); + return { modelName, inputTokens, outputTokens, cacheCreationTokens: 0, cacheReadTokens, cost }; +} + +// ============================================================================ +// FLATTEN +// ============================================================================ + +/** + * Flatten the nested response.usage.apis[provider].models[model].details[] + * structure into a flat array. Failed requests are skipped. + */ +export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): FlatDetail[] { + const apis = response?.usage?.apis; + if (!apis) return []; + + const results: FlatDetail[] = []; + for (const providerData of Object.values(apis)) { + const models = providerData?.models; + if (!models) continue; + for (const [model, modelData] of Object.entries(models)) { + const details = modelData?.details; + if (!details) continue; + for (const detail of details) { + if (detail.failed) continue; + results.push({ model, detail }); + } + } + } + return results; +} + +// ============================================================================ +// GENERIC AGGREGATOR +// ============================================================================ + +/** Group flat details by a time key extractor, return sorted DailyUsage-like records */ +function aggregateByKey( + flat: FlatDetail[], + keyFn: (timestamp: string) => string, + buildRecord: (key: string, breakdowns: ModelBreakdown[]) => T, + sortFn: (a: T, b: T) => number +): T[] { + // bucket: timeKey -> modelName -> accumulator + const buckets = new Map>(); + + for (const { model, detail } of flat) { + const key = keyFn(detail.timestamp); + if (!buckets.has(key)) buckets.set(key, new Map()); + const modelMap = buckets.get(key) as Map; + if (!modelMap.has(model)) { + modelMap.set(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 records: T[] = []; + Array.from(buckets.entries()).forEach(([key, modelMap]) => { + const breakdowns = Array.from(modelMap.entries()).map(([name, acc]) => + buildModelBreakdown(name, acc) + ); + records.push(buildRecord(key, breakdowns)); + }); + + return records.sort(sortFn); +} + +/** Sum token field across all breakdowns */ +function sumField(breakdowns: ModelBreakdown[], field: keyof ModelBreakdown): number { + return breakdowns.reduce((acc, b) => acc + (b[field] as number), 0); +} + +// ============================================================================ +// TRANSFORMS +// ============================================================================ + +/** Transform CLIProxy usage response into DailyUsage array (sorted descending by date) */ +export function transformCliproxyToDailyUsage(response: CliproxyUsageApiResponse): DailyUsage[] { + const flat = flattenCliproxyDetails(response); + return aggregateByKey( + flat, + (ts) => ts.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((b) => b.modelName), + modelBreakdowns: breakdowns, + }; + }, + (a, b) => b.date.localeCompare(a.date) + ); +} + +/** Transform CLIProxy usage response into HourlyUsage array (sorted descending by hour) */ +export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiResponse): HourlyUsage[] { + const flat = flattenCliproxyDetails(response); + return aggregateByKey( + flat, + (ts) => { + const date = ts.slice(0, 10); + const hour = ts.slice(11, 13) || '00'; + return `${date} ${hour}:00`; + }, + (hour, breakdowns) => { + 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((b) => b.modelName), + modelBreakdowns: breakdowns, + }; + }, + (a, b) => b.hour.localeCompare(a.hour) + ); +} + +/** Transform CLIProxy usage response into MonthlyUsage array (sorted descending by month) */ +export function transformCliproxyToMonthlyUsage( + response: CliproxyUsageApiResponse +): MonthlyUsage[] { + const flat = flattenCliproxyDetails(response); + return aggregateByKey( + flat, + (ts) => ts.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((b) => b.modelName), + modelBreakdowns: breakdowns, + }), + (a, b) => b.month.localeCompare(a.month) + ); +}