mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
fix(cli-proxy): durable analytics history persistence and dedupe migration
This commit is contained in:
1 parent
286b5781a7
commit
69b75c0449
5 files changed
+525
-117
No files matched your search
@@ -12,30 +12,43 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { fetchCliproxyUsageRaw } from '../../cliproxy/stats-fetcher';
|
import { fetchCliproxyUsageRaw } from '../../cliproxy/stats-fetcher';
|
||||||
import {
|
import {
|
||||||
transformCliproxyToDailyUsage,
|
buildCliproxyUsageHistoryAggregates,
|
||||||
transformCliproxyToHourlyUsage,
|
extractCliproxyUsageHistoryDetails,
|
||||||
transformCliproxyToMonthlyUsage,
|
mergeCliproxyUsageHistoryDetails,
|
||||||
|
pruneCliproxyUsageHistoryDetails,
|
||||||
|
type CliproxyUsageHistoryDetail,
|
||||||
} from './cliproxy-usage-transformer';
|
} from './cliproxy-usage-transformer';
|
||||||
import type { DailyUsage, HourlyUsage, MonthlyUsage } from './types';
|
import type { DailyUsage, HourlyUsage, MonthlyUsage } from './types';
|
||||||
import { getCcsDir } from '../../utils/config-manager';
|
import { getCcsDir } from '../../utils/config-manager';
|
||||||
import { ok, info, warn } from '../../utils/ui';
|
import { ok, info, warn } from '../../utils/ui';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Snapshot format
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface CliproxyUsageSnapshot {
|
interface CliproxyUsageSnapshot {
|
||||||
version: number;
|
version: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
details: CliproxyUsageHistoryDetail[];
|
||||||
daily: DailyUsage[];
|
daily: DailyUsage[];
|
||||||
hourly: HourlyUsage[];
|
hourly: HourlyUsage[];
|
||||||
monthly: MonthlyUsage[];
|
monthly: MonthlyUsage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LegacyCliproxyUsageSnapshot = {
|
||||||
|
version?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
daily?: DailyUsage[];
|
||||||
|
hourly?: HourlyUsage[];
|
||||||
|
monthly?: MonthlyUsage[];
|
||||||
|
};
|
||||||
|
|
||||||
type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw;
|
type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw;
|
||||||
|
|
||||||
const SNAPSHOT_VERSION = 2;
|
const SNAPSHOT_VERSION = 3;
|
||||||
const SNAPSHOT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
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) */
|
/** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */
|
||||||
const SYNC_INTERVAL_MS = Math.max(
|
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
|
parseInt(process.env.CCS_CLIPROXY_SYNC_INTERVAL ?? '300000', 10) || 300_000
|
||||||
);
|
);
|
||||||
|
|
||||||
// Module-level interval ID
|
|
||||||
let syncIntervalId: ReturnType<typeof setInterval> | null = null;
|
let syncIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
let snapshotTimestampOrdinal = 0;
|
let snapshotTimestampOrdinal = 0;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Cache directory helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function getCliproxyCacheDir(): string {
|
function getCliproxyCacheDir(): string {
|
||||||
return path.join(getCcsDir(), 'cache', 'cliproxy-usage');
|
return path.join(getCcsDir(), 'cache', 'cliproxy-usage');
|
||||||
}
|
}
|
||||||
@@ -71,6 +79,88 @@ function getSnapshotTimestamp(): number {
|
|||||||
return Date.now() + snapshotTimestampOrdinal / 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 buildLegacyHistoryDetails(
|
||||||
|
snapshot: LegacyCliproxyUsageSnapshot
|
||||||
|
): CliproxyUsageHistoryDetail[] {
|
||||||
|
const details: CliproxyUsageHistoryDetail[] = [];
|
||||||
|
const coveredDailyKeys = new Set<string>();
|
||||||
|
|
||||||
|
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 {
|
function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null {
|
||||||
try {
|
try {
|
||||||
const snapshotPath = getLatestSnapshotPath();
|
const snapshotPath = getLatestSnapshotPath();
|
||||||
@@ -79,23 +169,34 @@ function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const raw = fs.readFileSync(snapshotPath, 'utf-8');
|
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 (snapshot.version === SNAPSHOT_VERSION) {
|
||||||
if (emitWarnings) {
|
if (!Number.isFinite(snapshot.timestamp)) {
|
||||||
console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync'));
|
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 (snapshot.version === 2) {
|
||||||
if (emitWarnings) {
|
return migrateLegacySnapshot(snapshot as LegacyCliproxyUsageSnapshot, emitWarnings);
|
||||||
console.log(info('CLIProxy snapshot timestamp invalid, will refresh on next sync'));
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshot;
|
if (emitWarnings) {
|
||||||
|
console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync'));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (emitWarnings) {
|
if (emitWarnings) {
|
||||||
console.log(warn('Failed to read CLIProxy snapshot:') + ` ${(err as Error).message}`);
|
console.log(warn('Failed to read CLIProxy snapshot:') + ` ${(err as Error).message}`);
|
||||||
@@ -104,14 +205,55 @@ function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
function buildSnapshot(
|
||||||
// Load cached data
|
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), '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<{
|
export async function loadCachedCliproxyData(): Promise<{
|
||||||
daily: DailyUsage[];
|
daily: DailyUsage[];
|
||||||
hourly: HourlyUsage[];
|
hourly: HourlyUsage[];
|
||||||
@@ -132,18 +274,9 @@ export async function loadCachedCliproxyData(): Promise<{
|
|||||||
return { daily: snapshot.daily, hourly: snapshot.hourly, monthly: snapshot.monthly };
|
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(
|
export async function syncCliproxyUsage(
|
||||||
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw
|
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const syncStartedAt = getSnapshotTimestamp();
|
|
||||||
const raw = await fetchRaw();
|
const raw = await fetchRaw();
|
||||||
|
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
@@ -152,53 +285,12 @@ export async function syncCliproxyUsage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureCliproxyCacheDir();
|
await writeSnapshotWithMerge(extractCliproxyUsageHistoryDetails(raw));
|
||||||
|
|
||||||
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'));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Non-fatal - stale snapshot will continue to be served
|
|
||||||
console.log(warn('Failed to write CLIProxy snapshot:') + ` ${(err as Error).message}`);
|
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<void> = () => syncCliproxyUsage()): void {
|
export function startCliproxySync(syncNow: () => Promise<void> = () => syncCliproxyUsage()): void {
|
||||||
if (syncIntervalId !== null) {
|
if (syncIntervalId !== null) {
|
||||||
return;
|
return;
|
||||||
@@ -207,7 +299,6 @@ export function startCliproxySync(syncNow: () => Promise<void> = () => syncClipr
|
|||||||
const intervalMin = Math.round(SYNC_INTERVAL_MS / 60_000);
|
const intervalMin = Math.round(SYNC_INTERVAL_MS / 60_000);
|
||||||
console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`));
|
console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`));
|
||||||
|
|
||||||
// Fire-and-forget initial sync
|
|
||||||
void syncNow();
|
void syncNow();
|
||||||
|
|
||||||
syncIntervalId = setInterval(() => {
|
syncIntervalId = setInterval(() => {
|
||||||
@@ -215,9 +306,6 @@ export function startCliproxySync(syncNow: () => Promise<void> = () => syncClipr
|
|||||||
}, SYNC_INTERVAL_MS);
|
}, SYNC_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop periodic CLIProxy usage sync.
|
|
||||||
*/
|
|
||||||
export function stopCliproxySync(): void {
|
export function stopCliproxySync(): void {
|
||||||
if (syncIntervalId !== null) {
|
if (syncIntervalId !== null) {
|
||||||
clearInterval(syncIntervalId);
|
clearInterval(syncIntervalId);
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ import type { ModelBreakdown, DailyUsage, HourlyUsage, MonthlyUsage } from './ty
|
|||||||
// INTERNAL HELPERS
|
// INTERNAL HELPERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/** Flat entry pairing a model name with its request detail */
|
/** Persisted request detail used to rebuild historical CLIProxy analytics buckets */
|
||||||
interface FlatDetail {
|
export interface CliproxyUsageHistoryDetail {
|
||||||
model: string;
|
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 */
|
/** 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 };
|
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
|
// FLATTEN
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -51,14 +73,16 @@ function hasTrackedUsage(detail: CliproxyRequestDetail): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatten the nested response.usage.apis[provider].models[model].details[]
|
* Flatten the nested response.usage.apis[provider].models[model].details[]
|
||||||
* structure into a flat array. Failed requests are retained only when they
|
* structure into normalized history details. Failed requests are retained only
|
||||||
* still report tracked token usage that analytics can account for.
|
* 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;
|
const apis = response?.usage?.apis;
|
||||||
if (!apis) return [];
|
if (!apis) return [];
|
||||||
|
|
||||||
const results: FlatDetail[] = [];
|
const results: CliproxyUsageHistoryDetail[] = [];
|
||||||
for (const providerData of Object.values(apis)) {
|
for (const providerData of Object.values(apis)) {
|
||||||
const models = providerData?.models;
|
const models = providerData?.models;
|
||||||
if (!models) continue;
|
if (!models) continue;
|
||||||
@@ -67,20 +91,89 @@ export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): Flat
|
|||||||
if (!details) continue;
|
if (!details) continue;
|
||||||
for (const detail of details) {
|
for (const detail of details) {
|
||||||
if (detail.failed && !hasTrackedUsage(detail)) continue;
|
if (detail.failed && !hasTrackedUsage(detail)) continue;
|
||||||
results.push({ model, detail });
|
results.push(createHistoryDetail(model, detail));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results;
|
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<string, { detail: CliproxyUsageHistoryDetail; count: number }>();
|
||||||
|
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<string, { detail: CliproxyUsageHistoryDetail; count: number }>();
|
||||||
|
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
|
// GENERIC AGGREGATOR
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/** Group flat details by a time key extractor, return sorted DailyUsage-like records */
|
/** Group flat details by a time key extractor, return sorted DailyUsage-like records */
|
||||||
function aggregateByKey<T>(
|
function aggregateByKey<T>(
|
||||||
flat: FlatDetail[],
|
flat: CliproxyUsageHistoryDetail[],
|
||||||
keyFn: (timestamp: string) => string,
|
keyFn: (timestamp: string) => string,
|
||||||
buildRecord: (key: string, breakdowns: ModelBreakdown[], requestCount: number) => T,
|
buildRecord: (key: string, breakdowns: ModelBreakdown[], requestCount: number) => T,
|
||||||
sortFn: (a: T, b: T) => number
|
sortFn: (a: T, b: T) => number
|
||||||
@@ -89,18 +182,18 @@ function aggregateByKey<T>(
|
|||||||
const buckets = new Map<string, Map<string, ModelAccumulator>>();
|
const buckets = new Map<string, Map<string, ModelAccumulator>>();
|
||||||
const requestCounts = new Map<string, number>();
|
const requestCounts = new Map<string, number>();
|
||||||
|
|
||||||
for (const { model, detail } of flat) {
|
for (const detail of flat) {
|
||||||
const key = keyFn(detail.timestamp);
|
const key = keyFn(detail.timestamp);
|
||||||
if (!buckets.has(key)) buckets.set(key, new Map());
|
if (!buckets.has(key)) buckets.set(key, new Map());
|
||||||
requestCounts.set(key, (requestCounts.get(key) ?? 0) + 1);
|
requestCounts.set(key, (requestCounts.get(key) ?? 0) + 1);
|
||||||
const modelMap = buckets.get(key) as Map<string, ModelAccumulator>;
|
const modelMap = buckets.get(key) as Map<string, ModelAccumulator>;
|
||||||
if (!modelMap.has(model)) {
|
if (!modelMap.has(detail.model)) {
|
||||||
modelMap.set(model, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 });
|
modelMap.set(detail.model, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 });
|
||||||
}
|
}
|
||||||
const acc = modelMap.get(model) as ModelAccumulator;
|
const acc = modelMap.get(detail.model) as ModelAccumulator;
|
||||||
acc.inputTokens += detail.tokens?.input_tokens ?? 0;
|
acc.inputTokens += detail.inputTokens;
|
||||||
acc.outputTokens += detail.tokens?.output_tokens ?? 0;
|
acc.outputTokens += detail.outputTokens;
|
||||||
acc.cacheReadTokens += detail.tokens?.cached_tokens ?? 0;
|
acc.cacheReadTokens += detail.cacheReadTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
const records: T[] = [];
|
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) */
|
/** Transform CLIProxy usage response into DailyUsage array (sorted descending by date) */
|
||||||
export function transformCliproxyToDailyUsage(response: CliproxyUsageApiResponse): DailyUsage[] {
|
export function transformCliproxyToDailyUsage(response: CliproxyUsageApiResponse): DailyUsage[] {
|
||||||
const flat = flattenCliproxyDetails(response);
|
const flat = extractCliproxyUsageHistoryDetails(response);
|
||||||
return aggregateByKey(
|
return aggregateByKey(
|
||||||
flat,
|
flat,
|
||||||
(ts) => ts.slice(0, 10),
|
(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) */
|
/** Transform CLIProxy usage response into HourlyUsage array (sorted descending by hour) */
|
||||||
export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiResponse): HourlyUsage[] {
|
export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiResponse): HourlyUsage[] {
|
||||||
const flat = flattenCliproxyDetails(response);
|
const flat = extractCliproxyUsageHistoryDetails(response);
|
||||||
return aggregateByKey(
|
return aggregateByKey(
|
||||||
flat,
|
flat,
|
||||||
(ts) => {
|
(ts) => {
|
||||||
@@ -182,7 +275,7 @@ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiRespons
|
|||||||
export function transformCliproxyToMonthlyUsage(
|
export function transformCliproxyToMonthlyUsage(
|
||||||
response: CliproxyUsageApiResponse
|
response: CliproxyUsageApiResponse
|
||||||
): MonthlyUsage[] {
|
): MonthlyUsage[] {
|
||||||
const flat = flattenCliproxyDetails(response);
|
const flat = extractCliproxyUsageHistoryDetails(response);
|
||||||
return aggregateByKey(
|
return aggregateByKey(
|
||||||
flat,
|
flat,
|
||||||
(ts) => ts.slice(0, 7),
|
(ts) => ts.slice(0, 7),
|
||||||
@@ -200,3 +293,73 @@ export function transformCliproxyToMonthlyUsage(
|
|||||||
(a, b) => b.month.localeCompare(a.month)
|
(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)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -154,8 +154,8 @@ describe('cliproxy usage syncer', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the newer overlapping snapshot when an older sync finishes last', async () => {
|
it('merges unique history from overlapping syncs even when the older sync finishes last', async () => {
|
||||||
const olderSync = createDeferredFetch(buildResponse(100, '2026-03-02T10:00:00.000Z'));
|
const olderSync = createDeferredFetch(buildResponse(100, '2026-03-01T10:00:00.000Z'));
|
||||||
const newerSync = createDeferredFetch(buildResponse(200, '2026-03-02T11:00:00.000Z'));
|
const newerSync = createDeferredFetch(buildResponse(200, '2026-03-02T11:00:00.000Z'));
|
||||||
|
|
||||||
await runWithScopedConfigDir(ccsDir, async () => {
|
await runWithScopedConfigDir(ccsDir, async () => {
|
||||||
@@ -169,8 +169,127 @@ describe('cliproxy usage syncer', () => {
|
|||||||
await Promise.all([olderWrite, newerWrite]);
|
await Promise.all([olderWrite, newerWrite]);
|
||||||
|
|
||||||
const cached = await loadCachedCliproxyData();
|
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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher';
|
import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher';
|
||||||
import {
|
import {
|
||||||
flattenCliproxyDetails,
|
buildCliproxyUsageHistoryAggregates,
|
||||||
|
extractCliproxyUsageHistoryDetails,
|
||||||
|
mergeCliproxyUsageHistoryDetails,
|
||||||
transformCliproxyToDailyUsage,
|
transformCliproxyToDailyUsage,
|
||||||
transformCliproxyToHourlyUsage,
|
transformCliproxyToHourlyUsage,
|
||||||
transformCliproxyToMonthlyUsage,
|
transformCliproxyToMonthlyUsage,
|
||||||
@@ -97,26 +99,50 @@ const sampleResponse: CliproxyUsageApiResponse = {
|
|||||||
|
|
||||||
describe('cliproxy usage transformer', () => {
|
describe('cliproxy usage transformer', () => {
|
||||||
it('retains failed requests when they carry usage and skips zero-usage failures', () => {
|
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).toHaveLength(4);
|
||||||
expect(
|
expect(
|
||||||
flat.some(
|
flat.some(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
entry.detail.failed === true &&
|
entry.failed === true &&
|
||||||
entry.detail.tokens.input_tokens === 40 &&
|
entry.inputTokens === 40 &&
|
||||||
entry.detail.tokens.output_tokens === 10
|
entry.outputTokens === 10
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
flat.some(
|
flat.some(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
entry.detail.failed === true &&
|
entry.failed === true &&
|
||||||
entry.detail.tokens.input_tokens === 0 &&
|
entry.inputTokens === 0 &&
|
||||||
entry.detail.tokens.output_tokens === 0
|
entry.outputTokens === 0
|
||||||
)
|
)
|
||||||
).toBe(false);
|
).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', () => {
|
it('transforms daily usage with aggregated model totals', () => {
|
||||||
const daily = transformCliproxyToDailyUsage(sampleResponse);
|
const daily = transformCliproxyToDailyUsage(sampleResponse);
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,20 @@ function writeCliproxySnapshotFixture(): void {
|
|||||||
fs.mkdirSync(snapshotDir, { recursive: true });
|
fs.mkdirSync(snapshotDir, { recursive: true });
|
||||||
|
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
version: 2,
|
version: 3,
|
||||||
timestamp: Date.now(),
|
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: [
|
daily: [
|
||||||
{
|
{
|
||||||
date: '2026-03-02',
|
date: '2026-03-02',
|
||||||
|
|||||||
Reference in new issue
Block a user