feat(analytics): integrate CLIProxy multi-provider usage into dashboard

Add unified analytics tracking for all CLIProxy-routed providers
(Gemini/Droid, Codex, OpenCode, Antigravity, Qwen) alongside
existing Claude JSONL data.

- New transformer converts CLIProxy usage API response to
  DailyUsage/HourlyUsage/MonthlyUsage types
- New syncer periodically fetches CLIProxy data (5min interval)
  and persists snapshots to ~/.ccs/cache/cliproxy-usage/
- Aggregator treats CLIProxy as 3rd data source alongside
  default Claude config and CCS instances
- Charts automatically show multi-provider data in unified view
- Graceful degradation when CLIProxy is unavailable
This commit is contained in:
Tam Nhu Tran
2026-03-03 01:12:12 +07:00
parent c9da3ba035
commit c5124a9cee
4 changed files with 430 additions and 3 deletions
+44 -3
View File
@@ -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<CliproxyStats |
return null;
}
const data = (await response.json()) as UsageApiResponse;
const data = (await response.json()) as CliproxyUsageApiResponse;
const usage = data.usage;
// Extract models, providers, and per-account stats from the nested API structure
@@ -210,6 +213,44 @@ export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats |
}
}
/**
* Fetch raw usage response from CLIProxyAPI management API
* Returns the unprocessed API response for transformation by cliproxy-usage-transformer
*/
export async function fetchCliproxyUsageRaw(
port?: number
): Promise<CliproxyUsageApiResponse | null> {
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;
+28
View File
@@ -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();
}
@@ -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<typeof setInterval> | 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<void> {
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;
}
}
@@ -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<T>(
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<string, Map<string, ModelAccumulator>>();
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<string, ModelAccumulator>;
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)
);
}