mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +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:
1 parent
c9da3ba035
commit
c5124a9cee
4 files changed
+430
-3
No files matched your search
@@ -56,7 +56,7 @@ export interface CliproxyStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Request detail from CLIProxyAPI */
|
/** Request detail from CLIProxyAPI */
|
||||||
interface RequestDetail {
|
export interface CliproxyRequestDetail {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
source: string;
|
source: string;
|
||||||
auth_index: number;
|
auth_index: number;
|
||||||
@@ -70,8 +70,11 @@ interface RequestDetail {
|
|||||||
failed: boolean;
|
failed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use CliproxyRequestDetail instead */
|
||||||
|
type RequestDetail = CliproxyRequestDetail;
|
||||||
|
|
||||||
/** Usage API response from CLIProxyAPI /v0/management/usage endpoint */
|
/** Usage API response from CLIProxyAPI /v0/management/usage endpoint */
|
||||||
interface UsageApiResponse {
|
export interface CliproxyUsageApiResponse {
|
||||||
failed_requests?: number;
|
failed_requests?: number;
|
||||||
usage?: {
|
usage?: {
|
||||||
total_requests?: number;
|
total_requests?: number;
|
||||||
@@ -130,7 +133,7 @@ export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as UsageApiResponse;
|
const data = (await response.json()) as CliproxyUsageApiResponse;
|
||||||
const usage = data.usage;
|
const usage = data.usage;
|
||||||
|
|
||||||
// Extract models, providers, and per-account stats from the nested API structure
|
// 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 */
|
/** OpenAI-compatible model object from /v1/models endpoint */
|
||||||
export interface CliproxyModel {
|
export interface CliproxyModel {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import {
|
|||||||
} from './disk-cache';
|
} from './disk-cache';
|
||||||
import { ok, info, fail } from '../../utils/ui';
|
import { ok, info, fail } from '../../utils/ui';
|
||||||
import { getCcsDir } from '../../utils/config-manager';
|
import { getCcsDir } from '../../utils/config-manager';
|
||||||
|
import {
|
||||||
|
loadCachedCliproxyData,
|
||||||
|
startCliproxySync,
|
||||||
|
stopCliproxySync,
|
||||||
|
} from './cliproxy-usage-syncer';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Multi-Instance Support - Aggregate usage from CCS profiles
|
// 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)`));
|
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
|
// Merge all data sources
|
||||||
const daily = mergeDailyData(allDailySources);
|
const daily = mergeDailyData(allDailySources);
|
||||||
const hourly = mergeHourlyData(allHourlySources);
|
const hourly = mergeHourlyData(allHourlySources);
|
||||||
@@ -479,6 +497,9 @@ export async function prewarmUsageCache(): Promise<{
|
|||||||
console.log(info('Pre-warming usage cache...'));
|
console.log(info('Pre-warming usage cache...'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Start CLIProxy usage syncer early (runs in background every 5 min)
|
||||||
|
startCliproxySync();
|
||||||
|
|
||||||
const diskCache = readDiskCache();
|
const diskCache = readDiskCache();
|
||||||
|
|
||||||
// Fresh disk cache - use it directly
|
// Fresh disk cache - use it directly
|
||||||
@@ -539,3 +560,10 @@ export async function prewarmUsageCache(): Promise<{
|
|||||||
throw err;
|
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