mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 04:15:52 +00:00
Merge pull request #1111 from kaitranntt/kai/fix/1110-analytics-correctness
fix: correct analytics semantics and add native codex/droid collectors
This commit is contained in:
33 files changed
+3153
-510
No files matched your search
@@ -1,8 +1,8 @@
|
||||
# CCS Codebase Summary
|
||||
|
||||
Last Updated: 2026-04-07
|
||||
Last Updated: 2026-04-26
|
||||
|
||||
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, and native Codex runtime target support.
|
||||
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, native Codex runtime target support, and native Codex/Droid usage collectors.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -212,10 +212,12 @@ src/
|
||||
│ └── [more routes...]
|
||||
├── health/ # Health check system
|
||||
│ └── index.ts
|
||||
├── usage/ # Usage analytics module
|
||||
├── usage/ # Usage analytics module (default Claude, CCS instances, native Codex/Droid, CLIProxy snapshots)
|
||||
│ ├── index.ts
|
||||
│ ├── handlers.ts # Request handlers (633 lines)
|
||||
│ ├── aggregator.ts # Data aggregation (538 lines)
|
||||
│ ├── codex-native-usage-collector.ts # Native Codex rollout JSONL collector
|
||||
│ ├── droid-native-usage-collector.ts # Native Droid SQLite collector
|
||||
│ └── data-aggregator.ts
|
||||
├── services/ # Shared services
|
||||
│ └── index.ts
|
||||
|
||||
@@ -82,6 +82,18 @@ function toNonNegativeNumber(value: unknown): number {
|
||||
return numeric;
|
||||
}
|
||||
|
||||
function parseRequiredNonNegativeNumber(value: unknown): number | null {
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric < 0) {
|
||||
return null;
|
||||
}
|
||||
return numeric;
|
||||
}
|
||||
|
||||
function isValidTimestamp(value: unknown): value is string {
|
||||
return typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null {
|
||||
// Strip UTF-8 BOM if present (can occur on first line of some files)
|
||||
const cleanLine = line.replace(/^\uFEFF/, '').trim();
|
||||
@@ -97,16 +109,25 @@ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntr
|
||||
|
||||
const usage = entry.message.usage;
|
||||
const assistant = entry as JsonlAssistantEntry;
|
||||
const inputTokens = parseRequiredNonNegativeNumber(usage.input_tokens);
|
||||
const outputTokens = parseRequiredNonNegativeNumber(usage.output_tokens);
|
||||
if (inputTokens === null || outputTokens === null || !isValidTimestamp(assistant.timestamp)) {
|
||||
return null;
|
||||
}
|
||||
const normalizedProjectPath =
|
||||
typeof assistant.cwd === 'string' && assistant.cwd.trim().length > 0
|
||||
? assistant.cwd
|
||||
: projectPath;
|
||||
|
||||
return {
|
||||
inputTokens: toNonNegativeNumber(usage.input_tokens),
|
||||
outputTokens: toNonNegativeNumber(usage.output_tokens),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreationTokens: toNonNegativeNumber(usage.cache_creation_input_tokens),
|
||||
cacheReadTokens: toNonNegativeNumber(usage.cache_read_input_tokens),
|
||||
model: assistant.message.model,
|
||||
sessionId: assistant.sessionId || '',
|
||||
timestamp: assistant.timestamp || new Date().toISOString(),
|
||||
projectPath,
|
||||
timestamp: assistant.timestamp,
|
||||
projectPath: normalizedProjectPath,
|
||||
version: assistant.version,
|
||||
target:
|
||||
typeof (entry as { target?: unknown }).target === 'string'
|
||||
|
||||
@@ -828,9 +828,9 @@ function getDirectOrAliasPricing(model: string): ModelPricing | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pricing for a model with fuzzy matching fallback
|
||||
* @param model - Model name (exact or with provider prefix)
|
||||
* @returns ModelPricing for the model or fallback pricing
|
||||
* Get pricing for a model with narrow fuzzy matching fallback.
|
||||
* Unknown future model families should fall back instead of inheriting the
|
||||
* first known family tier that happens to share a prefix.
|
||||
*/
|
||||
export function getModelPricing(model: string): ModelPricing {
|
||||
const directOrAliasPricing = getDirectOrAliasPricing(model);
|
||||
@@ -839,17 +839,9 @@ export function getModelPricing(model: string): ModelPricing {
|
||||
}
|
||||
|
||||
for (const candidate of getLookupCandidates(model)) {
|
||||
// Try suffix matching (e.g., "claude-sonnet-4-5" matches "*-claude-sonnet-4-5")
|
||||
// Allow provider/routing wrappers to suffix a canonical model ID.
|
||||
for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) {
|
||||
if (candidate.endsWith(key) || key.endsWith(candidate)) {
|
||||
return pricing;
|
||||
}
|
||||
}
|
||||
|
||||
// Try partial matching for model families
|
||||
for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) {
|
||||
// Match by model family prefix
|
||||
if (candidate.startsWith(key.split('-').slice(0, 2).join('-'))) {
|
||||
if (candidate.endsWith(key)) {
|
||||
return pricing;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { loadAllUsageData } from './data-aggregator';
|
||||
import {
|
||||
aggregateDailyUsage,
|
||||
aggregateHourlyUsage,
|
||||
aggregateMonthlyUsage,
|
||||
aggregateSessionUsage,
|
||||
loadAllUsageData,
|
||||
} from './data-aggregator';
|
||||
import type { DailyUsage, HourlyUsage, MonthlyUsage, SessionUsage } from './types';
|
||||
import {
|
||||
readDiskCache,
|
||||
@@ -19,12 +25,15 @@ import {
|
||||
} from './disk-cache';
|
||||
import { ok, info, fail } from '../../utils/ui';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
import { getClaudeConfigDir, getDefaultClaudeConfigDir } from '../../utils/claude-config-path';
|
||||
import {
|
||||
loadCachedCliproxyData,
|
||||
startCliproxySync,
|
||||
stopCliproxySync,
|
||||
syncCliproxyUsage,
|
||||
} from './cliproxy-usage-syncer';
|
||||
import { scanCodexNativeUsageEntries } from './codex-native-usage-collector';
|
||||
import { scanDroidNativeUsageEntries } from './droid-native-usage-collector';
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Instance Support - Aggregate usage from CCS profiles
|
||||
@@ -35,6 +44,21 @@ function getCcsInstancesDir() {
|
||||
return path.join(getCcsDir(), 'instances');
|
||||
}
|
||||
|
||||
function isPathWithinDir(childPath: string, parentPath: string): boolean {
|
||||
const relative = path.relative(parentPath, childPath);
|
||||
return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function getDefaultProjectsDirForAnalytics(): string {
|
||||
const activeClaudeConfigDir = getClaudeConfigDir();
|
||||
const instancesDir = getCcsInstancesDir();
|
||||
const claudeConfigDir = isPathWithinDir(activeClaudeConfigDir, instancesDir)
|
||||
? getDefaultClaudeConfigDir()
|
||||
: activeClaudeConfigDir;
|
||||
|
||||
return path.join(claudeConfigDir, 'projects');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of CCS instance paths that have usage data
|
||||
* Only returns instances with existing projects/ directory
|
||||
@@ -83,6 +107,10 @@ async function loadInstanceData(instancePath: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
function getHourlyRequestCount(hour: HourlyUsage): number {
|
||||
return hour.requestCount ?? hour.modelBreakdowns.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge daily usage data from multiple sources
|
||||
* Combines entries with same date by aggregating tokens
|
||||
@@ -149,8 +177,26 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] {
|
||||
existing.totalCost += month.totalCost;
|
||||
const modelSet = new Set([...existing.modelsUsed, ...month.modelsUsed]);
|
||||
existing.modelsUsed = Array.from(modelSet);
|
||||
for (const breakdown of month.modelBreakdowns) {
|
||||
const existingBreakdown = existing.modelBreakdowns.find(
|
||||
(item) => item.modelName === breakdown.modelName
|
||||
);
|
||||
if (existingBreakdown) {
|
||||
existingBreakdown.inputTokens += breakdown.inputTokens;
|
||||
existingBreakdown.outputTokens += breakdown.outputTokens;
|
||||
existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens;
|
||||
existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens;
|
||||
existingBreakdown.cost += breakdown.cost;
|
||||
} else {
|
||||
existing.modelBreakdowns.push({ ...breakdown });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
monthMap.set(month.month, { ...month, modelsUsed: [...month.modelsUsed] });
|
||||
monthMap.set(month.month, {
|
||||
...month,
|
||||
modelsUsed: [...month.modelsUsed],
|
||||
modelBreakdowns: month.modelBreakdowns.map((breakdown) => ({ ...breakdown })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +220,7 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] {
|
||||
existing.cacheCreationTokens += hour.cacheCreationTokens;
|
||||
existing.cacheReadTokens += hour.cacheReadTokens;
|
||||
existing.totalCost += hour.totalCost;
|
||||
existing.requestCount = getHourlyRequestCount(existing) + getHourlyRequestCount(hour);
|
||||
const modelSet = new Set([...existing.modelsUsed, ...hour.modelsUsed]);
|
||||
existing.modelsUsed = Array.from(modelSet);
|
||||
// Merge model breakdowns
|
||||
@@ -196,6 +243,7 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] {
|
||||
...hour,
|
||||
modelsUsed: [...hour.modelsUsed],
|
||||
modelBreakdowns: hour.modelBreakdowns.map((b) => ({ ...b })),
|
||||
requestCount: getHourlyRequestCount(hour),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -251,6 +299,10 @@ export function getLastFetchTimestamp(): number | null {
|
||||
return lastFetchTimestamp;
|
||||
}
|
||||
|
||||
export function getUsageCacheSize(): number {
|
||||
return cache.size;
|
||||
}
|
||||
|
||||
// In-memory cache
|
||||
const cache = new Map<string, CacheEntry<unknown>>();
|
||||
|
||||
@@ -300,8 +352,8 @@ async function refreshFromSource(): Promise<{
|
||||
// Non-fatal: syncer handles unavailability and stale fallback.
|
||||
await syncCliproxyUsage();
|
||||
|
||||
// Load default data (from ~/.claude/projects/ or CLAUDE_CONFIG_DIR)
|
||||
const defaultData = await loadAllUsageData();
|
||||
// Load canonical default data and avoid counting the active instance twice
|
||||
const defaultData = await loadAllUsageData({ projectsDir: getDefaultProjectsDirForAnalytics() });
|
||||
|
||||
// Load data from all CCS instances sequentially
|
||||
const instancePaths = getInstancePaths();
|
||||
@@ -339,6 +391,32 @@ async function refreshFromSource(): Promise<{
|
||||
console.log(info(`Aggregated usage data from ${instanceDataResults.length} CCS instance(s)`));
|
||||
}
|
||||
|
||||
try {
|
||||
const codexEntries = await scanCodexNativeUsageEntries();
|
||||
if (codexEntries.length > 0) {
|
||||
allDailySources.push(aggregateDailyUsage(codexEntries, 'codex-native'));
|
||||
allHourlySources.push(aggregateHourlyUsage(codexEntries, 'codex-native'));
|
||||
allMonthlySources.push(aggregateMonthlyUsage(codexEntries, 'codex-native'));
|
||||
allSessionSources.push(aggregateSessionUsage(codexEntries, 'codex-native'));
|
||||
console.log(info(`Included native Codex usage data (${codexEntries.length} event(s))`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(fail(`Failed to load native Codex usage data: ${err}`));
|
||||
}
|
||||
|
||||
try {
|
||||
const droidEntries = await scanDroidNativeUsageEntries();
|
||||
if (droidEntries.length > 0) {
|
||||
allDailySources.push(aggregateDailyUsage(droidEntries, 'droid-native'));
|
||||
allHourlySources.push(aggregateHourlyUsage(droidEntries, 'droid-native'));
|
||||
allMonthlySources.push(aggregateMonthlyUsage(droidEntries, 'droid-native'));
|
||||
allSessionSources.push(aggregateSessionUsage(droidEntries, 'droid-native'));
|
||||
console.log(info(`Included native Droid usage data (${droidEntries.length} event(s))`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(fail(`Failed to load native Droid usage data: ${err}`));
|
||||
}
|
||||
|
||||
// Load CLIProxy usage data (from local snapshot cache)
|
||||
try {
|
||||
const cliproxyData = await loadCachedCliproxyData();
|
||||
|
||||
@@ -12,29 +12,43 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fetchCliproxyUsageRaw } from '../../cliproxy/stats-fetcher';
|
||||
import {
|
||||
transformCliproxyToDailyUsage,
|
||||
transformCliproxyToHourlyUsage,
|
||||
transformCliproxyToMonthlyUsage,
|
||||
buildCliproxyUsageHistoryAggregates,
|
||||
extractCliproxyUsageHistoryDetails,
|
||||
mergeCliproxyUsageHistoryDetails,
|
||||
pruneCliproxyUsageHistoryDetails,
|
||||
type CliproxyUsageHistoryDetail,
|
||||
} 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;
|
||||
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 = 1;
|
||||
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;
|
||||
|
||||
/** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */
|
||||
const SYNC_INTERVAL_MS = Math.max(
|
||||
@@ -42,12 +56,8 @@ const SYNC_INTERVAL_MS = Math.max(
|
||||
parseInt(process.env.CCS_CLIPROXY_SYNC_INTERVAL ?? '300000', 10) || 300_000
|
||||
);
|
||||
|
||||
// Module-level interval ID
|
||||
let syncIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache directory helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
let snapshotTimestampOrdinal = 0;
|
||||
|
||||
function getCliproxyCacheDir(): string {
|
||||
return path.join(getCcsDir(), 'cache', 'cliproxy-usage');
|
||||
@@ -64,14 +74,203 @@ function ensureCliproxyCacheDir(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load cached data
|
||||
// ---------------------------------------------------------------------------
|
||||
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),
|
||||
source: hour.source,
|
||||
authIndex: 'legacy-hourly',
|
||||
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),
|
||||
source: day.source,
|
||||
authIndex: 'legacy-daily',
|
||||
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), '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<{
|
||||
daily: DailyUsage[];
|
||||
hourly: HourlyUsage[];
|
||||
@@ -79,35 +278,19 @@ export async function loadCachedCliproxyData(): Promise<{
|
||||
}> {
|
||||
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}`);
|
||||
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 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(
|
||||
fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw
|
||||
): Promise<void> {
|
||||
@@ -119,41 +302,12 @@ export async function syncCliproxyUsage(
|
||||
}
|
||||
|
||||
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'));
|
||||
await writeSnapshotWithMerge(extractCliproxyUsageHistoryDetails(raw));
|
||||
} 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(syncNow: () => Promise<void> = () => syncCliproxyUsage()): void {
|
||||
if (syncIntervalId !== null) {
|
||||
return;
|
||||
@@ -162,7 +316,6 @@ export function startCliproxySync(syncNow: () => Promise<void> = () => syncClipr
|
||||
const intervalMin = Math.round(SYNC_INTERVAL_MS / 60_000);
|
||||
console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`));
|
||||
|
||||
// Fire-and-forget initial sync
|
||||
void syncNow();
|
||||
|
||||
syncIntervalId = setInterval(() => {
|
||||
@@ -170,9 +323,6 @@ export function startCliproxySync(syncNow: () => Promise<void> = () => syncClipr
|
||||
}, SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic CLIProxy usage sync.
|
||||
*/
|
||||
export function stopCliproxySync(): void {
|
||||
if (syncIntervalId !== null) {
|
||||
clearInterval(syncIntervalId);
|
||||
|
||||
@@ -13,10 +13,18 @@ import type { ModelBreakdown, DailyUsage, HourlyUsage, MonthlyUsage } from './ty
|
||||
// INTERNAL HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/** Flat entry pairing a model name with its request detail */
|
||||
interface FlatDetail {
|
||||
/** Persisted request detail used to rebuild historical CLIProxy analytics buckets */
|
||||
export interface CliproxyUsageHistoryDetail {
|
||||
model: string;
|
||||
detail: CliproxyRequestDetail;
|
||||
timestamp: string;
|
||||
source: string;
|
||||
authIndex: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
requestCount: number;
|
||||
cost: number;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/** Accumulator for token counts per model per time bucket */
|
||||
@@ -24,31 +32,66 @@ interface ModelAccumulator {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cost: 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
|
||||
);
|
||||
const { inputTokens, outputTokens, cacheReadTokens, cost } = acc;
|
||||
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,
|
||||
requestCount: 1,
|
||||
cost: calculateCost(
|
||||
{
|
||||
inputTokens: detail.tokens?.input_tokens ?? 0,
|
||||
outputTokens: detail.tokens?.output_tokens ?? 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: detail.tokens?.cached_tokens ?? 0,
|
||||
},
|
||||
model
|
||||
),
|
||||
failed: detail.failed,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FLATTEN
|
||||
// ============================================================================
|
||||
|
||||
function hasTrackedUsage(detail: CliproxyRequestDetail): boolean {
|
||||
const tokens = detail.tokens;
|
||||
return (
|
||||
(tokens?.input_tokens ?? 0) > 0 ||
|
||||
(tokens?.output_tokens ?? 0) > 0 ||
|
||||
(tokens?.cached_tokens ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the nested response.usage.apis[provider].models[model].details[]
|
||||
* structure into a flat array. Failed requests are skipped.
|
||||
* structure into normalized history details. Failed requests are retained only
|
||||
* 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;
|
||||
if (!apis) return [];
|
||||
|
||||
const results: FlatDetail[] = [];
|
||||
const results: CliproxyUsageHistoryDetail[] = [];
|
||||
for (const providerData of Object.values(apis)) {
|
||||
const models = providerData?.models;
|
||||
if (!models) continue;
|
||||
@@ -56,39 +99,117 @@ export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): Flat
|
||||
const details = modelData?.details;
|
||||
if (!details) continue;
|
||||
for (const detail of details) {
|
||||
if (detail.failed) continue;
|
||||
results.push({ model, detail });
|
||||
if (detail.failed && !hasTrackedUsage(detail)) continue;
|
||||
results.push(createHistoryDetail(model, detail));
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function createHistorySignature(detail: CliproxyUsageHistoryDetail): string {
|
||||
return [
|
||||
detail.model,
|
||||
detail.timestamp,
|
||||
detail.source,
|
||||
detail.authIndex,
|
||||
detail.inputTokens,
|
||||
detail.outputTokens,
|
||||
detail.cacheReadTokens,
|
||||
detail.requestCount,
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
/** Group flat details by a time key extractor, return sorted DailyUsage-like records */
|
||||
function aggregateByKey<T>(
|
||||
flat: FlatDetail[],
|
||||
flat: CliproxyUsageHistoryDetail[],
|
||||
keyFn: (timestamp: string) => string,
|
||||
buildRecord: (key: string, breakdowns: ModelBreakdown[]) => T,
|
||||
buildRecord: (key: string, breakdowns: ModelBreakdown[], requestCount: number) => T,
|
||||
sortFn: (a: T, b: T) => number
|
||||
): T[] {
|
||||
// bucket: timeKey -> modelName -> accumulator
|
||||
const buckets = new Map<string, Map<string, ModelAccumulator>>();
|
||||
const requestCounts = new Map<string, number>();
|
||||
|
||||
for (const { model, detail } of flat) {
|
||||
for (const detail of flat) {
|
||||
const key = keyFn(detail.timestamp);
|
||||
if (!buckets.has(key)) buckets.set(key, new Map());
|
||||
requestCounts.set(key, (requestCounts.get(key) ?? 0) + detail.requestCount);
|
||||
const modelMap = buckets.get(key) as Map<string, ModelAccumulator>;
|
||||
if (!modelMap.has(model)) {
|
||||
modelMap.set(model, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 });
|
||||
if (!modelMap.has(detail.model)) {
|
||||
modelMap.set(detail.model, {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 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 acc = modelMap.get(detail.model) as ModelAccumulator;
|
||||
acc.inputTokens += detail.inputTokens;
|
||||
acc.outputTokens += detail.outputTokens;
|
||||
acc.cacheReadTokens += detail.cacheReadTokens;
|
||||
acc.cost += detail.cost;
|
||||
}
|
||||
|
||||
const records: T[] = [];
|
||||
@@ -96,7 +217,7 @@ function aggregateByKey<T>(
|
||||
const breakdowns = Array.from(modelMap.entries()).map(([name, acc]) =>
|
||||
buildModelBreakdown(name, acc)
|
||||
);
|
||||
records.push(buildRecord(key, breakdowns));
|
||||
records.push(buildRecord(key, breakdowns, requestCounts.get(key) ?? 0));
|
||||
});
|
||||
|
||||
return records.sort(sortFn);
|
||||
@@ -113,7 +234,7 @@ function sumField(breakdowns: ModelBreakdown[], field: keyof ModelBreakdown): nu
|
||||
|
||||
/** Transform CLIProxy usage response into DailyUsage array (sorted descending by date) */
|
||||
export function transformCliproxyToDailyUsage(response: CliproxyUsageApiResponse): DailyUsage[] {
|
||||
const flat = flattenCliproxyDetails(response);
|
||||
const flat = extractCliproxyUsageHistoryDetails(response);
|
||||
return aggregateByKey(
|
||||
flat,
|
||||
(ts) => ts.slice(0, 10),
|
||||
@@ -138,7 +259,7 @@ export function transformCliproxyToDailyUsage(response: CliproxyUsageApiResponse
|
||||
|
||||
/** Transform CLIProxy usage response into HourlyUsage array (sorted descending by hour) */
|
||||
export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiResponse): HourlyUsage[] {
|
||||
const flat = flattenCliproxyDetails(response);
|
||||
const flat = extractCliproxyUsageHistoryDetails(response);
|
||||
return aggregateByKey(
|
||||
flat,
|
||||
(ts) => {
|
||||
@@ -146,7 +267,7 @@ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiRespons
|
||||
const hour = ts.slice(11, 13) || '00';
|
||||
return `${date} ${hour}:00`;
|
||||
},
|
||||
(hour, breakdowns) => {
|
||||
(hour, breakdowns, requestCount) => {
|
||||
const totalCost = sumField(breakdowns, 'cost');
|
||||
return {
|
||||
hour,
|
||||
@@ -159,6 +280,7 @@ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiRespons
|
||||
totalCost,
|
||||
modelsUsed: breakdowns.map((b) => b.modelName),
|
||||
modelBreakdowns: breakdowns,
|
||||
requestCount,
|
||||
};
|
||||
},
|
||||
(a, b) => b.hour.localeCompare(a.hour)
|
||||
@@ -169,7 +291,7 @@ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiRespons
|
||||
export function transformCliproxyToMonthlyUsage(
|
||||
response: CliproxyUsageApiResponse
|
||||
): MonthlyUsage[] {
|
||||
const flat = flattenCliproxyDetails(response);
|
||||
const flat = extractCliproxyUsageHistoryDetails(response);
|
||||
return aggregateByKey(
|
||||
flat,
|
||||
(ts) => ts.slice(0, 7),
|
||||
@@ -187,3 +309,73 @@ export function transformCliproxyToMonthlyUsage(
|
||||
(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)
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as readline from 'readline';
|
||||
import type { RawUsageEntry } from '../jsonl-parser';
|
||||
import { resolveCodexConfigPaths } from '../services/codex-dashboard-service';
|
||||
|
||||
interface CodexNativeUsageCollectorOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
includeCliproxySessions?: boolean;
|
||||
}
|
||||
|
||||
interface CodexTokenSnapshot {
|
||||
inputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
function isCliProxyBackedProvider(value: string | undefined): boolean {
|
||||
return value === 'cliproxy' || value === 'ccs_runtime';
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function hasUsage(snapshot: CodexTokenSnapshot): boolean {
|
||||
return snapshot.inputTokens > 0 || snapshot.cacheReadTokens > 0 || snapshot.outputTokens > 0;
|
||||
}
|
||||
|
||||
function normalizeTokenSnapshot(value: unknown): CodexTokenSnapshot | null {
|
||||
if (!isObject(value)) return null;
|
||||
return {
|
||||
inputTokens: asNumber(value.input_tokens),
|
||||
cacheReadTokens: asNumber(value.cached_input_tokens),
|
||||
outputTokens: asNumber(value.output_tokens) + asNumber(value.reasoning_output_tokens),
|
||||
};
|
||||
}
|
||||
|
||||
function subtractSnapshots(
|
||||
current: CodexTokenSnapshot,
|
||||
previous: CodexTokenSnapshot
|
||||
): CodexTokenSnapshot {
|
||||
return {
|
||||
inputTokens: Math.max(0, current.inputTokens - previous.inputTokens),
|
||||
cacheReadTokens: Math.max(0, current.cacheReadTokens - previous.cacheReadTokens),
|
||||
outputTokens: Math.max(0, current.outputTokens - previous.outputTokens),
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotsEqual(left: CodexTokenSnapshot, right: CodexTokenSnapshot): boolean {
|
||||
return (
|
||||
left.inputTokens === right.inputTokens &&
|
||||
left.cacheReadTokens === right.cacheReadTokens &&
|
||||
left.outputTokens === right.outputTokens
|
||||
);
|
||||
}
|
||||
|
||||
async function collectRolloutFiles(dir: string): Promise<string[]> {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
|
||||
const files: string[] = [];
|
||||
for (const entry of await fs.promises.readdir(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectRolloutFiles(entryPath)));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
async function parseRolloutFile(
|
||||
filePath: string,
|
||||
includeCliproxySessions: boolean
|
||||
): Promise<RawUsageEntry[]> {
|
||||
const entries: RawUsageEntry[] = [];
|
||||
let sessionId = '';
|
||||
let projectPath = '';
|
||||
let version: string | undefined;
|
||||
let modelProvider: string | undefined;
|
||||
let model = 'unknown-codex-model';
|
||||
let previousTotal: CodexTokenSnapshot | null = null;
|
||||
|
||||
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||
const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
try {
|
||||
for await (const line of reader) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.type === 'session_meta' && isObject(parsed.payload)) {
|
||||
sessionId = asString(parsed.payload.id) ?? sessionId;
|
||||
projectPath = asString(parsed.payload.cwd) ?? projectPath;
|
||||
version = asString(parsed.payload.cli_version) ?? version;
|
||||
modelProvider = asString(parsed.payload.model_provider) ?? modelProvider;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.type === 'turn_context' && isObject(parsed.payload)) {
|
||||
model = asString(parsed.payload.model) ?? model;
|
||||
projectPath = asString(parsed.payload.cwd) ?? projectPath;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.type !== 'event_msg' ||
|
||||
!isObject(parsed.payload) ||
|
||||
parsed.payload.type !== 'token_count' ||
|
||||
!sessionId ||
|
||||
(isCliProxyBackedProvider(modelProvider) && !includeCliproxySessions)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const totalSnapshot = normalizeTokenSnapshot(
|
||||
parsed.payload.info && isObject(parsed.payload.info)
|
||||
? (parsed.payload.info as Record<string, unknown>).total_token_usage
|
||||
: null
|
||||
);
|
||||
const lastSnapshot = normalizeTokenSnapshot(
|
||||
parsed.payload.info && isObject(parsed.payload.info)
|
||||
? (parsed.payload.info as Record<string, unknown>).last_token_usage
|
||||
: null
|
||||
);
|
||||
|
||||
if (!totalSnapshot) continue;
|
||||
if (previousTotal && snapshotsEqual(totalSnapshot, previousTotal)) continue;
|
||||
|
||||
const delta =
|
||||
previousTotal === null
|
||||
? lastSnapshot && hasUsage(lastSnapshot)
|
||||
? lastSnapshot
|
||||
: totalSnapshot
|
||||
: subtractSnapshots(totalSnapshot, previousTotal);
|
||||
previousTotal = totalSnapshot;
|
||||
|
||||
if (!hasUsage(delta)) continue;
|
||||
|
||||
const timestamp = asString(parsed.timestamp);
|
||||
if (!timestamp) continue;
|
||||
|
||||
entries.push({
|
||||
inputTokens: delta.inputTokens,
|
||||
outputTokens: delta.outputTokens,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: delta.cacheReadTokens,
|
||||
model,
|
||||
sessionId,
|
||||
timestamp,
|
||||
projectPath: projectPath || '/',
|
||||
version,
|
||||
target: 'codex',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
reader.close();
|
||||
stream.destroy();
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function scanCodexNativeUsageEntries(
|
||||
options: CodexNativeUsageCollectorOptions = {}
|
||||
): Promise<RawUsageEntry[]> {
|
||||
const { baseDir } = resolveCodexConfigPaths({
|
||||
env: options.env,
|
||||
homeDir: options.homeDir,
|
||||
});
|
||||
const rolloutFiles = await collectRolloutFiles(path.join(baseDir, 'sessions'));
|
||||
const entries: RawUsageEntry[] = [];
|
||||
|
||||
for (const filePath of rolloutFiles) {
|
||||
entries.push(...(await parseRolloutFile(filePath, options.includeCliproxySessions === true)));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -244,6 +244,7 @@ export function aggregateHourlyUsage(
|
||||
totalCost,
|
||||
modelsUsed: Array.from(modelMap.keys()),
|
||||
modelBreakdowns,
|
||||
requestCount: hourEntries.length,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { RawUsageEntry } from '../jsonl-parser';
|
||||
import { resolveDroidConfigPaths } from '../services/droid-dashboard-service';
|
||||
import { warn } from '../../utils/ui';
|
||||
import { querySqliteJson } from './sqlite-cli';
|
||||
|
||||
export type DroidSqliteQuery = typeof querySqliteJson;
|
||||
|
||||
interface DroidNativeUsageCollectorOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
includeCcsManagedSessions?: boolean;
|
||||
querySqliteJson?: DroidSqliteQuery;
|
||||
}
|
||||
|
||||
interface DroidSessionMetadata {
|
||||
model: string;
|
||||
projectPath: string;
|
||||
rawSelector: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
const DROID_COST_BATCH_SIZE = 1000;
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
|
||||
}
|
||||
|
||||
function isCcsManagedSelector(selector: string): boolean {
|
||||
return selector.startsWith('custom:CCS-') || selector.startsWith('custom:ccs-');
|
||||
}
|
||||
|
||||
function buildSelectorAlias(displayName: string, index: number): string {
|
||||
return `${displayName.trim().replace(/\s+/g, '-')}-${index}`;
|
||||
}
|
||||
|
||||
function normalizeCustomModels(settings: Record<string, unknown>): Array<Record<string, unknown>> {
|
||||
const raw = settings.customModels ?? settings.custom_models;
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.filter((entry): entry is Record<string, unknown> => isObject(entry));
|
||||
}
|
||||
if (isObject(raw)) {
|
||||
return Object.values(raw).filter((entry): entry is Record<string, unknown> => isObject(entry));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildCustomModelMap(settingsPath: string): Map<string, string> {
|
||||
const selectors = new Map<string, string>();
|
||||
if (!fs.existsSync(settingsPath)) return selectors;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
if (!isObject(parsed)) return selectors;
|
||||
|
||||
for (const [index, entry] of normalizeCustomModels(parsed).entries()) {
|
||||
const displayName = asString(entry.displayName ?? entry.model_display_name);
|
||||
const model = asString(entry.model);
|
||||
if (!displayName || !model) continue;
|
||||
selectors.set(`custom:${buildSelectorAlias(displayName, index)}`, model);
|
||||
}
|
||||
} catch {
|
||||
return selectors;
|
||||
}
|
||||
|
||||
return selectors;
|
||||
}
|
||||
|
||||
function collectSessionFiles(dir: string, suffix: string, results: string[] = []): string[] {
|
||||
if (!fs.existsSync(dir)) return results;
|
||||
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectSessionFiles(entryPath, suffix, results);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith(suffix)) {
|
||||
results.push(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function readSessionStartMetadata(
|
||||
filePath: string
|
||||
): { projectPath: string; version?: string } | null {
|
||||
try {
|
||||
const firstLine = fs.readFileSync(filePath, 'utf8').split('\n')[0];
|
||||
const parsed = JSON.parse(firstLine);
|
||||
if (parsed?.type !== 'session_start') return null;
|
||||
return {
|
||||
projectPath: asString(parsed.cwd) ?? '/',
|
||||
version:
|
||||
typeof parsed.version === 'number'
|
||||
? String(parsed.version)
|
||||
: (asString(parsed.version) ?? undefined),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadSessionMetadata(factoryDir: string): Map<string, DroidSessionMetadata> {
|
||||
const metadata = new Map<string, DroidSessionMetadata>();
|
||||
const selectorMap = buildCustomModelMap(path.join(factoryDir, 'settings.json'));
|
||||
const sessionsDir = path.join(factoryDir, 'sessions');
|
||||
|
||||
for (const settingsPath of collectSessionFiles(sessionsDir, '.settings.json')) {
|
||||
const sessionId = path.basename(settingsPath, '.settings.json');
|
||||
const jsonlPath = settingsPath.replace(/\.settings\.json$/, '.jsonl');
|
||||
const start = readSessionStartMetadata(jsonlPath);
|
||||
if (!start) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
const rawSelector = asString(parsed?.model) ?? 'unknown-droid-model';
|
||||
metadata.set(sessionId, {
|
||||
model: selectorMap.get(rawSelector) ?? rawSelector,
|
||||
projectPath: start.projectPath,
|
||||
rawSelector,
|
||||
version: start.version,
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export async function scanDroidNativeUsageEntries(
|
||||
options: DroidNativeUsageCollectorOptions = {}
|
||||
): Promise<RawUsageEntry[]> {
|
||||
const query = options.querySqliteJson ?? querySqliteJson;
|
||||
const { settingsPath } = resolveDroidConfigPaths({
|
||||
env: options.env,
|
||||
homeDir: options.homeDir,
|
||||
});
|
||||
const factoryDir = path.dirname(settingsPath);
|
||||
const costsDbPath = path.join(factoryDir, 'costs.db');
|
||||
if (!fs.existsSync(costsDbPath)) return [];
|
||||
|
||||
const rows: Array<Record<string, unknown>> = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const batch = await query(
|
||||
costsDbPath,
|
||||
`SELECT session_id, timestamp, input_tokens, output_tokens FROM costs ` +
|
||||
`ORDER BY timestamp ASC LIMIT ${DROID_COST_BATCH_SIZE} OFFSET ${offset};`
|
||||
);
|
||||
if (!batch.length) break;
|
||||
rows.push(...batch);
|
||||
if (batch.length < DROID_COST_BATCH_SIZE) break;
|
||||
offset += batch.length;
|
||||
}
|
||||
if (!rows.length) return [];
|
||||
|
||||
const metadata = loadSessionMetadata(factoryDir);
|
||||
const entries: RawUsageEntry[] = [];
|
||||
let skippedRowsWithoutMetadata = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!isObject(row)) continue;
|
||||
const sessionId = asString(row.session_id);
|
||||
const timestamp = asString(row.timestamp);
|
||||
const inputTokens = asNumber(row.input_tokens);
|
||||
const outputTokens = asNumber(row.output_tokens);
|
||||
if (!sessionId || !timestamp || inputTokens === null || outputTokens === null) continue;
|
||||
|
||||
const session = metadata.get(sessionId);
|
||||
if (!session) {
|
||||
skippedRowsWithoutMetadata++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!options.includeCcsManagedSessions && isCcsManagedSelector(session.rawSelector)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
model: session.model,
|
||||
sessionId,
|
||||
timestamp,
|
||||
projectPath: session.projectPath,
|
||||
version: session.version,
|
||||
target: 'droid',
|
||||
});
|
||||
}
|
||||
|
||||
if (skippedRowsWithoutMetadata > 0) {
|
||||
console.log(
|
||||
warn(
|
||||
`Skipped ${skippedRowsWithoutMetadata} Droid native cost row(s) without local session metadata`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getCachedMonthlyData,
|
||||
getCachedSessionData,
|
||||
getCachedHourlyData,
|
||||
getUsageCacheSize,
|
||||
getLastFetchTimestamp,
|
||||
refreshUsageCache,
|
||||
} from './aggregator';
|
||||
@@ -87,6 +88,13 @@ export function validateOffset(offset?: string): number {
|
||||
return num;
|
||||
}
|
||||
|
||||
export function validateDateRangeOrder(since?: string, until?: string): void {
|
||||
if (!since || !until) return;
|
||||
if (since > until) {
|
||||
throw new Error('The "since" date must be earlier than or equal to "until"');
|
||||
}
|
||||
}
|
||||
|
||||
export function filterByDateRange<
|
||||
T extends { date?: string; month?: string; lastActivity?: string },
|
||||
>(data: T[] | undefined, since?: string, until?: string): T[] {
|
||||
@@ -120,6 +128,66 @@ export function errorResponse(res: Response, error: unknown, defaultMessage: str
|
||||
});
|
||||
}
|
||||
|
||||
function roundToCurrency(value: number): number {
|
||||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
function calculateUsageTotalTokens(
|
||||
input: number,
|
||||
output: number,
|
||||
cacheCreation: number,
|
||||
cacheRead: number
|
||||
): number {
|
||||
return input + output + cacheCreation + cacheRead;
|
||||
}
|
||||
|
||||
function parseDateKey(dateString: string): Date {
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
Number(dateString.slice(0, 4)),
|
||||
Number(dateString.slice(4, 6)) - 1,
|
||||
Number(dateString.slice(6, 8))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getInclusiveDayCount(start: Date, end: Date): number {
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
return Math.floor((end.getTime() - start.getTime()) / dayMs) + 1;
|
||||
}
|
||||
|
||||
function countCalendarDays(data: DailyUsage[], since?: string, until?: string): number {
|
||||
const sortedDates = [...data]
|
||||
.map((item) => item.date.replace(/-/g, ''))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
const earliestDate = sortedDates[0];
|
||||
const latestDate = sortedDates[sortedDates.length - 1];
|
||||
|
||||
if (since && until) {
|
||||
return getInclusiveDayCount(parseDateKey(since), parseDateKey(until));
|
||||
}
|
||||
|
||||
if (since) {
|
||||
const end = until
|
||||
? parseDateKey(until)
|
||||
: latestDate
|
||||
? parseDateKey(latestDate)
|
||||
: parseDateKey(since);
|
||||
return getInclusiveDayCount(parseDateKey(since), end);
|
||||
}
|
||||
|
||||
if (until) {
|
||||
const start = earliestDate ? parseDateKey(earliestDate) : parseDateKey(until);
|
||||
return getInclusiveDayCount(start, parseDateKey(until));
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return getInclusiveDayCount(parseDateKey(earliestDate), parseDateKey(latestDate));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cost Calculation Helpers
|
||||
// ============================================================================
|
||||
@@ -150,10 +218,10 @@ export function calculateTokenBreakdownCosts(dailyData: DailyUsage[]): TokenBrea
|
||||
}
|
||||
|
||||
return {
|
||||
input: { tokens: inputTokens, cost: Math.round(inputCost * 100) / 100 },
|
||||
output: { tokens: outputTokens, cost: Math.round(outputCost * 100) / 100 },
|
||||
cacheCreation: { tokens: cacheCreationTokens, cost: Math.round(cacheCreationCost * 100) / 100 },
|
||||
cacheRead: { tokens: cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 },
|
||||
input: { tokens: inputTokens, cost: roundToCurrency(inputCost) },
|
||||
output: { tokens: outputTokens, cost: roundToCurrency(outputCost) },
|
||||
cacheCreation: { tokens: cacheCreationTokens, cost: roundToCurrency(cacheCreationCost) },
|
||||
cacheRead: { tokens: cacheReadTokens, cost: roundToCurrency(cacheReadCost) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,6 +402,7 @@ export async function handleSummary(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
validateDateRangeOrder(since, until);
|
||||
const dailyData = await getCachedDailyData();
|
||||
const filtered = filterByDateRange(dailyData, since, until);
|
||||
|
||||
@@ -351,8 +420,15 @@ export async function handleSummary(
|
||||
totalCost += day.totalCost;
|
||||
}
|
||||
|
||||
const totalTokens = totalInputTokens + totalOutputTokens;
|
||||
const totalTokens = calculateUsageTotalTokens(
|
||||
totalInputTokens,
|
||||
totalOutputTokens,
|
||||
totalCacheCreationTokens,
|
||||
totalCacheReadTokens
|
||||
);
|
||||
const tokenBreakdown = calculateTokenBreakdownCosts(filtered);
|
||||
const totalDays = countCalendarDays(filtered, since, until);
|
||||
const activeDays = filtered.length;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -363,12 +439,14 @@ export async function handleSummary(
|
||||
totalCacheTokens: totalCacheCreationTokens + totalCacheReadTokens,
|
||||
totalCacheCreationTokens,
|
||||
totalCacheReadTokens,
|
||||
totalCost: Math.round(totalCost * 100) / 100,
|
||||
totalCost: roundToCurrency(totalCost),
|
||||
tokenBreakdown,
|
||||
totalDays: filtered.length,
|
||||
averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0,
|
||||
averageCostPerDay:
|
||||
filtered.length > 0 ? Math.round((totalCost / filtered.length) * 100) / 100 : 0,
|
||||
totalDays,
|
||||
activeDays,
|
||||
averageTokensPerDay: totalDays > 0 ? Math.round(totalTokens / totalDays) : 0,
|
||||
averageTokensPerActiveDay: activeDays > 0 ? Math.round(totalTokens / activeDays) : 0,
|
||||
averageCostPerDay: totalDays > 0 ? roundToCurrency(totalCost / totalDays) : 0,
|
||||
averageCostPerActiveDay: activeDays > 0 ? roundToCurrency(totalCost / activeDays) : 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -383,16 +461,22 @@ export async function handleDaily(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
validateDateRangeOrder(since, until);
|
||||
const dailyData = await getCachedDailyData();
|
||||
const filtered = filterByDateRange(dailyData, since, until);
|
||||
|
||||
const trends = filtered.map((day) => ({
|
||||
date: day.date,
|
||||
tokens: day.inputTokens + day.outputTokens,
|
||||
tokens: calculateUsageTotalTokens(
|
||||
day.inputTokens,
|
||||
day.outputTokens,
|
||||
day.cacheCreationTokens,
|
||||
day.cacheReadTokens
|
||||
),
|
||||
inputTokens: day.inputTokens,
|
||||
outputTokens: day.outputTokens,
|
||||
cacheTokens: day.cacheCreationTokens + day.cacheReadTokens,
|
||||
cost: Math.round(day.totalCost * 100) / 100,
|
||||
cost: roundToCurrency(day.totalCost),
|
||||
modelsUsed: day.modelsUsed.length,
|
||||
}));
|
||||
|
||||
@@ -409,6 +493,7 @@ export async function handleHourly(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
validateDateRangeOrder(since, until);
|
||||
const hourlyData = await getCachedHourlyData();
|
||||
|
||||
const filtered = (hourlyData || []).filter((h) => {
|
||||
@@ -420,13 +505,18 @@ export async function handleHourly(
|
||||
|
||||
const trends = filtered.map((hour) => ({
|
||||
hour: hour.hour,
|
||||
tokens: hour.inputTokens + hour.outputTokens,
|
||||
tokens: calculateUsageTotalTokens(
|
||||
hour.inputTokens,
|
||||
hour.outputTokens,
|
||||
hour.cacheCreationTokens,
|
||||
hour.cacheReadTokens
|
||||
),
|
||||
inputTokens: hour.inputTokens,
|
||||
outputTokens: hour.outputTokens,
|
||||
cacheTokens: hour.cacheCreationTokens + hour.cacheReadTokens,
|
||||
cost: Math.round(hour.totalCost * 100) / 100,
|
||||
cost: roundToCurrency(hour.totalCost),
|
||||
modelsUsed: hour.modelsUsed.length,
|
||||
requests: hour.modelBreakdowns.length,
|
||||
requests: hour.requestCount ?? hour.modelBreakdowns.length,
|
||||
}));
|
||||
|
||||
const filledTrends = fillHourlyGaps(trends, since, until);
|
||||
@@ -443,6 +533,7 @@ export async function handleModels(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
validateDateRangeOrder(since, until);
|
||||
const dailyData = await getCachedDailyData();
|
||||
const filtered = filterByDateRange(dailyData, since, until);
|
||||
|
||||
@@ -478,7 +569,17 @@ export async function handleModels(
|
||||
}
|
||||
|
||||
const models = Array.from(modelMap.values());
|
||||
const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0);
|
||||
const totalTokens = models.reduce(
|
||||
(sum, model) =>
|
||||
sum +
|
||||
calculateUsageTotalTokens(
|
||||
model.inputTokens,
|
||||
model.outputTokens,
|
||||
model.cacheCreationTokens,
|
||||
model.cacheReadTokens
|
||||
),
|
||||
0
|
||||
);
|
||||
|
||||
const result = models
|
||||
.map((m) => {
|
||||
@@ -489,28 +590,32 @@ export async function handleModels(
|
||||
(m.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion;
|
||||
const cacheReadCost = (m.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion;
|
||||
const ioRatio = m.outputTokens > 0 ? m.inputTokens / m.outputTokens : 0;
|
||||
const totalModelTokens = calculateUsageTotalTokens(
|
||||
m.inputTokens,
|
||||
m.outputTokens,
|
||||
m.cacheCreationTokens,
|
||||
m.cacheReadTokens
|
||||
);
|
||||
|
||||
return {
|
||||
model: m.model,
|
||||
tokens: m.inputTokens + m.outputTokens,
|
||||
tokens: totalModelTokens,
|
||||
inputTokens: m.inputTokens,
|
||||
outputTokens: m.outputTokens,
|
||||
cacheCreationTokens: m.cacheCreationTokens,
|
||||
cacheReadTokens: m.cacheReadTokens,
|
||||
cacheTokens: m.cacheCreationTokens + m.cacheReadTokens,
|
||||
cost: Math.round(m.cost * 100) / 100,
|
||||
cost: roundToCurrency(m.cost),
|
||||
percentage:
|
||||
totalTokens > 0
|
||||
? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10
|
||||
: 0,
|
||||
totalTokens > 0 ? Math.round((totalModelTokens / totalTokens) * 1000) / 10 : 0,
|
||||
costBreakdown: {
|
||||
input: { tokens: m.inputTokens, cost: Math.round(inputCost * 100) / 100 },
|
||||
output: { tokens: m.outputTokens, cost: Math.round(outputCost * 100) / 100 },
|
||||
input: { tokens: m.inputTokens, cost: roundToCurrency(inputCost) },
|
||||
output: { tokens: m.outputTokens, cost: roundToCurrency(outputCost) },
|
||||
cacheCreation: {
|
||||
tokens: m.cacheCreationTokens,
|
||||
cost: Math.round(cacheCreationCost * 100) / 100,
|
||||
cost: roundToCurrency(cacheCreationCost),
|
||||
},
|
||||
cacheRead: { tokens: m.cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 },
|
||||
cacheRead: { tokens: m.cacheReadTokens, cost: roundToCurrency(cacheReadCost) },
|
||||
},
|
||||
ioRatio: Math.round(ioRatio * 10) / 10,
|
||||
};
|
||||
@@ -530,6 +635,7 @@ export async function handleSessions(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
validateDateRangeOrder(since, until);
|
||||
const limit = validateLimit(req.query.limit);
|
||||
const offset = validateOffset(req.query.offset);
|
||||
|
||||
@@ -543,10 +649,15 @@ export async function handleSessions(
|
||||
const sessions = paginated.map((s) => ({
|
||||
sessionId: s.sessionId,
|
||||
projectPath: s.projectPath,
|
||||
tokens: s.inputTokens + s.outputTokens,
|
||||
tokens: calculateUsageTotalTokens(
|
||||
s.inputTokens,
|
||||
s.outputTokens,
|
||||
s.cacheCreationTokens,
|
||||
s.cacheReadTokens
|
||||
),
|
||||
inputTokens: s.inputTokens,
|
||||
outputTokens: s.outputTokens,
|
||||
cost: Math.round(s.totalCost * 100) / 100,
|
||||
cost: roundToCurrency(s.totalCost),
|
||||
lastActivity: s.lastActivity,
|
||||
modelsUsed: s.modelsUsed,
|
||||
target: s.target || 'claude',
|
||||
@@ -574,25 +685,113 @@ export async function handleMonthly(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
const monthlyData = await getCachedMonthlyData();
|
||||
validateDateRangeOrder(since, until);
|
||||
let filtered: Array<{
|
||||
month: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
totalCost: number;
|
||||
modelsUsed: string[];
|
||||
modelBreakdowns: DailyUsage['modelBreakdowns'];
|
||||
}>;
|
||||
|
||||
const filtered =
|
||||
since || until
|
||||
? monthlyData.filter((m) => {
|
||||
const monthDate = m.month.replace('-', '') + '01';
|
||||
if (since && monthDate < since) return false;
|
||||
if (until && monthDate > until) return false;
|
||||
return true;
|
||||
})
|
||||
: monthlyData;
|
||||
if (since || until) {
|
||||
const dailyData = filterByDateRange(await getCachedDailyData(), since, until);
|
||||
const monthMap = new Map<
|
||||
string,
|
||||
{
|
||||
month: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
totalCost: number;
|
||||
modelsUsed: Set<string>;
|
||||
modelBreakdowns: Map<
|
||||
string,
|
||||
{
|
||||
modelName: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cost: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const day of dailyData) {
|
||||
const month = day.date.slice(0, 7);
|
||||
const existing = monthMap.get(month) ?? {
|
||||
month,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalCost: 0,
|
||||
modelsUsed: new Set<string>(),
|
||||
modelBreakdowns: new Map(),
|
||||
};
|
||||
|
||||
existing.inputTokens += day.inputTokens;
|
||||
existing.outputTokens += day.outputTokens;
|
||||
existing.cacheCreationTokens += day.cacheCreationTokens;
|
||||
existing.cacheReadTokens += day.cacheReadTokens;
|
||||
existing.totalCost += day.totalCost;
|
||||
for (const model of day.modelsUsed) {
|
||||
existing.modelsUsed.add(model);
|
||||
}
|
||||
for (const breakdown of day.modelBreakdowns) {
|
||||
const existingBreakdown = existing.modelBreakdowns.get(breakdown.modelName) ?? {
|
||||
modelName: breakdown.modelName,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 0,
|
||||
};
|
||||
existingBreakdown.inputTokens += breakdown.inputTokens;
|
||||
existingBreakdown.outputTokens += breakdown.outputTokens;
|
||||
existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens;
|
||||
existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens;
|
||||
existingBreakdown.cost += breakdown.cost;
|
||||
existing.modelBreakdowns.set(breakdown.modelName, existingBreakdown);
|
||||
}
|
||||
|
||||
monthMap.set(month, existing);
|
||||
}
|
||||
|
||||
filtered = Array.from(monthMap.values())
|
||||
.map((month) => ({
|
||||
month: month.month,
|
||||
inputTokens: month.inputTokens,
|
||||
outputTokens: month.outputTokens,
|
||||
cacheCreationTokens: month.cacheCreationTokens,
|
||||
cacheReadTokens: month.cacheReadTokens,
|
||||
totalCost: month.totalCost,
|
||||
modelsUsed: Array.from(month.modelsUsed),
|
||||
modelBreakdowns: Array.from(month.modelBreakdowns.values()),
|
||||
}))
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
} else {
|
||||
filtered = await getCachedMonthlyData();
|
||||
}
|
||||
|
||||
const result = filtered.map((m) => ({
|
||||
month: m.month,
|
||||
tokens: m.inputTokens + m.outputTokens,
|
||||
tokens: calculateUsageTotalTokens(
|
||||
m.inputTokens,
|
||||
m.outputTokens,
|
||||
m.cacheCreationTokens,
|
||||
m.cacheReadTokens
|
||||
),
|
||||
inputTokens: m.inputTokens,
|
||||
outputTokens: m.outputTokens,
|
||||
cacheTokens: m.cacheCreationTokens + m.cacheReadTokens,
|
||||
cost: Math.round(m.totalCost * 100) / 100,
|
||||
cost: roundToCurrency(m.totalCost),
|
||||
modelsUsed: m.modelsUsed.length,
|
||||
}));
|
||||
|
||||
@@ -612,10 +811,9 @@ export async function handleRefresh(_req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
export function handleStatus(_req: Request, res: Response): void {
|
||||
const cache = new Map(); // Note: this is a placeholder, actual cache is in aggregator
|
||||
res.json({
|
||||
success: true,
|
||||
data: { lastFetch: getLastFetchTimestamp(), cacheSize: cache.size },
|
||||
data: { lastFetch: getLastFetchTimestamp(), cacheSize: getUsageCacheSize() },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -626,6 +824,7 @@ export async function handleInsights(
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
validateDateRangeOrder(since, until);
|
||||
const dailyData = await getCachedDailyData();
|
||||
const filtered = filterByDateRange(dailyData, since, until);
|
||||
const anomalies = detectAnomalies(filtered);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { execFile } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SQLITE_JSON_MAX_BUFFER = 10 * 1024 * 1024;
|
||||
|
||||
export type SqliteJsonRow = Record<string, unknown>;
|
||||
|
||||
function isCommandMissing(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const nodeError = error as Error & { code?: string };
|
||||
return nodeError.code === 'ENOENT' || /not found/i.test(nodeError.message);
|
||||
}
|
||||
|
||||
export async function querySqliteJson(dbPath: string, sql: string): Promise<SqliteJsonRow[]> {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('sqlite3', ['-json', dbPath, sql], {
|
||||
maxBuffer: SQLITE_JSON_MAX_BUFFER,
|
||||
});
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return Array.isArray(parsed) ? (parsed as SqliteJsonRow[]) : [];
|
||||
} catch (error) {
|
||||
if (isCommandMissing(error)) {
|
||||
throw new Error('sqlite3 command not available');
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`sqlite3 query failed for ${dbPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,11 @@ export interface HourlyUsage {
|
||||
totalCost: number;
|
||||
modelsUsed: string[];
|
||||
modelBreakdowns: ModelBreakdown[];
|
||||
/**
|
||||
* Raw request count for this hour bucket.
|
||||
* Optional for backward compatibility with previously persisted snapshots.
|
||||
*/
|
||||
requestCount?: number;
|
||||
}
|
||||
|
||||
/** Monthly usage aggregation (YYYY-MM) */
|
||||
|
||||
@@ -146,6 +146,16 @@ describe('parseUsageEntry', () => {
|
||||
|
||||
test('includes project path in result', () => {
|
||||
const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/custom/project/path');
|
||||
expect(result!.projectPath).toBe('/home/user/project');
|
||||
});
|
||||
|
||||
test('falls back to the decoded project path when cwd is missing', () => {
|
||||
const withoutCwd = JSON.stringify({
|
||||
...JSON.parse(VALID_ASSISTANT_ENTRY),
|
||||
cwd: undefined,
|
||||
});
|
||||
|
||||
const result = parseUsageEntry(withoutCwd, '/custom/project/path');
|
||||
expect(result!.projectPath).toBe('/custom/project/path');
|
||||
});
|
||||
|
||||
@@ -169,7 +179,7 @@ describe('parseUsageEntry', () => {
|
||||
expect(result!.target).toBeUndefined();
|
||||
});
|
||||
|
||||
test('coerces token fields to non-negative numbers', () => {
|
||||
test('returns null when required token fields are invalid', () => {
|
||||
const withInvalidUsage = JSON.stringify({
|
||||
...JSON.parse(VALID_ASSISTANT_ENTRY),
|
||||
message: {
|
||||
@@ -184,11 +194,16 @@ describe('parseUsageEntry', () => {
|
||||
});
|
||||
|
||||
const result = parseUsageEntry(withInvalidUsage, '/test');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.inputTokens).toBe(1500);
|
||||
expect(result!.outputTokens).toBe(0);
|
||||
expect(result!.cacheCreationTokens).toBe(0);
|
||||
expect(result!.cacheReadTokens).toBe(0);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when timestamp is missing', () => {
|
||||
const withoutTimestamp = JSON.stringify({
|
||||
...JSON.parse(VALID_ASSISTANT_ENTRY),
|
||||
timestamp: undefined,
|
||||
});
|
||||
|
||||
expect(parseUsageEntry(withoutTimestamp, '/test')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -314,8 +329,12 @@ describe('parseProjectDirectory', () => {
|
||||
|
||||
test('sanitizes derived projectPath from dashed directory names', async () => {
|
||||
const projectDir = path.join(tempDir, '-..-etc-passwd');
|
||||
const withoutCwd = JSON.stringify({
|
||||
...JSON.parse(VALID_ASSISTANT_ENTRY),
|
||||
cwd: undefined,
|
||||
});
|
||||
fs.mkdirSync(projectDir);
|
||||
fs.writeFileSync(path.join(projectDir, 'session.jsonl'), VALID_ASSISTANT_ENTRY);
|
||||
fs.writeFileSync(path.join(projectDir, 'session.jsonl'), withoutCwd);
|
||||
|
||||
const entries = await parseProjectDirectory(projectDir);
|
||||
|
||||
|
||||
@@ -160,6 +160,13 @@ describe('model-pricing', () => {
|
||||
expect(opus47dated.inputPerMillion).toBe(5.0);
|
||||
expect(opus47dated.outputPerMillion).toBe(25.0);
|
||||
});
|
||||
|
||||
it('should not map unknown future model families onto known family pricing', () => {
|
||||
const fallback = getModelPricing('unknown-model-xyz');
|
||||
|
||||
expect(getModelPricing('claude-opus-5-20270101')).toEqual(fallback);
|
||||
expect(getModelPricing('gemini-3.2-pro')).toEqual(fallback);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateCost', () => {
|
||||
|
||||
@@ -20,6 +20,48 @@ function fetchRawResponse(): Promise<CliproxyUsageApiResponse | null> {
|
||||
return Promise.resolve(rawResponse);
|
||||
}
|
||||
|
||||
function buildResponse(inputTokens: number, timestamp = '2026-03-02T12:00:00.000Z'): CliproxyUsageApiResponse {
|
||||
return {
|
||||
usage: {
|
||||
apis: {
|
||||
gemini: {
|
||||
models: {
|
||||
'gemini-2.5-pro': {
|
||||
details: [
|
||||
{
|
||||
timestamp,
|
||||
source: 'account-a',
|
||||
auth_index: 0,
|
||||
tokens: {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: 20,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 10,
|
||||
total_tokens: inputTokens + 30,
|
||||
},
|
||||
failed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferredFetch(response: CliproxyUsageApiResponse | null) {
|
||||
let resolveFetch: ((value: CliproxyUsageApiResponse | null) => void) | undefined;
|
||||
return {
|
||||
fetch: () =>
|
||||
new Promise<CliproxyUsageApiResponse | null>((resolve) => {
|
||||
fetchCalls++;
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
resolve: () => resolveFetch?.(response),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
ccsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-syncer-'));
|
||||
fetchCalls = 0;
|
||||
@@ -92,4 +134,182 @@ describe('cliproxy usage syncer', () => {
|
||||
stopCliproxySync();
|
||||
intervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps stale cached snapshots available for historical analytics reads', async () => {
|
||||
await runWithScopedConfigDir(ccsDir, async () => {
|
||||
await syncCliproxyUsage(fetchRawResponse);
|
||||
|
||||
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
|
||||
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
snapshot.timestamp = Date.now() - 8 * 24 * 60 * 60 * 1000;
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot), 'utf-8');
|
||||
|
||||
const cached = await loadCachedCliproxyData();
|
||||
expect(cached.daily).toHaveLength(1);
|
||||
expect(cached.hourly).toHaveLength(1);
|
||||
expect(cached.monthly).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('merges unique history from overlapping syncs even when the older sync finishes last', async () => {
|
||||
const olderSync = createDeferredFetch(buildResponse(100, '2026-03-01T10:00:00.000Z'));
|
||||
const newerSync = createDeferredFetch(buildResponse(200, '2026-03-02T11:00:00.000Z'));
|
||||
|
||||
await runWithScopedConfigDir(ccsDir, async () => {
|
||||
const olderWrite = syncCliproxyUsage(olderSync.fetch);
|
||||
const newerWrite = syncCliproxyUsage(newerSync.fetch);
|
||||
|
||||
newerSync.resolve();
|
||||
await Promise.resolve();
|
||||
olderSync.resolve();
|
||||
|
||||
await Promise.all([olderWrite, newerWrite]);
|
||||
|
||||
const cached = await loadCachedCliproxyData();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
it('migrates legacy v1 and v2 snapshots forward before merging new history', async () => {
|
||||
for (const version of [1, 2]) {
|
||||
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,
|
||||
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',
|
||||
requestCount: 7,
|
||||
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']);
|
||||
expect(cached.hourly.find((entry) => entry.hour === '2026-03-01 12:00')?.requestCount).toBe(7);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('prunes history details older than the configured retention window', async () => {
|
||||
await runWithScopedConfigDir(ccsDir, async () => {
|
||||
await syncCliproxyUsage(() =>
|
||||
Promise.resolve(buildResponse(100, '2024-01-01T12:00:00.000Z'))
|
||||
);
|
||||
await syncCliproxyUsage(() =>
|
||||
Promise.resolve(buildResponse(200, new Date().toISOString()))
|
||||
);
|
||||
|
||||
const cached = await loadCachedCliproxyData();
|
||||
expect(cached.daily.some((entry) => entry.date === '2024-01-01')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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 type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher';
|
||||
import {
|
||||
flattenCliproxyDetails,
|
||||
buildCliproxyUsageHistoryAggregates,
|
||||
extractCliproxyUsageHistoryDetails,
|
||||
mergeCliproxyUsageHistoryDetails,
|
||||
transformCliproxyToDailyUsage,
|
||||
transformCliproxyToHourlyUsage,
|
||||
transformCliproxyToMonthlyUsage,
|
||||
@@ -40,6 +42,19 @@ const sampleResponse: CliproxyUsageApiResponse = {
|
||||
},
|
||||
failed: true,
|
||||
},
|
||||
{
|
||||
timestamp: '2026-03-01T12:15:00.000Z',
|
||||
source: 'account-a',
|
||||
auth_index: 0,
|
||||
tokens: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
cached_tokens: 0,
|
||||
total_tokens: 0,
|
||||
},
|
||||
failed: true,
|
||||
},
|
||||
{
|
||||
timestamp: '2026-03-01T10:45:00.000Z',
|
||||
source: 'account-a',
|
||||
@@ -83,10 +98,57 @@ const sampleResponse: CliproxyUsageApiResponse = {
|
||||
};
|
||||
|
||||
describe('cliproxy usage transformer', () => {
|
||||
it('flattens nested API details and skips failed requests', () => {
|
||||
const flat = flattenCliproxyDetails(sampleResponse);
|
||||
expect(flat).toHaveLength(3);
|
||||
expect(flat.every((entry) => entry.detail.failed === false)).toBe(true);
|
||||
it('retains failed requests when they carry usage and skips zero-usage failures', () => {
|
||||
const flat = extractCliproxyUsageHistoryDetails(sampleResponse);
|
||||
expect(flat).toHaveLength(4);
|
||||
expect(
|
||||
flat.some(
|
||||
(entry) =>
|
||||
entry.failed === true &&
|
||||
entry.inputTokens === 40 &&
|
||||
entry.outputTokens === 10
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
flat.some(
|
||||
(entry) =>
|
||||
entry.failed === true &&
|
||||
entry.inputTokens === 0 &&
|
||||
entry.outputTokens === 0
|
||||
)
|
||||
).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('uses persisted cost from history instead of recomputing from current pricing', () => {
|
||||
const details = extractCliproxyUsageHistoryDetails(sampleResponse);
|
||||
const seeded = details.map((detail) => ({ ...detail, cost: 999 }));
|
||||
const { daily } = buildCliproxyUsageHistoryAggregates(seeded);
|
||||
|
||||
expect(daily[0].modelBreakdowns[0]?.cost).toBe(999);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@@ -98,21 +160,25 @@ describe('cliproxy usage transformer', () => {
|
||||
expect(daily[1].date).toBe('2026-03-01');
|
||||
|
||||
const marchFirst = daily.find((d) => d.date === '2026-03-01');
|
||||
expect(marchFirst?.inputTokens).toBe(130);
|
||||
expect(marchFirst?.outputTokens).toBe(70);
|
||||
expect(marchFirst?.cacheReadTokens).toBe(30);
|
||||
expect(marchFirst?.inputTokens).toBe(170);
|
||||
expect(marchFirst?.outputTokens).toBe(80);
|
||||
expect(marchFirst?.cacheReadTokens).toBe(35);
|
||||
expect(marchFirst?.modelsUsed).toContain('gemini-2.5-pro');
|
||||
});
|
||||
|
||||
it('transforms hourly usage with hour buckets', () => {
|
||||
const hourly = transformCliproxyToHourlyUsage(sampleResponse);
|
||||
|
||||
expect(hourly).toHaveLength(2);
|
||||
expect(hourly).toHaveLength(3);
|
||||
expect(hourly[0].hour).toBe('2026-03-02 01:00');
|
||||
|
||||
const tenAm = hourly.find((h) => h.hour === '2026-03-01 10:00');
|
||||
expect(tenAm?.inputTokens).toBe(130);
|
||||
expect(tenAm?.outputTokens).toBe(70);
|
||||
|
||||
const elevenAm = hourly.find((h) => h.hour === '2026-03-01 11:00');
|
||||
expect(elevenAm?.inputTokens).toBe(40);
|
||||
expect(elevenAm?.outputTokens).toBe(10);
|
||||
});
|
||||
|
||||
it('transforms monthly usage with cliproxy source', () => {
|
||||
@@ -121,8 +187,8 @@ describe('cliproxy usage transformer', () => {
|
||||
expect(monthly).toHaveLength(1);
|
||||
expect(monthly[0].month).toBe('2026-03');
|
||||
expect(monthly[0].source).toBe('cliproxy');
|
||||
expect(monthly[0].inputTokens).toBe(200);
|
||||
expect(monthly[0].outputTokens).toBe(100);
|
||||
expect(monthly[0].cacheReadTokens).toBe(30);
|
||||
expect(monthly[0].inputTokens).toBe(240);
|
||||
expect(monthly[0].outputTokens).toBe(110);
|
||||
expect(monthly[0].cacheReadTokens).toBe(35);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { scanCodexNativeUsageEntries } from '../../../src/web-server/usage/codex-native-usage-collector';
|
||||
|
||||
function writeCodexRollout(
|
||||
baseDir: string,
|
||||
options: {
|
||||
sessionId?: string;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
} = {}
|
||||
): void {
|
||||
const sessionId = options.sessionId ?? 'codex-session-1';
|
||||
const rolloutDir = path.join(baseDir, 'sessions', '2026', '03', '02');
|
||||
const rolloutPath = path.join(rolloutDir, `rollout-${sessionId}.jsonl`);
|
||||
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: sessionId,
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
cwd: options.cwd ?? '/tmp/codex-project',
|
||||
cli_version: '0.126.0',
|
||||
source: 'cli',
|
||||
model_provider: options.modelProvider ?? 'openai',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:00:01.000Z',
|
||||
type: 'turn_context',
|
||||
payload: {
|
||||
turn_id: 'turn-1',
|
||||
cwd: options.cwd ?? '/tmp/codex-project',
|
||||
model: options.model ?? 'gpt-5',
|
||||
effort: 'high',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:00:02.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: null,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:05:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total_token_usage: {
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 20,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 2,
|
||||
total_tokens: 127,
|
||||
},
|
||||
last_token_usage: {
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 20,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 2,
|
||||
total_tokens: 127,
|
||||
},
|
||||
model_context_window: 200000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:05:30.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total_token_usage: {
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 20,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 2,
|
||||
total_tokens: 127,
|
||||
},
|
||||
last_token_usage: {
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 20,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 2,
|
||||
total_tokens: 127,
|
||||
},
|
||||
model_context_window: 200000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:10:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total_token_usage: {
|
||||
input_tokens: 150,
|
||||
cached_input_tokens: 30,
|
||||
output_tokens: 10,
|
||||
reasoning_output_tokens: 3,
|
||||
total_tokens: 193,
|
||||
},
|
||||
last_token_usage: {
|
||||
input_tokens: 50,
|
||||
cached_input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 1,
|
||||
total_tokens: 66,
|
||||
},
|
||||
model_context_window: 200000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
fs.mkdirSync(rolloutDir, { recursive: true });
|
||||
fs.writeFileSync(rolloutPath, `${lines.join('\n')}\n`, 'utf8');
|
||||
}
|
||||
|
||||
describe('codex native usage collector', () => {
|
||||
let tempRoot = '';
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-native-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('parses token_count events into raw usage entries and suppresses duplicates', async () => {
|
||||
writeCodexRollout(tempRoot);
|
||||
|
||||
const entries = await scanCodexNativeUsageEntries({
|
||||
env: { CODEX_HOME: tempRoot },
|
||||
homeDir: tempRoot,
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]).toMatchObject({
|
||||
sessionId: 'codex-session-1',
|
||||
projectPath: '/tmp/codex-project',
|
||||
model: 'gpt-5',
|
||||
version: '0.126.0',
|
||||
target: 'codex',
|
||||
inputTokens: 100,
|
||||
cacheReadTokens: 20,
|
||||
outputTokens: 7,
|
||||
});
|
||||
expect(entries[1]).toMatchObject({
|
||||
inputTokens: 50,
|
||||
cacheReadTokens: 10,
|
||||
outputTokens: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips cliproxy-backed codex sessions by default to avoid double counting', async () => {
|
||||
writeCodexRollout(tempRoot, { modelProvider: 'cliproxy' });
|
||||
|
||||
const entries = await scanCodexNativeUsageEntries({
|
||||
env: { CODEX_HOME: tempRoot },
|
||||
homeDir: tempRoot,
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('also skips ccs_runtime-backed codex bridge sessions by default', async () => {
|
||||
writeCodexRollout(tempRoot, { modelProvider: 'ccs_runtime' });
|
||||
|
||||
const entries = await scanCodexNativeUsageEntries({
|
||||
env: { CODEX_HOME: tempRoot },
|
||||
homeDir: tempRoot,
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
scanDroidNativeUsageEntries,
|
||||
type DroidSqliteQuery,
|
||||
} from '../../../src/web-server/usage/droid-native-usage-collector';
|
||||
|
||||
function writeDroidSessionFixture(
|
||||
tempRoot: string,
|
||||
options: {
|
||||
sessionId?: string;
|
||||
selector?: string;
|
||||
cwd?: string;
|
||||
} = {}
|
||||
): string {
|
||||
const sessionId = options.sessionId ?? 'droid-session-1';
|
||||
const sessionDir = path.join(tempRoot, '.factory', 'sessions', 'demo-project');
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(sessionDir, `${sessionId}.jsonl`),
|
||||
`${JSON.stringify({
|
||||
type: 'session_start',
|
||||
id: sessionId,
|
||||
version: 2,
|
||||
cwd: options.cwd ?? '/tmp/droid-project',
|
||||
})}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(sessionDir, `${sessionId}.settings.json`),
|
||||
JSON.stringify({
|
||||
model: options.selector ?? 'custom:Demo-0',
|
||||
providerLock: 'openai',
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function writeDroidGlobalSettings(tempRoot: string): void {
|
||||
const factoryDir = path.join(tempRoot, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{
|
||||
displayName: 'Demo',
|
||||
model: 'gpt-4.1',
|
||||
provider: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
},
|
||||
{
|
||||
displayName: 'CCS codex',
|
||||
model: 'gpt-5',
|
||||
provider: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(path.join(factoryDir, 'costs.db'), '', 'utf8');
|
||||
}
|
||||
|
||||
describe('droid native usage collector', () => {
|
||||
let tempRoot = '';
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-native-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('hydrates cost rows with session metadata and resolved custom model IDs', async () => {
|
||||
writeDroidGlobalSettings(tempRoot);
|
||||
const sessionId = writeDroidSessionFixture(tempRoot);
|
||||
|
||||
const querySqliteJson: DroidSqliteQuery = async () => [
|
||||
{
|
||||
session_id: sessionId,
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
input_tokens: 120,
|
||||
output_tokens: 30,
|
||||
},
|
||||
];
|
||||
|
||||
const entries = await scanDroidNativeUsageEntries({
|
||||
env: { CCS_HOME: tempRoot },
|
||||
homeDir: tempRoot,
|
||||
querySqliteJson,
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
sessionId,
|
||||
projectPath: '/tmp/droid-project',
|
||||
model: 'gpt-4.1',
|
||||
inputTokens: 120,
|
||||
outputTokens: 30,
|
||||
target: 'droid',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips CCS-managed selectors to avoid double counting CLIProxy-backed Droid runs', async () => {
|
||||
writeDroidGlobalSettings(tempRoot);
|
||||
const sessionId = writeDroidSessionFixture(tempRoot, { selector: 'custom:CCS-codex-1' });
|
||||
|
||||
const querySqliteJson: DroidSqliteQuery = async () => [
|
||||
{
|
||||
session_id: sessionId,
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
input_tokens: 120,
|
||||
output_tokens: 30,
|
||||
},
|
||||
];
|
||||
|
||||
const entries = await scanDroidNativeUsageEntries({
|
||||
env: { CCS_HOME: tempRoot },
|
||||
homeDir: tempRoot,
|
||||
querySqliteJson,
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('surfaces sqlite failures instead of flattening them into an empty result', async () => {
|
||||
writeDroidGlobalSettings(tempRoot);
|
||||
writeDroidSessionFixture(tempRoot);
|
||||
|
||||
const querySqliteJson: DroidSqliteQuery = async () => {
|
||||
throw new Error('sqlite blew up');
|
||||
};
|
||||
|
||||
await expect(
|
||||
scanDroidNativeUsageEntries({
|
||||
env: { CCS_HOME: tempRoot },
|
||||
homeDir: tempRoot,
|
||||
querySqliteJson,
|
||||
})
|
||||
).rejects.toThrow('sqlite blew up');
|
||||
});
|
||||
|
||||
it('warns when cost rows are skipped because local session metadata is missing', async () => {
|
||||
writeDroidGlobalSettings(tempRoot);
|
||||
const sessionId = writeDroidSessionFixture(tempRoot);
|
||||
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
const querySqliteJson: DroidSqliteQuery = async () => [
|
||||
{
|
||||
session_id: 'missing-session',
|
||||
timestamp: '2026-03-02T09:00:00.000Z',
|
||||
input_tokens: 50,
|
||||
output_tokens: 10,
|
||||
},
|
||||
{
|
||||
session_id: sessionId,
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
input_tokens: 120,
|
||||
output_tokens: 30,
|
||||
},
|
||||
];
|
||||
|
||||
const entries = await scanDroidNativeUsageEntries({
|
||||
env: { CCS_HOME: tempRoot },
|
||||
homeDir: tempRoot,
|
||||
querySqliteJson,
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.sessionId).toBe(sessionId);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Skipped 1 Droid native cost row(s) without local session metadata')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,12 @@ import * as path from 'path';
|
||||
let tempRoot = '';
|
||||
let ccsDir = '';
|
||||
let claudeDir = '';
|
||||
let codexDir = '';
|
||||
let aggregator: typeof import('../../../src/web-server/usage/aggregator');
|
||||
let originalCcsDir: string | undefined;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCodexHome: string | undefined;
|
||||
|
||||
function writeClaudeJsonlFixture(): void {
|
||||
const projectDir = path.join(claudeDir, 'projects', 'project-one');
|
||||
@@ -37,8 +40,20 @@ function writeCliproxySnapshotFixture(): void {
|
||||
fs.mkdirSync(snapshotDir, { recursive: true });
|
||||
|
||||
const snapshot = {
|
||||
version: 1,
|
||||
version: 3,
|
||||
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: [
|
||||
{
|
||||
date: '2026-03-02',
|
||||
@@ -140,11 +155,16 @@ beforeEach(async () => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-agg-'));
|
||||
ccsDir = path.join(tempRoot, '.ccs');
|
||||
claudeDir = path.join(tempRoot, '.claude');
|
||||
codexDir = path.join(tempRoot, '.codex');
|
||||
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CCS_DIR = ccsDir;
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir;
|
||||
process.env.CODEX_HOME = codexDir;
|
||||
|
||||
writeUnifiedConfigFixture();
|
||||
writeClaudeJsonlFixture();
|
||||
@@ -170,6 +190,18 @@ afterEach(() => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (originalCodexHome !== undefined) {
|
||||
process.env.CODEX_HOME = originalCodexHome;
|
||||
} else {
|
||||
delete process.env.CODEX_HOME;
|
||||
}
|
||||
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -193,4 +225,162 @@ describe('usage aggregator cliproxy integration', () => {
|
||||
aggregator.clearUsageCache();
|
||||
expect(aggregator.getLastFetchTimestamp()).toBeNull();
|
||||
});
|
||||
|
||||
it('avoids double counting when CLAUDE_CONFIG_DIR points at a CCS instance', async () => {
|
||||
const instancePath = path.join(ccsDir, 'instances', 'work-profile');
|
||||
const instanceProjectDir = path.join(instancePath, 'projects', 'profile-one');
|
||||
fs.mkdirSync(instanceProjectDir, { recursive: true });
|
||||
|
||||
const globalLine = JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId: 'session-global',
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
cwd: '/tmp/global',
|
||||
message: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(path.join(claudeDir, 'projects', 'project-one', 'global.jsonl'), `${globalLine}\n`);
|
||||
|
||||
const instanceLine = JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId: 'session-instance',
|
||||
timestamp: '2026-03-02T11:00:00.000Z',
|
||||
cwd: '/tmp/instance',
|
||||
message: {
|
||||
model: 'gemini-2.5-pro',
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(path.join(instanceProjectDir, 'usage.jsonl'), `${instanceLine}\n`);
|
||||
|
||||
process.env.CLAUDE_CONFIG_DIR = instancePath;
|
||||
aggregator.clearUsageCache();
|
||||
|
||||
const daily = await aggregator.getCachedDailyData();
|
||||
|
||||
expect(daily).toHaveLength(1);
|
||||
expect(daily[0].inputTokens).toBe(260);
|
||||
expect(daily[0].outputTokens).toBe(61);
|
||||
expect(daily[0].modelsUsed).toContain('claude-sonnet-4-5');
|
||||
expect(daily[0].modelsUsed).toContain('gemini-2.5-pro');
|
||||
});
|
||||
|
||||
it('merges monthly model breakdowns when sources collide', () => {
|
||||
const result = aggregator.mergeMonthlyData([
|
||||
[
|
||||
{
|
||||
month: '2026-03',
|
||||
source: 'default',
|
||||
inputTokens: 10,
|
||||
outputTokens: 1,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalCost: 1,
|
||||
modelsUsed: ['claude-sonnet-4-5'],
|
||||
modelBreakdowns: [
|
||||
{
|
||||
modelName: 'claude-sonnet-4-5',
|
||||
inputTokens: 10,
|
||||
outputTokens: 1,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
month: '2026-03',
|
||||
source: 'instance',
|
||||
inputTokens: 20,
|
||||
outputTokens: 2,
|
||||
cacheCreationTokens: 5,
|
||||
cacheReadTokens: 3,
|
||||
totalCost: 2,
|
||||
modelsUsed: ['gemini-2.5-pro'],
|
||||
modelBreakdowns: [
|
||||
{
|
||||
modelName: 'gemini-2.5-pro',
|
||||
inputTokens: 20,
|
||||
outputTokens: 2,
|
||||
cacheCreationTokens: 5,
|
||||
cacheReadTokens: 3,
|
||||
cost: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].inputTokens).toBe(30);
|
||||
expect(result[0].outputTokens).toBe(3);
|
||||
expect(result[0].cacheCreationTokens).toBe(5);
|
||||
expect(result[0].cacheReadTokens).toBe(3);
|
||||
expect(result[0].totalCost).toBe(3);
|
||||
expect(result[0].modelBreakdowns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('falls back to model cardinality when merging legacy hourly buckets without requestCount', () => {
|
||||
const result = aggregator.mergeHourlyData([
|
||||
[
|
||||
{
|
||||
hour: '2026-03-02 10:00',
|
||||
source: 'legacy-a',
|
||||
inputTokens: 10,
|
||||
outputTokens: 1,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 1,
|
||||
totalCost: 1,
|
||||
modelsUsed: ['claude-sonnet-4-5'],
|
||||
modelBreakdowns: [
|
||||
{
|
||||
modelName: 'claude-sonnet-4-5',
|
||||
inputTokens: 10,
|
||||
outputTokens: 1,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
hour: '2026-03-02 10:00',
|
||||
source: 'legacy-b',
|
||||
inputTokens: 20,
|
||||
outputTokens: 2,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 2,
|
||||
totalCost: 2,
|
||||
modelsUsed: ['gemini-2.5-pro'],
|
||||
modelBreakdowns: [
|
||||
{
|
||||
modelName: 'gemini-2.5-pro',
|
||||
inputTokens: 20,
|
||||
outputTokens: 2,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].requestCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
let tempRoot = '';
|
||||
let ccsDir = '';
|
||||
let claudeDir = '';
|
||||
let codexDir = '';
|
||||
let aggregator: typeof import('../../../src/web-server/usage/aggregator');
|
||||
let originalCcsDir: string | undefined;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCodexHome: string | undefined;
|
||||
|
||||
function writeUnifiedConfigFixture(): void {
|
||||
const yaml = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants: {}
|
||||
cliproxy_server:
|
||||
local:
|
||||
port: 65534
|
||||
`;
|
||||
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf8');
|
||||
}
|
||||
|
||||
function writeClaudeJsonlFixture(): void {
|
||||
const projectDir = path.join(claudeDir, 'projects', 'project-one');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
const line = JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
cwd: '/tmp/project',
|
||||
message: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 40,
|
||||
},
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(path.join(projectDir, 'usage.jsonl'), `${line}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function writeCliproxySnapshotFixture(): void {
|
||||
const snapshotDir = path.join(ccsDir, 'cache', 'cliproxy-usage');
|
||||
fs.mkdirSync(snapshotDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(snapshotDir, 'latest.json'),
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
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,
|
||||
requestCount: 1,
|
||||
cost: 0.2,
|
||||
failed: false,
|
||||
},
|
||||
],
|
||||
daily: [
|
||||
{
|
||||
date: '2026-03-02',
|
||||
source: 'cliproxy',
|
||||
inputTokens: 50,
|
||||
outputTokens: 10,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 5,
|
||||
cost: 0.2,
|
||||
totalCost: 0.2,
|
||||
modelsUsed: ['gemini-2.5-pro'],
|
||||
modelBreakdowns: [
|
||||
{
|
||||
modelName: 'gemini-2.5-pro',
|
||||
inputTokens: 50,
|
||||
outputTokens: 10,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 5,
|
||||
cost: 0.2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hourly: [
|
||||
{
|
||||
hour: '2026-03-02 10:00',
|
||||
source: 'cliproxy',
|
||||
inputTokens: 50,
|
||||
outputTokens: 10,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 5,
|
||||
cost: 0.2,
|
||||
totalCost: 0.2,
|
||||
modelsUsed: ['gemini-2.5-pro'],
|
||||
modelBreakdowns: [
|
||||
{
|
||||
modelName: 'gemini-2.5-pro',
|
||||
inputTokens: 50,
|
||||
outputTokens: 10,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 5,
|
||||
cost: 0.2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
monthly: [
|
||||
{
|
||||
month: '2026-03',
|
||||
source: 'cliproxy',
|
||||
inputTokens: 50,
|
||||
outputTokens: 10,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 5,
|
||||
totalCost: 0.2,
|
||||
modelsUsed: ['gemini-2.5-pro'],
|
||||
modelBreakdowns: [
|
||||
{
|
||||
modelName: 'gemini-2.5-pro',
|
||||
inputTokens: 50,
|
||||
outputTokens: 10,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 5,
|
||||
cost: 0.2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
function writeCodexFixture(modelProvider = 'openai'): void {
|
||||
const rolloutDir = path.join(codexDir, 'sessions', '2026', '03', '02');
|
||||
fs.mkdirSync(rolloutDir, { recursive: true });
|
||||
const rolloutPath = path.join(rolloutDir, 'rollout-native-codex.jsonl');
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: 'codex-session',
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
cwd: '/tmp/codex-project',
|
||||
cli_version: '0.126.0',
|
||||
source: 'cli',
|
||||
model_provider: modelProvider,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:00:01.000Z',
|
||||
type: 'turn_context',
|
||||
payload: {
|
||||
turn_id: 'turn-1',
|
||||
cwd: '/tmp/codex-project',
|
||||
model: 'gpt-5',
|
||||
effort: 'high',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-02T10:05:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total_token_usage: {
|
||||
input_tokens: 30,
|
||||
cached_input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
reasoning_output_tokens: 1,
|
||||
total_tokens: 38,
|
||||
},
|
||||
last_token_usage: {
|
||||
input_tokens: 30,
|
||||
cached_input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
reasoning_output_tokens: 1,
|
||||
total_tokens: 38,
|
||||
},
|
||||
model_context_window: 200000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
fs.writeFileSync(rolloutPath, `${lines.join('\n')}\n`, 'utf8');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-native-agg-'));
|
||||
ccsDir = path.join(tempRoot, '.ccs');
|
||||
claudeDir = path.join(tempRoot, '.claude');
|
||||
codexDir = path.join(tempRoot, '.codex-native');
|
||||
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
originalCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CCS_DIR = ccsDir;
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir;
|
||||
process.env.CODEX_HOME = codexDir;
|
||||
|
||||
writeUnifiedConfigFixture();
|
||||
writeClaudeJsonlFixture();
|
||||
writeCliproxySnapshotFixture();
|
||||
writeCodexFixture();
|
||||
|
||||
aggregator = await import('../../../src/web-server/usage/aggregator');
|
||||
aggregator.clearUsageCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
aggregator.shutdownUsageAggregator();
|
||||
aggregator.clearUsageCache();
|
||||
|
||||
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
|
||||
else delete process.env.CCS_DIR;
|
||||
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
else delete process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
if (originalCodexHome !== undefined) process.env.CODEX_HOME = originalCodexHome;
|
||||
else delete process.env.CODEX_HOME;
|
||||
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('usage aggregator native runtime integration', () => {
|
||||
it('merges native codex usage with claude and cliproxy analytics', async () => {
|
||||
const daily = await aggregator.getCachedDailyData();
|
||||
|
||||
expect(daily).toHaveLength(1);
|
||||
expect(daily[0]).toMatchObject({
|
||||
date: '2026-03-02',
|
||||
inputTokens: 180,
|
||||
outputTokens: 53,
|
||||
cacheReadTokens: 10,
|
||||
});
|
||||
expect(daily[0].modelsUsed).toContain('claude-sonnet-4-5');
|
||||
expect(daily[0].modelsUsed).toContain('gemini-2.5-pro');
|
||||
expect(daily[0].modelsUsed).toContain('gpt-5');
|
||||
});
|
||||
|
||||
it('ignores cliproxy-backed codex rollouts to avoid double counting', async () => {
|
||||
writeCodexFixture('cliproxy');
|
||||
aggregator.clearUsageCache();
|
||||
|
||||
const daily = await aggregator.getCachedDailyData();
|
||||
|
||||
expect(daily).toHaveLength(1);
|
||||
expect(daily[0]).toMatchObject({
|
||||
inputTokens: 150,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 5,
|
||||
});
|
||||
expect(daily[0].modelsUsed).not.toContain('gpt-5');
|
||||
});
|
||||
|
||||
it('ignores ccs_runtime-backed codex bridge rollouts to avoid double counting', async () => {
|
||||
writeCodexFixture('ccs_runtime');
|
||||
aggregator.clearUsageCache();
|
||||
|
||||
const daily = await aggregator.getCachedDailyData();
|
||||
|
||||
expect(daily).toHaveLength(1);
|
||||
expect(daily[0]).toMatchObject({
|
||||
inputTokens: 150,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 5,
|
||||
});
|
||||
expect(daily[0].modelsUsed).not.toContain('gpt-5');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
type HandlersModule = typeof import('../../../src/web-server/usage/handlers');
|
||||
type AggregatorModule = typeof import('../../../src/web-server/usage/aggregator');
|
||||
|
||||
interface AssistantFixture {
|
||||
project: string;
|
||||
sessionId: string;
|
||||
timestamp: string;
|
||||
model: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
}
|
||||
|
||||
interface MockResponse {
|
||||
payload: unknown;
|
||||
statusCode: number;
|
||||
status: (code: number) => MockResponse;
|
||||
json: (body: unknown) => MockResponse;
|
||||
}
|
||||
|
||||
let tempHome = '';
|
||||
let claudeDir = '';
|
||||
let codexDir = '';
|
||||
let handlers: HandlersModule;
|
||||
let aggregator: AggregatorModule;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
let originalCodexHome: string | undefined;
|
||||
|
||||
function writeUnifiedConfigFixture(): void {
|
||||
const yaml = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants: {}
|
||||
cliproxy_server:
|
||||
local:
|
||||
port: 65534
|
||||
`;
|
||||
|
||||
fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempHome, '.ccs', 'config.yaml'), yaml, 'utf-8');
|
||||
}
|
||||
|
||||
function writeAssistantEntries(entries: AssistantFixture[]): void {
|
||||
for (const entry of entries) {
|
||||
const projectDir = path.join(claudeDir, 'projects', entry.project);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const line = JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId: entry.sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
version: '1.0.0',
|
||||
cwd: `/tmp/${entry.project}`,
|
||||
message: {
|
||||
model: entry.model,
|
||||
usage: {
|
||||
input_tokens: entry.inputTokens ?? 0,
|
||||
output_tokens: entry.outputTokens ?? 0,
|
||||
cache_creation_input_tokens: entry.cacheCreationTokens ?? 0,
|
||||
cache_read_input_tokens: entry.cacheReadTokens ?? 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, `${entry.sessionId}.jsonl`),
|
||||
`${line}\n`,
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createMockResponse(): MockResponse {
|
||||
return {
|
||||
payload: undefined,
|
||||
statusCode: 200,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(body: unknown) {
|
||||
this.payload = body;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-handlers-'));
|
||||
claudeDir = path.join(tempHome, '.claude');
|
||||
codexDir = path.join(tempHome, '.codex');
|
||||
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
originalCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir;
|
||||
process.env.CODEX_HOME = codexDir;
|
||||
|
||||
writeUnifiedConfigFixture();
|
||||
|
||||
handlers = await import('../../../src/web-server/usage/handlers');
|
||||
aggregator = await import('../../../src/web-server/usage/aggregator');
|
||||
aggregator.shutdownUsageAggregator();
|
||||
aggregator.clearUsageCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
aggregator.shutdownUsageAggregator();
|
||||
aggregator.clearUsageCache();
|
||||
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (originalClaudeConfigDir !== undefined) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
|
||||
if (originalCodexHome !== undefined) {
|
||||
process.env.CODEX_HOME = originalCodexHome;
|
||||
} else {
|
||||
delete process.env.CODEX_HOME;
|
||||
}
|
||||
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('usage handlers semantics', () => {
|
||||
it('includes cache tokens in summary totals and uses calendar-day averages', async () => {
|
||||
writeAssistantEntries([
|
||||
{
|
||||
project: 'project-one',
|
||||
sessionId: 'session-a',
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
model: 'claude-sonnet-4-5',
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 100_000,
|
||||
cacheCreationTokens: 100_000,
|
||||
cacheReadTokens: 200_000,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = createMockResponse();
|
||||
await handlers.handleSummary(
|
||||
{ query: { since: '20260301', until: '20260303' } } as never,
|
||||
res as never
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.payload).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
totalTokens: 1_400_000,
|
||||
totalCacheTokens: 300_000,
|
||||
totalDays: 3,
|
||||
activeDays: 1,
|
||||
averageTokensPerDay: 466_667,
|
||||
averageTokensPerActiveDay: 1_400_000,
|
||||
averageCostPerDay: 1.65,
|
||||
averageCostPerActiveDay: 4.93,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('counts hourly requests from raw entries instead of distinct models', async () => {
|
||||
writeAssistantEntries([
|
||||
{
|
||||
project: 'project-one',
|
||||
sessionId: 'session-a',
|
||||
timestamp: '2026-03-02T10:05:00.000Z',
|
||||
model: 'claude-sonnet-4-5',
|
||||
inputTokens: 100,
|
||||
outputTokens: 10,
|
||||
},
|
||||
{
|
||||
project: 'project-two',
|
||||
sessionId: 'session-b',
|
||||
timestamp: '2026-03-02T10:15:00.000Z',
|
||||
model: 'claude-sonnet-4-5',
|
||||
inputTokens: 120,
|
||||
outputTokens: 15,
|
||||
},
|
||||
{
|
||||
project: 'project-three',
|
||||
sessionId: 'session-c',
|
||||
timestamp: '2026-03-02T10:30:00.000Z',
|
||||
model: 'gemini-2.5-pro',
|
||||
inputTokens: 80,
|
||||
outputTokens: 20,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = createMockResponse();
|
||||
await handlers.handleHourly(
|
||||
{ query: { since: '20260302', until: '20260302' } } as never,
|
||||
res as never
|
||||
);
|
||||
|
||||
const payload = res.payload as { success: boolean; data: Array<{ hour: string; requests: number }> };
|
||||
const targetHour = payload.data.find((row) => row.hour === '2026-03-02 10:00');
|
||||
|
||||
expect(targetHour?.requests).toBe(3);
|
||||
});
|
||||
|
||||
it('uses overlapping months for monthly filtering', async () => {
|
||||
writeAssistantEntries([
|
||||
{
|
||||
project: 'march-project',
|
||||
sessionId: 'session-march',
|
||||
timestamp: '2026-03-20T10:00:00.000Z',
|
||||
model: 'claude-sonnet-4-5',
|
||||
inputTokens: 100,
|
||||
outputTokens: 10,
|
||||
},
|
||||
{
|
||||
project: 'april-project',
|
||||
sessionId: 'session-april',
|
||||
timestamp: '2026-04-05T10:00:00.000Z',
|
||||
model: 'claude-sonnet-4-5',
|
||||
inputTokens: 200,
|
||||
outputTokens: 20,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = createMockResponse();
|
||||
await handlers.handleMonthly(
|
||||
{ query: { since: '20260315', until: '20260410' } } as never,
|
||||
res as never
|
||||
);
|
||||
|
||||
expect(res.payload).toMatchObject({
|
||||
success: true,
|
||||
data: [
|
||||
expect.objectContaining({ month: '2026-03' }),
|
||||
expect.objectContaining({ month: '2026-04' }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports actual cache size after warming the usage cache', async () => {
|
||||
writeAssistantEntries([
|
||||
{
|
||||
project: 'project-one',
|
||||
sessionId: 'session-a',
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
model: 'claude-sonnet-4-5',
|
||||
inputTokens: 100,
|
||||
outputTokens: 10,
|
||||
},
|
||||
]);
|
||||
|
||||
await aggregator.getCachedDailyData();
|
||||
|
||||
const res = createMockResponse();
|
||||
handlers.handleStatus({} as never, res as never);
|
||||
|
||||
const payload = res.payload as {
|
||||
success: boolean;
|
||||
data: { lastFetch: number | null; cacheSize: unknown };
|
||||
};
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.data.lastFetch).not.toBeNull();
|
||||
expect(payload.data.cacheSize).toBe(aggregator.getUsageCacheSize());
|
||||
expect(aggregator.getUsageCacheSize()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('includes cache-only model activity in model token percentages', async () => {
|
||||
writeAssistantEntries([
|
||||
{
|
||||
project: 'project-one',
|
||||
sessionId: 'session-a',
|
||||
timestamp: '2026-03-02T10:00:00.000Z',
|
||||
model: 'claude-sonnet-4-5',
|
||||
inputTokens: 100,
|
||||
outputTokens: 0,
|
||||
},
|
||||
{
|
||||
project: 'project-two',
|
||||
sessionId: 'session-b',
|
||||
timestamp: '2026-03-02T11:00:00.000Z',
|
||||
model: 'gemini-2.5-pro',
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 100,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = createMockResponse();
|
||||
await handlers.handleModels(
|
||||
{ query: { since: '20260302', until: '20260302' } } as never,
|
||||
res as never
|
||||
);
|
||||
|
||||
expect(res.payload).toMatchObject({
|
||||
success: true,
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ model: 'claude-sonnet-4-5', tokens: 100, percentage: 50 }),
|
||||
expect.objectContaining({ model: 'gemini-2.5-pro', tokens: 100, percentage: 50 }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects reversed date ranges before computing summary totals', async () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handlers.handleSummary(
|
||||
{ query: { since: '20260410', until: '20260401' } } as never,
|
||||
res as never
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.payload).toMatchObject({
|
||||
success: false,
|
||||
error: 'The "since" date must be earlier than or equal to "until"',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
const { t } = useTranslation();
|
||||
const ioRatioStatus = getIoRatioStatus(model.ioRatio);
|
||||
const cacheTokens = model.cacheCreationTokens + model.cacheReadTokens;
|
||||
const totalTokensLabel = cacheTokens > 0 ? 'All Tokens' : t('analyticsCards.totalTokens');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -49,7 +51,7 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
|
||||
{formatCompactNumber(model.tokens)}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{t('analyticsCards.totalTokens')}
|
||||
{totalTokensLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,26 +31,18 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
||||
|
||||
const sessions = data.sessions;
|
||||
const totalSessions = data.total;
|
||||
|
||||
// Calculate average tokens per session
|
||||
const totalTokens = sessions.reduce((sum, s) => sum + (s.inputTokens + s.outputTokens), 0);
|
||||
const avgTokens = Math.round(totalTokens / sessions.length);
|
||||
const sampledSessions = sessions.length;
|
||||
const hasPartialSample = data.hasMore || data.offset > 0;
|
||||
|
||||
// Calculate total cost for visible sessions
|
||||
const totalCost = sessions.reduce((sum, s) => sum + s.cost, 0);
|
||||
const avgCost = totalCost / sessions.length;
|
||||
|
||||
// Most recent session
|
||||
const lastSession = sessions[0];
|
||||
const lastActive = lastSession
|
||||
? formatDistanceToNow(new Date(lastSession.lastActivity), { addSuffix: true })
|
||||
: 'N/A';
|
||||
|
||||
return {
|
||||
displayedSessions: hasPartialSample ? sampledSessions : totalSessions,
|
||||
totalSessions,
|
||||
avgTokens,
|
||||
avgCost,
|
||||
lastActive,
|
||||
hasPartialSample,
|
||||
recentSessions: sessions.slice(0, 3),
|
||||
};
|
||||
}, [data]);
|
||||
@@ -105,12 +97,16 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 text-blue-600 dark:text-blue-400">
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-xl font-bold">{stats.totalSessions}</span>
|
||||
<span className="text-xl font-bold">{stats.displayedSessions}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||
{/* TODO i18n: missing key for "Total Sessions" */}
|
||||
Total Sessions
|
||||
{stats.hasPartialSample ? 'Sampled Sessions' : 'Total Sessions'}
|
||||
</p>
|
||||
{stats.hasPartialSample && (
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{stats.totalSessions} total
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Avg Cost */}
|
||||
@@ -122,8 +118,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||
{/* TODO i18n: missing key for "Avg Cost/Session" */}
|
||||
Avg Cost/Session
|
||||
{stats.hasPartialSample ? 'Recent Avg Cost' : 'Avg Cost/Session'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +154,8 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
||||
<div className={cn('text-right shrink-0 ml-2', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
<div className="font-mono">${session.cost.toFixed(2)}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{formatCompact(session.inputTokens + session.outputTokens)} toks
|
||||
{formatCompact(session.tokens ?? session.inputTokens + session.outputTokens)}{' '}
|
||||
toks
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,11 @@ interface UsageSummaryCardsProps {
|
||||
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
||||
const { privacyMode } = usePrivacy();
|
||||
const { t } = useTranslation();
|
||||
const totalTokens = data?.totalTokens ?? 0;
|
||||
const totalInputTokens = data?.totalInputTokens ?? 0;
|
||||
const totalOutputTokens = data?.totalOutputTokens ?? 0;
|
||||
const totalCacheTokens = data?.totalCacheTokens ?? 0;
|
||||
const totalIoTokens = totalInputTokens + totalOutputTokens;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -47,19 +52,27 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
||||
const cacheCost =
|
||||
(data?.tokenBreakdown?.cacheCreation?.cost ?? 0) + (data?.tokenBreakdown?.cacheRead?.cost ?? 0);
|
||||
const cacheCostPercent = data?.totalCost ? Math.round((cacheCost / data.totalCost) * 100) : 0;
|
||||
const totalTokensIncludesCache = totalCacheTokens > 0 && totalTokens > totalIoTokens;
|
||||
const totalTokensTitle =
|
||||
totalCacheTokens > 0 && !totalTokensIncludesCache
|
||||
? 'Total Tokens (I/O)'
|
||||
: t('analyticsSummary.totalTokens');
|
||||
const totalTokensSubtitle = totalTokensIncludesCache
|
||||
? `${formatNumber(totalInputTokens)} in / ${formatNumber(totalOutputTokens)} out / ${formatNumber(totalCacheTokens)} cache`
|
||||
: t('analyticsSummary.totalTokensSubtitle', {
|
||||
input: formatNumber(totalInputTokens),
|
||||
output: formatNumber(totalOutputTokens),
|
||||
});
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t('analyticsSummary.totalTokens'),
|
||||
value: data?.totalTokens ?? 0,
|
||||
title: totalTokensTitle,
|
||||
value: totalTokens,
|
||||
icon: FileText,
|
||||
format: (v: number) => formatNumber(v),
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
|
||||
subtitle: t('analyticsSummary.totalTokensSubtitle', {
|
||||
input: formatNumber(data?.totalInputTokens ?? 0),
|
||||
output: formatNumber(data?.totalOutputTokens ?? 0),
|
||||
}),
|
||||
subtitle: totalTokensSubtitle,
|
||||
},
|
||||
{
|
||||
title: t('analyticsSummary.totalCost'),
|
||||
|
||||
@@ -146,7 +146,6 @@ export function UsageTrendChart({
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
const tooltipData = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{label}</p>
|
||||
@@ -162,16 +161,6 @@ export function UsageTrendChart({
|
||||
: `$${entry.value}`}
|
||||
</p>
|
||||
))}
|
||||
{'requests' in tooltipData && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground mt-1',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
Requests: {tooltipData.requests}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
||||
+16
-16
@@ -95,6 +95,7 @@ export interface UsageInsights {
|
||||
export interface Session {
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
tokens?: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cost: number;
|
||||
@@ -122,7 +123,6 @@ export interface MonthlyUsage {
|
||||
export interface UsageQueryOptions {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
profile?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
@@ -145,48 +145,49 @@ function formatDateForApi(date: Date): string {
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
function buildUsageUrl(path: string, params: URLSearchParams): string {
|
||||
const query = params.toString();
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
export const usageApi = {
|
||||
summary: (options?: UsageQueryOptions) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
||||
if (options?.profile) params.append('profile', options.profile);
|
||||
return request<UsageSummary>(`/usage/summary?${params}`);
|
||||
return request<UsageSummary>(buildUsageUrl('/usage/summary', params));
|
||||
},
|
||||
trends: (options?: UsageQueryOptions) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
||||
if (options?.profile) params.append('profile', options.profile);
|
||||
return request<DailyUsage[]>(`/usage/daily?${params}`);
|
||||
return request<DailyUsage[]>(buildUsageUrl('/usage/daily', params));
|
||||
},
|
||||
hourly: (options?: UsageQueryOptions) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
||||
return request<HourlyUsage[]>(`/usage/hourly?${params}`);
|
||||
return request<HourlyUsage[]>(buildUsageUrl('/usage/hourly', params));
|
||||
},
|
||||
models: (options?: UsageQueryOptions) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
||||
if (options?.profile) params.append('profile', options.profile);
|
||||
return request<ModelUsage[]>(`/usage/models?${params}`);
|
||||
return request<ModelUsage[]>(buildUsageUrl('/usage/models', params));
|
||||
},
|
||||
sessions: (options?: UsageQueryOptions) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
||||
if (options?.profile) params.append('profile', options.profile);
|
||||
if (options?.limit) params.append('limit', options.limit.toString());
|
||||
if (options?.offset) params.append('offset', options.offset.toString());
|
||||
return request<PaginatedSessions>(`/usage/sessions?${params}`);
|
||||
return request<PaginatedSessions>(buildUsageUrl('/usage/sessions', params));
|
||||
},
|
||||
monthly: (months?: number, profile?: string) => {
|
||||
monthly: (options?: UsageQueryOptions) => {
|
||||
const params = new URLSearchParams();
|
||||
if (months) params.append('months', months.toString());
|
||||
if (profile) params.append('profile', profile);
|
||||
return request<MonthlyUsage[]>(`/usage/monthly?${params}`);
|
||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
||||
return request<MonthlyUsage[]>(buildUsageUrl('/usage/monthly', params));
|
||||
},
|
||||
/** Clear server-side usage cache and force fresh data fetch */
|
||||
refresh: async (): Promise<void> => {
|
||||
@@ -205,8 +206,7 @@ export const usageApi = {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
||||
if (options?.profile) params.append('profile', options.profile);
|
||||
return request<UsageInsights>(`/usage/insights?${params}`);
|
||||
return request<UsageInsights>(buildUsageUrl('/usage/insights', params));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
type ModelUsage,
|
||||
} from '@/hooks/use-usage';
|
||||
|
||||
const RECENT_SESSION_SAMPLE_LIMIT = 50;
|
||||
|
||||
export function useAnalyticsPage() {
|
||||
// Default to last 30 days
|
||||
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
||||
@@ -55,7 +57,10 @@ export function useAnalyticsPage() {
|
||||
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
|
||||
const { data: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions);
|
||||
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
|
||||
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
|
||||
const { data: sessions, isLoading: isSessionsLoading } = useSessions({
|
||||
...apiOptions,
|
||||
limit: RECENT_SESSION_SAMPLE_LIMIT,
|
||||
});
|
||||
const { data: status } = useUsageStatus();
|
||||
|
||||
// Handle "24H" preset click
|
||||
|
||||
@@ -11,6 +11,8 @@ import { afterEach, vi } from 'vitest';
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// Mock matchMedia for components that use media queries
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
/**
|
||||
* Session Stats Card Tests
|
||||
* Unit tests for SessionStatsCard component with project name formatting
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SessionStatsCard } from '../../../../src/components/analytics/session-stats-card';
|
||||
import { AllProviders } from '../../../setup/test-utils';
|
||||
import type { PaginatedSessions } from '../../../../src/hooks/use-usage';
|
||||
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
||||
import type { PaginatedSessions, Session } from '@/hooks/use-usage';
|
||||
import { AllProviders } from '@tests/setup/test-utils';
|
||||
|
||||
// Mock date-fns to return consistent dates
|
||||
vi.mock('date-fns', async () => {
|
||||
const actual = await vi.importActual('date-fns');
|
||||
return {
|
||||
@@ -18,288 +12,133 @@ vi.mock('date-fns', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
function buildSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
projectPath: '/Users/kaitran/CloudPersonal/worktrees/ccs-cli/feature-branch',
|
||||
inputTokens: 1_500,
|
||||
outputTokens: 2_500,
|
||||
cost: 0.08,
|
||||
lastActivity: '2026-04-26T14:00:00.000Z',
|
||||
modelsUsed: ['claude-sonnet-4'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPaginatedSessions(overrides: Partial<PaginatedSessions> = {}): PaginatedSessions {
|
||||
return {
|
||||
sessions: [buildSession()],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SessionStatsCard', () => {
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Loading and Empty States', () => {
|
||||
it('renders loading skeleton when isLoading is true', () => {
|
||||
const { container } = render(<SessionStatsCard data={undefined} isLoading={true} />, {
|
||||
wrapper: AllProviders,
|
||||
});
|
||||
|
||||
// Should have skeleton loading elements
|
||||
expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument();
|
||||
it('renders a loading skeleton', () => {
|
||||
const { container } = render(<SessionStatsCard data={undefined} isLoading />, {
|
||||
wrapper: AllProviders,
|
||||
});
|
||||
|
||||
it('shows empty state when no data available', () => {
|
||||
render(<SessionStatsCard data={undefined} />, { wrapper: AllProviders });
|
||||
|
||||
expect(screen.getByText('Session Stats')).toBeInTheDocument();
|
||||
expect(screen.getByText('No session data available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when sessions array is empty', () => {
|
||||
const emptyData: PaginatedSessions = {
|
||||
sessions: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
render(<SessionStatsCard data={emptyData} />, { wrapper: AllProviders });
|
||||
|
||||
expect(screen.getByText('Session Stats')).toBeInTheDocument();
|
||||
expect(screen.getByText('No session data available')).toBeInTheDocument();
|
||||
});
|
||||
expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Session Stats Display', () => {
|
||||
const createMockSession = (
|
||||
projectPath: string,
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
cost: number
|
||||
) => ({
|
||||
sessionId: `session-${Math.random()}`,
|
||||
projectPath,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cost,
|
||||
lastActivity: new Date().toISOString(),
|
||||
it('renders the empty state with the live paginated contract', () => {
|
||||
render(<SessionStatsCard data={buildPaginatedSessions({ sessions: [], total: 0 })} />, {
|
||||
wrapper: AllProviders,
|
||||
});
|
||||
|
||||
const mockData: PaginatedSessions = {
|
||||
expect(screen.getByText('Session Stats')).toBeInTheDocument();
|
||||
expect(screen.getByText('No session data available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps subset session metrics explicitly sample-scoped when pagination truncates the result set', () => {
|
||||
const data = buildPaginatedSessions({
|
||||
sessions: [
|
||||
createMockSession('/home/user/projects/my-app', 1500, 2500, 0.08),
|
||||
createMockSession(
|
||||
'/home/user/workspaces/repo-name/worktrees/feature-branch',
|
||||
2000,
|
||||
3000,
|
||||
0.12
|
||||
),
|
||||
createMockSession('/Users/joe/Developer/share-pi', 1000, 2000, 0.05),
|
||||
buildSession({ sessionId: 'session-1', cost: 0.08 }),
|
||||
buildSession({
|
||||
sessionId: 'session-2',
|
||||
projectPath: '/Users/kaitran/projects/platform/worktrees/kai-fix',
|
||||
inputTokens: 2_000,
|
||||
outputTokens: 3_000,
|
||||
cost: 0.12,
|
||||
target: 'codex',
|
||||
}),
|
||||
buildSession({
|
||||
sessionId: 'session-3',
|
||||
projectPath: '/Users/kaitran/projects/share-pi',
|
||||
inputTokens: 1_000,
|
||||
outputTokens: 2_000,
|
||||
cost: 0.05,
|
||||
}),
|
||||
],
|
||||
total: 3,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockData.sessions = [
|
||||
createMockSession('/home/user/projects/my-app', 1500, 2500, 0.08),
|
||||
createMockSession(
|
||||
'/home/user/workspaces/repo-name/worktrees/feature-branch',
|
||||
2000,
|
||||
3000,
|
||||
0.12
|
||||
),
|
||||
createMockSession('/Users/joe/Developer/share-pi', 1000, 2000, 0.05),
|
||||
];
|
||||
total: 9,
|
||||
hasMore: true,
|
||||
});
|
||||
|
||||
it('displays session stats header', () => {
|
||||
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
|
||||
render(<SessionStatsCard data={data} />, { wrapper: AllProviders });
|
||||
|
||||
expect(screen.getByText('Session Stats')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows total sessions count', () => {
|
||||
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
|
||||
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total Sessions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates and displays average cost per session', () => {
|
||||
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
|
||||
|
||||
// Average cost: (0.08 + 0.12 + 0.05) / 3 = 0.0833 → $0.08
|
||||
// Use getAllByText since cost may appear multiple times (per session + average)
|
||||
const costElements = screen.getAllByText('$0.08');
|
||||
expect(costElements.length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Avg Cost/Session')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows recent activity section', () => {
|
||||
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
|
||||
|
||||
expect(screen.getByText('Recent Activity')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Sampled Sessions')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('9 total')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recent Avg Cost')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('$0.08').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('codex')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Project Name Formatting', () => {
|
||||
it('displays correct project name for simple path', () => {
|
||||
const mockData: PaginatedSessions = {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: '1',
|
||||
projectPath: '/home/user/projects/my-app',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 2000,
|
||||
cost: 0.05,
|
||||
lastActivity: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
|
||||
|
||||
// Should show "my-app" instead of just "app"
|
||||
expect(screen.getByTitle('/home/user/projects/my-app')).toHaveTextContent('my-app');
|
||||
it('keeps the overall average label when the full result set is loaded', () => {
|
||||
const data = buildPaginatedSessions({
|
||||
sessions: [
|
||||
buildSession({ sessionId: 'session-1', cost: 0.04 }),
|
||||
buildSession({ sessionId: 'session-2', cost: 0.06 }),
|
||||
],
|
||||
total: 2,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
it('displays correct project name for worktree path', () => {
|
||||
const mockData: PaginatedSessions = {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: '1',
|
||||
projectPath:
|
||||
'/Users/joe/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 2000,
|
||||
cost: 0.05,
|
||||
lastActivity: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
render(<SessionStatsCard data={data} />, { wrapper: AllProviders });
|
||||
|
||||
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
|
||||
|
||||
// Should show "2026-01-08" instead of just "08"
|
||||
expect(
|
||||
screen.getByTitle(
|
||||
'/Users/joe/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08'
|
||||
)
|
||||
).toHaveTextContent('2026-01-08');
|
||||
});
|
||||
|
||||
it('displays correct project name for shared project', () => {
|
||||
const mockData: PaginatedSessions = {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: '1',
|
||||
projectPath: '/Users/joe/Developer/share-pi',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 2000,
|
||||
cost: 0.05,
|
||||
lastActivity: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
|
||||
|
||||
// Should show "share-pi" instead of just "pi"
|
||||
expect(screen.getByTitle('/Users/joe/Developer/share-pi')).toHaveTextContent('share-pi');
|
||||
});
|
||||
|
||||
it('handles empty project path gracefully', () => {
|
||||
const mockData: PaginatedSessions = {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: '1',
|
||||
projectPath: '',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 2000,
|
||||
cost: 0.05,
|
||||
lastActivity: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
|
||||
|
||||
// Should not crash and show empty string
|
||||
expect(screen.getByTitle('')).toHaveTextContent('');
|
||||
});
|
||||
|
||||
it('handles project path with only slashes', () => {
|
||||
const mockData: PaginatedSessions = {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: '1',
|
||||
projectPath: '///',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 2000,
|
||||
cost: 0.05,
|
||||
lastActivity: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
|
||||
|
||||
// Should handle gracefully and show empty string
|
||||
expect(screen.getByTitle('///')).toHaveTextContent('');
|
||||
});
|
||||
expect(screen.getByText('Avg Cost/Session')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Recent Avg Cost')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Token Count Display', () => {
|
||||
it('displays token counts in compact format', () => {
|
||||
const mockData: PaginatedSessions = {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: '1',
|
||||
projectPath: '/project/test',
|
||||
inputTokens: 1500000, // 1.5M
|
||||
outputTokens: 500000, // 500K
|
||||
cost: 0.1,
|
||||
lastActivity: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
render(<SessionStatsCard data={mockData} />, { wrapper: AllProviders });
|
||||
|
||||
expect(screen.getByText('2.0M toks')).toBeInTheDocument();
|
||||
it('formats project names from worktree paths without regressing the live fixture shape', () => {
|
||||
const data = buildPaginatedSessions({
|
||||
sessions: [
|
||||
buildSession({
|
||||
projectPath:
|
||||
'/Users/kaitran/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(<SessionStatsCard data={data} />, { wrapper: AllProviders });
|
||||
|
||||
expect(
|
||||
screen.getByTitle(
|
||||
'/Users/kaitran/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08'
|
||||
)
|
||||
).toHaveTextContent('2026-01-08');
|
||||
});
|
||||
|
||||
describe('Privacy Mode', () => {
|
||||
it('blurs cost information when privacy mode is enabled', () => {
|
||||
// This would require mocking the privacy context
|
||||
// For now, just ensure the component renders with privacy mode
|
||||
const testData: PaginatedSessions = {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: '1',
|
||||
projectPath: '/home/user/project',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 2000,
|
||||
cost: 0.05,
|
||||
lastActivity: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
const { container } = render(<SessionStatsCard data={testData} />, { wrapper: AllProviders });
|
||||
|
||||
// Component should render without errors
|
||||
expect(container).toBeInTheDocument();
|
||||
it('formats visible token counts from input plus output only', () => {
|
||||
const data = buildPaginatedSessions({
|
||||
sessions: [
|
||||
buildSession({
|
||||
inputTokens: 1_500_000,
|
||||
outputTokens: 500_000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(<SessionStatsCard data={data} />, { wrapper: AllProviders });
|
||||
|
||||
expect(screen.getByText('2.0M toks')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
|
||||
import type { UsageSummary } from '@/hooks/use-usage';
|
||||
import { AllProviders } from '@tests/setup/test-utils';
|
||||
|
||||
function buildSummary(overrides: Partial<UsageSummary> = {}): UsageSummary {
|
||||
return {
|
||||
totalTokens: 3_000,
|
||||
totalInputTokens: 2_000,
|
||||
totalOutputTokens: 1_000,
|
||||
totalCacheTokens: 500,
|
||||
totalCacheCreationTokens: 300,
|
||||
totalCacheReadTokens: 200,
|
||||
totalCost: 2,
|
||||
tokenBreakdown: {
|
||||
input: { tokens: 2_000, cost: 0.8 },
|
||||
output: { tokens: 1_000, cost: 0.7 },
|
||||
cacheCreation: { tokens: 300, cost: 0.3 },
|
||||
cacheRead: { tokens: 200, cost: 0.2 },
|
||||
},
|
||||
totalDays: 2,
|
||||
averageTokensPerDay: 1_500,
|
||||
averageCostPerDay: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('UsageSummaryCards', () => {
|
||||
it('labels total tokens as I/O-only when cache is excluded from the backend total', () => {
|
||||
render(<UsageSummaryCards data={buildSummary()} />, { wrapper: AllProviders });
|
||||
|
||||
expect(screen.getByText('Total Tokens (I/O)')).toBeInTheDocument();
|
||||
expect(screen.getByText('2.0K in / 1.0K out')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cache Tokens')).toBeInTheDocument();
|
||||
expect(screen.getByText('$0.50 (25% of cost)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to an inclusive total label when total tokens already include cache', () => {
|
||||
render(
|
||||
<UsageSummaryCards
|
||||
data={buildSummary({
|
||||
totalTokens: 3_500,
|
||||
})}
|
||||
/>,
|
||||
{ wrapper: AllProviders }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Total Tokens')).toBeInTheDocument();
|
||||
expect(screen.getByText('2.0K in / 1.0K out / 500 cache')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Total Tokens (I/O)')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
|
||||
import { AllProviders } from '@tests/setup/test-utils';
|
||||
|
||||
vi.mock('recharts', () => ({
|
||||
ResponsiveContainer: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AreaChart: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Area: () => null,
|
||||
CartesianGrid: () => null,
|
||||
XAxis: () => null,
|
||||
YAxis: () => null,
|
||||
Tooltip: ({ content }: { content: (args: unknown) => ReactNode }) => (
|
||||
<div data-testid="chart-tooltip">
|
||||
{content({
|
||||
active: true,
|
||||
label: '14:00',
|
||||
payload: [
|
||||
{
|
||||
name: 'Tokens',
|
||||
value: 1_200,
|
||||
color: '#0080FF',
|
||||
payload: { requests: 4 },
|
||||
},
|
||||
{
|
||||
name: 'Cost',
|
||||
value: 1.5,
|
||||
color: '#00C49F',
|
||||
payload: { requests: 4 },
|
||||
},
|
||||
],
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
defs: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
linearGradient: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
stop: () => null,
|
||||
}));
|
||||
|
||||
describe('UsageTrendChart', () => {
|
||||
it('does not overclaim hourly request semantics in the tooltip', () => {
|
||||
render(
|
||||
<UsageTrendChart
|
||||
granularity="hourly"
|
||||
data={[
|
||||
{
|
||||
hour: '2026-04-26 14:00',
|
||||
tokens: 1_200,
|
||||
inputTokens: 700,
|
||||
outputTokens: 500,
|
||||
cacheTokens: 200,
|
||||
cost: 1.5,
|
||||
modelsUsed: 2,
|
||||
requests: 4,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{ wrapper: AllProviders }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Tokens: 1.2K')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cost: $1.5')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Requests:/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useAnalyticsPage } from '@/pages/analytics/hooks';
|
||||
import { AllProviders } from '@tests/setup/test-utils';
|
||||
|
||||
const usageMocks = vi.hoisted(() => ({
|
||||
useUsageSummary: vi.fn(() => ({ data: undefined, isLoading: false })),
|
||||
useUsageTrends: vi.fn(() => ({ data: undefined, isLoading: false })),
|
||||
useHourlyUsage: vi.fn(() => ({ data: undefined, isLoading: false })),
|
||||
useModelUsage: vi.fn(() => ({ data: undefined, isLoading: false })),
|
||||
useRefreshUsage: vi.fn(() => vi.fn()),
|
||||
useUsageStatus: vi.fn(() => ({ data: { lastFetch: null } })),
|
||||
useSessions: vi.fn(() => ({ data: undefined, isLoading: false })),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-usage', () => usageMocks);
|
||||
|
||||
describe('useAnalyticsPage', () => {
|
||||
it('requests a broader recent session sample instead of the old 3-session slice', () => {
|
||||
renderHook(() => useAnalyticsPage(), {
|
||||
wrapper: AllProviders,
|
||||
});
|
||||
|
||||
expect(usageMocks.useSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
limit: 50,
|
||||
})
|
||||
);
|
||||
expect(usageMocks.useSessions).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
limit: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { usageApi } from '@/hooks/use-usage';
|
||||
|
||||
function jsonResponse(body: unknown) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('analytics usage API contract', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({ data: {} }))) as typeof fetch
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes only the supported analytics query params', async () => {
|
||||
const startDate = new Date(2026, 3, 1);
|
||||
const endDate = new Date(2026, 3, 30);
|
||||
|
||||
await usageApi.summary({ startDate, endDate });
|
||||
await usageApi.trends({ startDate, endDate });
|
||||
await usageApi.models({ startDate, endDate });
|
||||
await usageApi.sessions({ startDate, endDate, limit: 50, offset: 10 });
|
||||
await usageApi.insights({ startDate, endDate });
|
||||
await usageApi.monthly({ startDate, endDate });
|
||||
|
||||
const urls = vi.mocked(global.fetch).mock.calls.map(([url]) => String(url));
|
||||
|
||||
expect(urls).toContain('/api/usage/summary?since=20260401&until=20260430');
|
||||
expect(urls).toContain('/api/usage/daily?since=20260401&until=20260430');
|
||||
expect(urls).toContain('/api/usage/models?since=20260401&until=20260430');
|
||||
expect(urls).toContain('/api/usage/sessions?since=20260401&until=20260430&limit=50&offset=10');
|
||||
expect(urls).toContain('/api/usage/insights?since=20260401&until=20260430');
|
||||
expect(urls).toContain('/api/usage/monthly?since=20260401&until=20260430');
|
||||
expect(urls.every((url) => !url.includes('months='))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user