mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user