mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-14 16:18:52 +00:00
344 lines
10 KiB
TypeScript
344 lines
10 KiB
TypeScript
/**
|
|
* 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/services/stats-fetcher';
|
|
import {
|
|
buildCliproxyUsageHistoryAggregates,
|
|
extractCliproxyUsageHistoryDetails,
|
|
mergeCliproxyUsageHistoryDetails,
|
|
pruneCliproxyUsageHistoryDetails,
|
|
type CliproxyUsageHistoryDetail,
|
|
} from './cliproxy-usage-transformer';
|
|
import type { DailyUsage, HourlyUsage, MonthlyUsage } from './types';
|
|
|
|
import { ok, info, warn } from '../../utils/ui';
|
|
import { getCcsDir } from '../../config/config-loader-facade';
|
|
|
|
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 = 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;
|
|
const PRIVATE_DIR_MODE = 0o700;
|
|
const PRIVATE_FILE_MODE = 0o600;
|
|
|
|
/** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */
|
|
const SYNC_INTERVAL_MS = Math.max(
|
|
30_000,
|
|
parseInt(process.env.CCS_CLIPROXY_SYNC_INTERVAL ?? '300000', 10) || 300_000
|
|
);
|
|
|
|
let syncIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
let snapshotTimestampOrdinal = 0;
|
|
|
|
function getCliproxyCacheDir(): string {
|
|
return path.join(getCcsDir(), 'cache', 'cliproxy-usage');
|
|
}
|
|
|
|
function getLatestSnapshotPath(): string {
|
|
return path.join(getCliproxyCacheDir(), 'latest.json');
|
|
}
|
|
|
|
function ensurePrivateDirectory(dir: string): void {
|
|
fs.mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
fs.chmodSync(dir, PRIVATE_DIR_MODE);
|
|
}
|
|
|
|
function ensureCliproxyCacheDir(): void {
|
|
const ccsDir = getCcsDir();
|
|
const cacheDir = path.join(ccsDir, 'cache');
|
|
const cliproxyCacheDir = path.join(cacheDir, 'cliproxy-usage');
|
|
|
|
ensurePrivateDirectory(ccsDir);
|
|
ensurePrivateDirectory(cacheDir);
|
|
ensurePrivateDirectory(cliproxyCacheDir);
|
|
}
|
|
|
|
function getSnapshotTimestamp(): number {
|
|
snapshotTimestampOrdinal = (snapshotTimestampOrdinal + 1) % 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 distributeRequestCounts(total: number, buckets: number): number[] {
|
|
if (buckets <= 0) return [];
|
|
const normalizedTotal = Math.max(buckets, total);
|
|
const base = Math.floor(normalizedTotal / buckets);
|
|
const remainder = normalizedTotal % buckets;
|
|
return Array.from({ length: buckets }, (_value, index) => base + (index < remainder ? 1 : 0));
|
|
}
|
|
|
|
function buildLegacyHistoryDetails(
|
|
snapshot: LegacyCliproxyUsageSnapshot
|
|
): CliproxyUsageHistoryDetail[] {
|
|
const details: CliproxyUsageHistoryDetail[] = [];
|
|
const coveredDailyKeys = new Set<string>();
|
|
|
|
for (const hour of snapshot.hourly ?? []) {
|
|
const requestCounts = distributeRequestCounts(
|
|
hour.requestCount ?? hour.modelBreakdowns.length,
|
|
hour.modelBreakdowns.length
|
|
);
|
|
|
|
hour.modelBreakdowns.forEach((breakdown, index) => {
|
|
details.push({
|
|
model: breakdown.modelName,
|
|
timestamp: buildHourlyTimestamp(hour.hour),
|
|
inputTokens: breakdown.inputTokens,
|
|
outputTokens: breakdown.outputTokens,
|
|
cacheReadTokens: breakdown.cacheReadTokens,
|
|
requestCount: requestCounts[index] ?? 1,
|
|
cost: breakdown.cost,
|
|
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),
|
|
inputTokens: breakdown.inputTokens,
|
|
outputTokens: breakdown.outputTokens,
|
|
cacheReadTokens: breakdown.cacheReadTokens,
|
|
requestCount: 1,
|
|
cost: breakdown.cost,
|
|
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();
|
|
if (!fs.existsSync(snapshotPath)) {
|
|
return null;
|
|
}
|
|
|
|
const raw = fs.readFileSync(snapshotPath, 'utf-8');
|
|
const snapshot = JSON.parse(raw) as CliproxyUsageSnapshot | LegacyCliproxyUsageSnapshot;
|
|
|
|
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;
|
|
}
|
|
|
|
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 (snapshot.version === 1 || snapshot.version === 2) {
|
|
return migrateLegacySnapshot(snapshot as LegacyCliproxyUsageSnapshot, emitWarnings);
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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<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), {
|
|
encoding: 'utf-8',
|
|
mode: PRIVATE_FILE_MODE,
|
|
});
|
|
fs.chmodSync(tempFile, PRIVATE_FILE_MODE);
|
|
|
|
const latestSnapshot = readSnapshot(false);
|
|
const latestTimestamp = latestSnapshot?.timestamp ?? -Infinity;
|
|
if (latestTimestamp > baseTimestamp) {
|
|
fs.rmSync(tempFile, { force: true });
|
|
continue;
|
|
}
|
|
|
|
fs.renameSync(tempFile, snapshotPath);
|
|
fs.chmodSync(snapshotPath, PRIVATE_FILE_MODE);
|
|
console.log(ok('CLIProxy usage snapshot updated'));
|
|
return;
|
|
}
|
|
|
|
throw new Error('Failed to write CLIProxy snapshot after repeated overlap retries');
|
|
}
|
|
|
|
export async function loadCachedCliproxyData(): Promise<{
|
|
daily: DailyUsage[];
|
|
hourly: HourlyUsage[];
|
|
monthly: MonthlyUsage[];
|
|
}> {
|
|
const empty = { daily: [], hourly: [], monthly: [] };
|
|
|
|
const snapshot = readSnapshot();
|
|
if (!snapshot) {
|
|
return empty;
|
|
}
|
|
|
|
const age = Date.now() - snapshot.timestamp;
|
|
if (age > SNAPSHOT_MAX_AGE_MS) {
|
|
console.log(info('Using stale CLIProxy snapshot while proxy sync is unavailable'));
|
|
}
|
|
|
|
return { daily: snapshot.daily, hourly: snapshot.hourly, monthly: snapshot.monthly };
|
|
}
|
|
|
|
export async function syncCliproxyUsage(
|
|
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw
|
|
): Promise<void> {
|
|
const raw = await fetchRaw();
|
|
|
|
if (raw === null) {
|
|
console.log(warn('CLIProxy usage sync skipped: proxy unavailable'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await writeSnapshotWithMerge(extractCliproxyUsageHistoryDetails(raw));
|
|
} catch (err) {
|
|
console.log(warn('Failed to write CLIProxy snapshot:') + ` ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
export function startCliproxySync(syncNow: () => Promise<void> = () => syncCliproxyUsage()): void {
|
|
if (syncIntervalId !== null) {
|
|
return;
|
|
}
|
|
|
|
const intervalMin = Math.round(SYNC_INTERVAL_MS / 60_000);
|
|
console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`));
|
|
|
|
void syncNow();
|
|
|
|
syncIntervalId = setInterval(() => {
|
|
void syncNow();
|
|
}, SYNC_INTERVAL_MS);
|
|
}
|
|
|
|
export function stopCliproxySync(): void {
|
|
if (syncIntervalId !== null) {
|
|
clearInterval(syncIntervalId);
|
|
syncIntervalId = null;
|
|
}
|
|
}
|