diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 1b6b8108..cdff9ac7 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -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 diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts index b6d2f74b..c99dc5dd 100644 --- a/src/web-server/jsonl-parser.ts +++ b/src/web-server/jsonl-parser.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' diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 93da148b..2c1e6766 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -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; } } diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index 8e9fa3c0..97913930 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -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>(); @@ -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(); diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts index f3249089..db0ad837 100644 --- a/src/web-server/usage/cliproxy-usage-syncer.ts +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -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 | 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(); + + 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 { + 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 { @@ -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 = () => syncCliproxyUsage()): void { if (syncIntervalId !== null) { return; @@ -162,7 +316,6 @@ export function startCliproxySync(syncNow: () => Promise = () => 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 = () => syncClipr }, SYNC_INTERVAL_MS); } -/** - * Stop periodic CLIProxy usage sync. - */ export function stopCliproxySync(): void { if (syncIntervalId !== null) { clearInterval(syncIntervalId); diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts index aff50311..2ae608e5 100644 --- a/src/web-server/usage/cliproxy-usage-transformer.ts +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -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(); + 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(); + 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( - 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>(); + const requestCounts = new Map(); - 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; - 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( 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) + ), + }; +} diff --git a/src/web-server/usage/codex-native-usage-collector.ts b/src/web-server/usage/codex-native-usage-collector.ts new file mode 100644 index 00000000..9d4c8f4b --- /dev/null +++ b/src/web-server/usage/codex-native-usage-collector.ts @@ -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 { + 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 { + 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 { + 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; + 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).total_token_usage + : null + ); + const lastSnapshot = normalizeTokenSnapshot( + parsed.payload.info && isObject(parsed.payload.info) + ? (parsed.payload.info as Record).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 { + 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; +} diff --git a/src/web-server/usage/data-aggregator.ts b/src/web-server/usage/data-aggregator.ts index b0f4fb5f..ba664576 100644 --- a/src/web-server/usage/data-aggregator.ts +++ b/src/web-server/usage/data-aggregator.ts @@ -244,6 +244,7 @@ export function aggregateHourlyUsage( totalCost, modelsUsed: Array.from(modelMap.keys()), modelBreakdowns, + requestCount: hourEntries.length, }); } diff --git a/src/web-server/usage/droid-native-usage-collector.ts b/src/web-server/usage/droid-native-usage-collector.ts new file mode 100644 index 00000000..2814742d --- /dev/null +++ b/src/web-server/usage/droid-native-usage-collector.ts @@ -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 { + 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): Array> { + const raw = settings.customModels ?? settings.custom_models; + if (Array.isArray(raw)) { + return raw.filter((entry): entry is Record => isObject(entry)); + } + if (isObject(raw)) { + return Object.values(raw).filter((entry): entry is Record => isObject(entry)); + } + return []; +} + +function buildCustomModelMap(settingsPath: string): Map { + const selectors = new Map(); + 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 { + const metadata = new Map(); + 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 { + 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> = []; + 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; +} diff --git a/src/web-server/usage/handlers.ts b/src/web-server/usage/handlers.ts index e9e34668..2f3bb054 100644 --- a/src/web-server/usage/handlers.ts +++ b/src/web-server/usage/handlers.ts @@ -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; + 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(), + 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 } 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); diff --git a/src/web-server/usage/sqlite-cli.ts b/src/web-server/usage/sqlite-cli.ts new file mode 100644 index 00000000..cde5afcb --- /dev/null +++ b/src/web-server/usage/sqlite-cli.ts @@ -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; + +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 { + 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}`); + } +} diff --git a/src/web-server/usage/types.ts b/src/web-server/usage/types.ts index 1866e783..9348479d 100644 --- a/src/web-server/usage/types.ts +++ b/src/web-server/usage/types.ts @@ -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) */ diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index f4f9e402..6980eba1 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -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); diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 2770439f..d74bdd56 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -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', () => { diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index 699bcd25..54b192ce 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -20,6 +20,48 @@ function fetchRawResponse(): Promise { 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((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); + }); + }); }); diff --git a/tests/unit/web-server/cliproxy-usage-transformer.test.ts b/tests/unit/web-server/cliproxy-usage-transformer.test.ts index ac2e6eec..d533865b 100644 --- a/tests/unit/web-server/cliproxy-usage-transformer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-transformer.test.ts @@ -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); }); }); diff --git a/tests/unit/web-server/codex-native-usage-collector.test.ts b/tests/unit/web-server/codex-native-usage-collector.test.ts new file mode 100644 index 00000000..aa32db7d --- /dev/null +++ b/tests/unit/web-server/codex-native-usage-collector.test.ts @@ -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); + }); +}); diff --git a/tests/unit/web-server/droid-native-usage-collector.test.ts b/tests/unit/web-server/droid-native-usage-collector.test.ts new file mode 100644 index 00000000..21f31680 --- /dev/null +++ b/tests/unit/web-server/droid-native-usage-collector.test.ts @@ -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') + ); + }); +}); diff --git a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts index 3b5cd6cf..55fb2739 100644 --- a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts +++ b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts @@ -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); + }); }); diff --git a/tests/unit/web-server/usage-aggregator-native-runtime-integration.test.ts b/tests/unit/web-server/usage-aggregator-native-runtime-integration.test.ts new file mode 100644 index 00000000..d4885e8f --- /dev/null +++ b/tests/unit/web-server/usage-aggregator-native-runtime-integration.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/web-server/usage-handlers-semantics.test.ts b/tests/unit/web-server/usage-handlers-semantics.test.ts new file mode 100644 index 00000000..9b9fe47d --- /dev/null +++ b/tests/unit/web-server/usage-handlers-semantics.test.ts @@ -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"', + }); + }); +}); diff --git a/ui/src/components/analytics/model-details-content.tsx b/ui/src/components/analytics/model-details-content.tsx index b360b3da..ede1773b 100644 --- a/ui/src/components/analytics/model-details-content.tsx +++ b/ui/src/components/analytics/model-details-content.tsx @@ -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 (
@@ -49,7 +51,7 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) { {formatCompactNumber(model.tokens)}

- {t('analyticsCards.totalTokens')} + {totalTokensLabel}

diff --git a/ui/src/components/analytics/session-stats-card.tsx b/ui/src/components/analytics/session-stats-card.tsx index dbe6ec21..d1b1d0b1 100644 --- a/ui/src/components/analytics/session-stats-card.tsx +++ b/ui/src/components/analytics/session-stats-card.tsx @@ -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
- {stats.totalSessions} + {stats.displayedSessions}

- {/* TODO i18n: missing key for "Total Sessions" */} - Total Sessions + {stats.hasPartialSample ? 'Sampled Sessions' : 'Total Sessions'}

+ {stats.hasPartialSample && ( +

+ {stats.totalSessions} total +

+ )}
{/* Avg Cost */} @@ -122,8 +118,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar

- {/* TODO i18n: missing key for "Avg Cost/Session" */} - Avg Cost/Session + {stats.hasPartialSample ? 'Recent Avg Cost' : 'Avg Cost/Session'}

@@ -159,7 +154,8 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
${session.cost.toFixed(2)}
- {formatCompact(session.inputTokens + session.outputTokens)} toks + {formatCompact(session.tokens ?? session.inputTokens + session.outputTokens)}{' '} + toks
diff --git a/ui/src/components/analytics/usage-summary-cards.tsx b/ui/src/components/analytics/usage-summary-cards.tsx index 0ca03eee..98be74e0 100644 --- a/ui/src/components/analytics/usage-summary-cards.tsx +++ b/ui/src/components/analytics/usage-summary-cards.tsx @@ -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'), diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx index e20d0303..5b031a2d 100644 --- a/ui/src/components/analytics/usage-trend-chart.tsx +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -146,7 +146,6 @@ export function UsageTrendChart({ content={({ active, payload, label }) => { if (!active || !payload || !payload.length) return null; - const tooltipData = payload[0].payload; return (

{label}

@@ -162,16 +161,6 @@ export function UsageTrendChart({ : `$${entry.value}`}

))} - {'requests' in tooltipData && ( -

- Requests: {tooltipData.requests} -

- )}
); }} diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts index 709da6b6..9c929fdd 100644 --- a/ui/src/hooks/use-usage.ts +++ b/ui/src/hooks/use-usage.ts @@ -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(`/usage/summary?${params}`); + return request(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(`/usage/daily?${params}`); + return request(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(`/usage/hourly?${params}`); + return request(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(`/usage/models?${params}`); + return request(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(`/usage/sessions?${params}`); + return request(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(`/usage/monthly?${params}`); + if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); + if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + return request(buildUsageUrl('/usage/monthly', params)); }, /** Clear server-side usage cache and force fresh data fetch */ refresh: async (): Promise => { @@ -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(`/usage/insights?${params}`); + return request(buildUsageUrl('/usage/insights', params)); }, }; diff --git a/ui/src/pages/analytics/hooks.ts b/ui/src/pages/analytics/hooks.ts index f4fc73ef..359e769b 100644 --- a/ui/src/pages/analytics/hooks.ts +++ b/ui/src/pages/analytics/hooks.ts @@ -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({ @@ -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 diff --git a/ui/tests/setup/vitest-setup.ts b/ui/tests/setup/vitest-setup.ts index 0fa24398..c3815df1 100644 --- a/ui/tests/setup/vitest-setup.ts +++ b/ui/tests/setup/vitest-setup.ts @@ -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 diff --git a/ui/tests/unit/components/analytics/session-stats-card.test.tsx b/ui/tests/unit/components/analytics/session-stats-card.test.tsx index e09776c1..04e5eae8 100644 --- a/ui/tests/unit/components/analytics/session-stats-card.test.tsx +++ b/ui/tests/unit/components/analytics/session-stats-card.test.tsx @@ -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 { + 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 { + 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(, { - wrapper: AllProviders, - }); - - // Should have skeleton loading elements - expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument(); + it('renders a loading skeleton', () => { + const { container } = render(, { + wrapper: AllProviders, }); - it('shows empty state when no data available', () => { - render(, { 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(, { 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(, { + 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(, { wrapper: AllProviders }); + render(, { wrapper: AllProviders }); - expect(screen.getByText('Session Stats')).toBeInTheDocument(); - }); - - it('shows total sessions count', () => { - render(, { wrapper: AllProviders }); - - expect(screen.getByText('3')).toBeInTheDocument(); - expect(screen.getByText('Total Sessions')).toBeInTheDocument(); - }); - - it('calculates and displays average cost per session', () => { - render(, { 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(, { 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(, { 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(, { wrapper: AllProviders }); - render(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { wrapper: AllProviders }); + + expect(screen.getByText('2.0M toks')).toBeInTheDocument(); }); }); diff --git a/ui/tests/unit/components/analytics/usage-summary-cards.test.tsx b/ui/tests/unit/components/analytics/usage-summary-cards.test.tsx new file mode 100644 index 00000000..c9bcfb5e --- /dev/null +++ b/ui/tests/unit/components/analytics/usage-summary-cards.test.tsx @@ -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 { + 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(, { 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( + , + { 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(); + }); +}); diff --git a/ui/tests/unit/components/analytics/usage-trend-chart.test.tsx b/ui/tests/unit/components/analytics/usage-trend-chart.test.tsx new file mode 100644 index 00000000..29da8057 --- /dev/null +++ b/ui/tests/unit/components/analytics/usage-trend-chart.test.tsx @@ -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 }) =>
{children}
, + AreaChart: ({ children }: { children: ReactNode }) =>
{children}
, + Area: () => null, + CartesianGrid: () => null, + XAxis: () => null, + YAxis: () => null, + Tooltip: ({ content }: { content: (args: unknown) => ReactNode }) => ( +
+ {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 }, + }, + ], + })} +
+ ), + 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( + , + { wrapper: AllProviders } + ); + + expect(screen.getByText('Tokens: 1.2K')).toBeInTheDocument(); + expect(screen.getByText('Cost: $1.5')).toBeInTheDocument(); + expect(screen.queryByText(/Requests:/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/tests/unit/ui/pages/analytics/hooks.test.tsx b/ui/tests/unit/ui/pages/analytics/hooks.test.tsx new file mode 100644 index 00000000..eb4f4455 --- /dev/null +++ b/ui/tests/unit/ui/pages/analytics/hooks.test.tsx @@ -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, + }) + ); + }); +}); diff --git a/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts b/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts new file mode 100644 index 00000000..645151c2 --- /dev/null +++ b/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts @@ -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); + }); +});