From 49b4065186bc223af1b589395808e962b3cf6bb3 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 9 Dec 2025 23:41:47 -0500 Subject: [PATCH] feat(usage): add internal data aggregation and cost tracking --- bun.lock | 3 - package.json | 1 - src/types/external.d.ts | 75 ---- src/web-server/data-aggregator.ts | 405 +++++++++++++++++ src/web-server/jsonl-parser.ts | 251 +++++++++++ src/web-server/model-pricing.ts | 676 +++++++++++++++++++++++++++++ src/web-server/usage-disk-cache.ts | 5 +- src/web-server/usage-routes.ts | 51 +-- src/web-server/usage-types.ts | 68 +++ tests/unit/data-aggregator.test.ts | 280 ++++++++++++ tests/unit/jsonl-parser.test.ts | 411 ++++++++++++++++++ tests/unit/model-pricing.test.ts | 141 ++++++ 12 files changed, 2250 insertions(+), 117 deletions(-) create mode 100644 src/web-server/data-aggregator.ts create mode 100644 src/web-server/jsonl-parser.ts create mode 100644 src/web-server/model-pricing.ts create mode 100644 src/web-server/usage-types.ts create mode 100644 tests/unit/data-aggregator.test.ts create mode 100644 tests/unit/jsonl-parser.test.ts create mode 100644 tests/unit/model-pricing.test.ts diff --git a/bun.lock b/bun.lock index f541b922..3e11076c 100644 --- a/bun.lock +++ b/bun.lock @@ -4,7 +4,6 @@ "": { "name": "@kaitranntt/ccs", "dependencies": { - "better-ccusage": "^1.2.6", "boxen": "^8.0.1", "chalk": "^5.6.2", "chokidar": "^5.0.0", @@ -446,8 +445,6 @@ "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], - "better-ccusage": ["better-ccusage@1.2.6", "", { "bin": { "better-ccusage": "dist/index.js" } }, "sha512-IZCYBX1kF0IfJ6ho9JMwLKn2o820WRiVGZ+2tVS2olODU5J7Np5mJ1j1i5HtazZPNo2S9wKU9C9iysc0f8Cjqw=="], - "body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], diff --git a/package.json b/package.json index 84bb2a81..d063f698 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,6 @@ "postinstall": "node scripts/postinstall.js" }, "dependencies": { - "better-ccusage": "^1.2.6", "boxen": "^8.0.1", "chalk": "^5.6.2", "chokidar": "^5.0.0", diff --git a/src/types/external.d.ts b/src/types/external.d.ts index c1e87455..ba86374a 100644 --- a/src/types/external.d.ts +++ b/src/types/external.d.ts @@ -2,81 +2,6 @@ * Type shims for incomplete external dependencies */ -// better-ccusage types (package has JS exports but incomplete TS subpath support) -declare module 'better-ccusage/data-loader' { - export interface ModelBreakdown { - modelName: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - cost: number; - } - - export interface DailyUsage { - date: string; - source: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - cost: number; - totalCost: number; - modelsUsed: string[]; - modelBreakdowns: ModelBreakdown[]; - } - - export interface MonthlyUsage { - month: string; - source: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - totalCost: number; - modelsUsed: string[]; - modelBreakdowns: ModelBreakdown[]; - } - - export interface SessionUsage { - sessionId: string; - projectPath: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - cost: number; - totalCost: number; - lastActivity: string; - versions: string[]; - modelsUsed: string[]; - modelBreakdowns: ModelBreakdown[]; - source: string; - } - - export interface DataLoaderOptions { - mode?: 'calculate' | 'cached'; - claudePaths?: string[]; - } - - export function loadDailyUsageData(options?: DataLoaderOptions): Promise; - export function loadMonthlyUsageData(options?: DataLoaderOptions): Promise; - export function loadSessionData(options?: DataLoaderOptions): Promise; -} - -declare module 'better-ccusage/calculate-cost' { - export interface Totals { - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - costUSD: number; - } - - export function calculateTotals(entries: unknown[]): Totals; - export function getTotalTokens(entries: unknown[]): number; -} - declare module 'cli-table3' { interface TableOptions { head?: string[]; diff --git a/src/web-server/data-aggregator.ts b/src/web-server/data-aggregator.ts new file mode 100644 index 00000000..6d558615 --- /dev/null +++ b/src/web-server/data-aggregator.ts @@ -0,0 +1,405 @@ +/** + * Data Aggregator for Claude Code Usage Analytics + * + * Aggregates raw JSONL entries into daily, monthly, and session summaries. + * Uses model-pricing.ts for cost calculations. + */ + +import { type RawUsageEntry } from './jsonl-parser'; +import { calculateCost } from './model-pricing'; +import { + type ModelBreakdown, + type DailyUsage, + type MonthlyUsage, + type SessionUsage, +} from './usage-types'; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** Extract YYYY-MM-DD from ISO timestamp */ +function extractDate(timestamp: string): string { + return timestamp.slice(0, 10); +} + +/** Extract YYYY-MM from ISO timestamp */ +function extractMonth(timestamp: string): string { + return timestamp.slice(0, 7); +} + +/** Create model breakdown from accumulated data */ +function createModelBreakdown( + modelName: string, + inputTokens: number, + outputTokens: number, + cacheCreationTokens: number, + cacheReadTokens: number +): ModelBreakdown { + const cost = calculateCost( + { inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens }, + modelName + ); + + return { + modelName, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + cost, + }; +} + +/** Accumulator for per-model token counts */ +interface ModelAccumulator { + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; +} + +// ============================================================================ +// DAILY AGGREGATION +// ============================================================================ + +/** + * Aggregate raw entries into daily usage summaries + * Groups by date (YYYY-MM-DD), calculates costs per model + */ +export function aggregateDailyUsage( + entries: RawUsageEntry[], + source = 'custom-parser' +): DailyUsage[] { + // Group entries by date + const byDate = new Map(); + + for (const entry of entries) { + const date = extractDate(entry.timestamp); + const existing = byDate.get(date) || []; + existing.push(entry); + byDate.set(date, existing); + } + + // Build daily summaries + const dailyUsage: DailyUsage[] = []; + + for (const [date, dateEntries] of byDate) { + // Aggregate by model + const modelMap = new Map(); + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + + for (const entry of dateEntries) { + const model = entry.model; + const acc = modelMap.get(model) || { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + + acc.inputTokens += entry.inputTokens; + acc.outputTokens += entry.outputTokens; + acc.cacheCreationTokens += entry.cacheCreationTokens; + acc.cacheReadTokens += entry.cacheReadTokens; + modelMap.set(model, acc); + + totalInput += entry.inputTokens; + totalOutput += entry.outputTokens; + totalCacheCreation += entry.cacheCreationTokens; + totalCacheRead += entry.cacheReadTokens; + } + + // Build model breakdowns + const modelBreakdowns: ModelBreakdown[] = []; + let totalCost = 0; + + for (const [modelName, acc] of modelMap) { + const breakdown = createModelBreakdown( + modelName, + acc.inputTokens, + acc.outputTokens, + acc.cacheCreationTokens, + acc.cacheReadTokens + ); + modelBreakdowns.push(breakdown); + totalCost += breakdown.cost; + } + + // Sort breakdowns by cost descending + modelBreakdowns.sort((a, b) => b.cost - a.cost); + + dailyUsage.push({ + date, + source, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + cost: totalCost, + totalCost, + modelsUsed: Array.from(modelMap.keys()), + modelBreakdowns, + }); + } + + // Sort by date descending (most recent first) + dailyUsage.sort((a, b) => b.date.localeCompare(a.date)); + + return dailyUsage; +} + +// ============================================================================ +// MONTHLY AGGREGATION +// ============================================================================ + +/** + * Aggregate raw entries into monthly usage summaries + * Groups by month (YYYY-MM), calculates costs per model + */ +export function aggregateMonthlyUsage( + entries: RawUsageEntry[], + source = 'custom-parser' +): MonthlyUsage[] { + // Group entries by month + const byMonth = new Map(); + + for (const entry of entries) { + const month = extractMonth(entry.timestamp); + const existing = byMonth.get(month) || []; + existing.push(entry); + byMonth.set(month, existing); + } + + // Build monthly summaries + const monthlyUsage: MonthlyUsage[] = []; + + for (const [month, monthEntries] of byMonth) { + // Aggregate by model + const modelMap = new Map(); + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + + for (const entry of monthEntries) { + const model = entry.model; + const acc = modelMap.get(model) || { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + + acc.inputTokens += entry.inputTokens; + acc.outputTokens += entry.outputTokens; + acc.cacheCreationTokens += entry.cacheCreationTokens; + acc.cacheReadTokens += entry.cacheReadTokens; + modelMap.set(model, acc); + + totalInput += entry.inputTokens; + totalOutput += entry.outputTokens; + totalCacheCreation += entry.cacheCreationTokens; + totalCacheRead += entry.cacheReadTokens; + } + + // Build model breakdowns + const modelBreakdowns: ModelBreakdown[] = []; + let totalCost = 0; + + for (const [modelName, acc] of modelMap) { + const breakdown = createModelBreakdown( + modelName, + acc.inputTokens, + acc.outputTokens, + acc.cacheCreationTokens, + acc.cacheReadTokens + ); + modelBreakdowns.push(breakdown); + totalCost += breakdown.cost; + } + + // Sort breakdowns by cost descending + modelBreakdowns.sort((a, b) => b.cost - a.cost); + + monthlyUsage.push({ + month, + source, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + totalCost, + modelsUsed: Array.from(modelMap.keys()), + modelBreakdowns, + }); + } + + // Sort by month descending (most recent first) + monthlyUsage.sort((a, b) => b.month.localeCompare(a.month)); + + return monthlyUsage; +} + +// ============================================================================ +// SESSION AGGREGATION +// ============================================================================ + +/** + * Aggregate raw entries into session usage summaries + * Groups by sessionId, tracks last activity and versions + */ +export function aggregateSessionUsage( + entries: RawUsageEntry[], + source = 'custom-parser' +): SessionUsage[] { + // Group entries by sessionId + const bySession = new Map(); + + for (const entry of entries) { + if (!entry.sessionId) continue; + const existing = bySession.get(entry.sessionId) || []; + existing.push(entry); + bySession.set(entry.sessionId, existing); + } + + // Build session summaries + const sessionUsage: SessionUsage[] = []; + + for (const [sessionId, sessionEntries] of bySession) { + // Aggregate by model + const modelMap = new Map(); + const versions = new Set(); + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + let lastActivity = ''; + let projectPath = ''; + + for (const entry of sessionEntries) { + const model = entry.model; + const acc = modelMap.get(model) || { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + + acc.inputTokens += entry.inputTokens; + acc.outputTokens += entry.outputTokens; + acc.cacheCreationTokens += entry.cacheCreationTokens; + acc.cacheReadTokens += entry.cacheReadTokens; + modelMap.set(model, acc); + + totalInput += entry.inputTokens; + totalOutput += entry.outputTokens; + totalCacheCreation += entry.cacheCreationTokens; + totalCacheRead += entry.cacheReadTokens; + + // Track latest timestamp + if (entry.timestamp > lastActivity) { + lastActivity = entry.timestamp; + } + + // Track versions + if (entry.version) { + versions.add(entry.version); + } + + // Use project path from entry + if (entry.projectPath) { + projectPath = entry.projectPath; + } + } + + // Build model breakdowns + const modelBreakdowns: ModelBreakdown[] = []; + let totalCost = 0; + + for (const [modelName, acc] of modelMap) { + const breakdown = createModelBreakdown( + modelName, + acc.inputTokens, + acc.outputTokens, + acc.cacheCreationTokens, + acc.cacheReadTokens + ); + modelBreakdowns.push(breakdown); + totalCost += breakdown.cost; + } + + // Sort breakdowns by cost descending + modelBreakdowns.sort((a, b) => b.cost - a.cost); + + sessionUsage.push({ + sessionId, + projectPath, + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + cost: totalCost, + totalCost, + lastActivity, + versions: Array.from(versions), + modelsUsed: Array.from(modelMap.keys()), + modelBreakdowns, + source, + }); + } + + // Sort by last activity descending (most recent first) + sessionUsage.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)); + + return sessionUsage; +} + +// ============================================================================ +// MAIN DATA LOADER (drop-in replacement for better-ccusage) +// ============================================================================ + +import { scanProjectsDirectory, type ParserOptions } from './jsonl-parser'; + +/** + * Load daily usage data (replaces better-ccusage loadDailyUsageData) + */ +export async function loadDailyUsageData(options?: ParserOptions): Promise { + const entries = await scanProjectsDirectory(options); + return aggregateDailyUsage(entries); +} + +/** + * Load monthly usage data (replaces better-ccusage loadMonthlyUsageData) + */ +export async function loadMonthlyUsageData(options?: ParserOptions): Promise { + const entries = await scanProjectsDirectory(options); + return aggregateMonthlyUsage(entries); +} + +/** + * Load session data (replaces better-ccusage loadSessionData) + */ +export async function loadSessionData(options?: ParserOptions): Promise { + const entries = await scanProjectsDirectory(options); + return aggregateSessionUsage(entries); +} + +/** + * Load all usage data in a single pass (more efficient) + */ +export async function loadAllUsageData(options?: ParserOptions): Promise<{ + daily: DailyUsage[]; + monthly: MonthlyUsage[]; + session: SessionUsage[]; +}> { + const entries = await scanProjectsDirectory(options); + return { + daily: aggregateDailyUsage(entries), + monthly: aggregateMonthlyUsage(entries), + session: aggregateSessionUsage(entries), + }; +} diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts new file mode 100644 index 00000000..4bb21aee --- /dev/null +++ b/src/web-server/jsonl-parser.ts @@ -0,0 +1,251 @@ +/** + * JSONL Parser for Claude Code Usage Analytics + * + * High-performance streaming parser for ~/.claude/projects/ JSONL files. + * Replaces better-ccusage dependency with optimized custom implementation. + * + * Key features: + * - Streaming line-by-line parsing (memory efficient) + * - Only parses "assistant" entries with usage data + * - Parallel file processing with configurable concurrency + * - Graceful error handling for malformed entries + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as readline from 'readline'; +import * as os from 'os'; + +// ============================================================================ +// TYPE DEFINITIONS +// ============================================================================ + +/** Raw usage data from JSONL entry */ +export interface RawUsageEntry { + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + model: string; + sessionId: string; + timestamp: string; + projectPath: string; + version?: string; +} + +/** Internal structure matching JSONL assistant entries */ +interface JsonlAssistantEntry { + type: 'assistant'; + sessionId: string; + timestamp: string; + version?: string; + cwd?: string; + message: { + model: string; + usage: { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + }; + }; +} + +/** Parser options */ +export interface ParserOptions { + /** Max files to parse concurrently (default: 10) */ + concurrency?: number; + /** Skip files older than this date */ + minDate?: Date; + /** Custom projects directory (default: ~/.claude/projects) */ + projectsDir?: string; +} + +// ============================================================================ +// CORE PARSING FUNCTIONS +// ============================================================================ + +/** + * Parse a single JSONL line into RawUsageEntry if valid + * Returns null for non-assistant entries or entries without usage data + */ +export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null { + if (!line.trim()) return null; + + try { + const entry = JSON.parse(line); + + // Only process assistant entries with usage data + if (entry.type !== 'assistant') return null; + if (!entry.message?.usage) return null; + if (!entry.message?.model) return null; + + const usage = entry.message.usage; + const assistant = entry as JsonlAssistantEntry; + + return { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreationTokens: usage.cache_creation_input_tokens || 0, + cacheReadTokens: usage.cache_read_input_tokens || 0, + model: assistant.message.model, + sessionId: assistant.sessionId || '', + timestamp: assistant.timestamp || new Date().toISOString(), + projectPath, + version: assistant.version, + }; + } catch { + // Malformed JSON - skip silently + return null; + } +} + +/** + * Stream-parse a single JSONL file + * Yields RawUsageEntry for each valid assistant entry + */ +export async function parseJsonlFile( + filePath: string, + projectPath: string +): Promise { + const entries: RawUsageEntry[] = []; + + if (!fs.existsSync(filePath)) { + return entries; + } + + const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + for await (const line of rl) { + const entry = parseUsageEntry(line, projectPath); + if (entry) { + entries.push(entry); + } + } + + return entries; +} + +/** + * Parse all JSONL files in a single project directory + */ +export async function parseProjectDirectory(projectDir: string): Promise { + const entries: RawUsageEntry[] = []; + + if (!fs.existsSync(projectDir)) { + return entries; + } + + // Get project path from directory name (e.g., "-home-kai-project" -> "/home/kai/project") + const projectPath = path.basename(projectDir).replace(/-/g, '/'); + + try { + const files = fs.readdirSync(projectDir); + const jsonlFiles = files.filter((f) => f.endsWith('.jsonl')); + + // Parse files sequentially within a project to avoid too many open handles + for (const file of jsonlFiles) { + const filePath = path.join(projectDir, file); + const fileEntries = await parseJsonlFile(filePath, projectPath); + entries.push(...fileEntries); + } + } catch { + // Directory access error - skip silently + } + + return entries; +} + +// ============================================================================ +// DIRECTORY SCANNING +// ============================================================================ + +/** + * Get default Claude projects directory + */ +export function getDefaultProjectsDir(): string { + const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + return path.join(configDir, 'projects'); +} + +/** + * Find all project directories under ~/.claude/projects/ + */ +export function findProjectDirectories(projectsDir?: string): string[] { + const dir = projectsDir || getDefaultProjectsDir(); + + if (!fs.existsSync(dir)) { + return []; + } + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(dir, entry.name)); + } catch { + return []; + } +} + +/** + * Scan all projects and parse all JSONL files + * Main entry point for usage data extraction + * + * @param options - Parser configuration + * @returns All parsed usage entries from all projects + */ +export async function scanProjectsDirectory(options: ParserOptions = {}): Promise { + const { concurrency = 10, projectsDir } = options; + const allEntries: RawUsageEntry[] = []; + + const projectDirs = findProjectDirectories(projectsDir); + + if (projectDirs.length === 0) { + return allEntries; + } + + // Process projects in batches for controlled concurrency + for (let i = 0; i < projectDirs.length; i += concurrency) { + const batch = projectDirs.slice(i, i + concurrency); + const batchResults = await Promise.all(batch.map((dir) => parseProjectDirectory(dir))); + + for (const entries of batchResults) { + allEntries.push(...entries); + } + } + + // Filter by date if specified + if (options.minDate) { + const minTime = options.minDate.getTime(); + return allEntries.filter((entry) => { + const entryTime = new Date(entry.timestamp).getTime(); + return entryTime >= minTime; + }); + } + + return allEntries; +} + +/** + * Get count of JSONL files across all projects (for progress reporting) + */ +export function countJsonlFiles(projectsDir?: string): number { + const projectDirs = findProjectDirectories(projectsDir); + let count = 0; + + for (const dir of projectDirs) { + try { + const files = fs.readdirSync(dir); + count += files.filter((f) => f.endsWith('.jsonl')).length; + } catch { + // Skip inaccessible directories + } + } + + return count; +} diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts new file mode 100644 index 00000000..77bf900f --- /dev/null +++ b/src/web-server/model-pricing.ts @@ -0,0 +1,676 @@ +/** + * Model Pricing Registry + * + * User-editable pricing configuration for Claude Code usage analytics. + * Update rates below when new models are released or pricing changes. + * + * All rates are in USD per MILLION tokens. + */ + +// ============================================================================ +// TYPE DEFINITIONS +// ============================================================================ + +export interface ModelPricing { + inputPerMillion: number; + outputPerMillion: number; + cacheCreationPerMillion: number; + cacheReadPerMillion: number; +} + +export interface TokenUsage { + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; +} + +// ============================================================================ +// USER-EDITABLE PRICING TABLE +// Update rates below (per million tokens in USD) +// ============================================================================ + +const PRICING_REGISTRY: Record = { + // --------------------------------------------------------------------------- + // Claude Models (Anthropic) - Source: Official Anthropic pricing + // cacheCreation = 5min cache writes, cacheRead = cache hits & refreshes + // --------------------------------------------------------------------------- + // Claude 3 Haiku ($0.25/$1.25) + 'claude-3-haiku-20240307': { + inputPerMillion: 0.25, + outputPerMillion: 1.25, + cacheCreationPerMillion: 0.3, + cacheReadPerMillion: 0.03, + }, + // Claude 3.5 Haiku ($0.80/$4) + 'claude-3-5-haiku-20241022': { + inputPerMillion: 0.8, + outputPerMillion: 4.0, + cacheCreationPerMillion: 1.0, + cacheReadPerMillion: 0.08, + }, + 'claude-3-5-haiku-latest': { + inputPerMillion: 0.8, + outputPerMillion: 4.0, + cacheCreationPerMillion: 1.0, + cacheReadPerMillion: 0.08, + }, + // Claude 4.5 Haiku ($1/$5) + 'claude-haiku-4-5-20251001': { + inputPerMillion: 1.0, + outputPerMillion: 5.0, + cacheCreationPerMillion: 1.25, + cacheReadPerMillion: 0.1, + }, + 'claude-haiku-4-5': { + inputPerMillion: 1.0, + outputPerMillion: 5.0, + cacheCreationPerMillion: 1.25, + cacheReadPerMillion: 0.1, + }, + // Claude 3.5 Sonnet (deprecated, same as Sonnet 3.7: $3/$15) + 'claude-3-5-sonnet-20240620': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + 'claude-3-5-sonnet-20241022': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + 'claude-3-5-sonnet-latest': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + // Claude 3.7 Sonnet (deprecated: $3/$15) + 'claude-3-7-sonnet-20250219': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + 'claude-3-7-sonnet-latest': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + // Claude 3 Opus (deprecated: $15/$75) + 'claude-3-opus-20240229': { + inputPerMillion: 15.0, + outputPerMillion: 75.0, + cacheCreationPerMillion: 18.75, + cacheReadPerMillion: 1.5, + }, + 'claude-3-opus-latest': { + inputPerMillion: 15.0, + outputPerMillion: 75.0, + cacheCreationPerMillion: 18.75, + cacheReadPerMillion: 1.5, + }, + // Claude 4 Sonnet ($3/$15) + 'claude-4-sonnet-20250514': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + 'claude-sonnet-4-20250514': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + 'claude-sonnet-4': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + // Claude 4.5 Sonnet ($3/$15) + 'claude-sonnet-4-5-20250929': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + 'claude-sonnet-4-5': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + 'claude-sonnet-4-5-thinking': { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, + }, + // Claude 4 Opus ($15/$75) + 'claude-4-opus-20250514': { + inputPerMillion: 15.0, + outputPerMillion: 75.0, + cacheCreationPerMillion: 18.75, + cacheReadPerMillion: 1.5, + }, + 'claude-opus-4-20250514': { + inputPerMillion: 15.0, + outputPerMillion: 75.0, + cacheCreationPerMillion: 18.75, + cacheReadPerMillion: 1.5, + }, + 'claude-opus-4': { + inputPerMillion: 15.0, + outputPerMillion: 75.0, + cacheCreationPerMillion: 18.75, + cacheReadPerMillion: 1.5, + }, + // Claude 4.1 Opus ($15/$75) + 'claude-opus-4-1': { + inputPerMillion: 15.0, + outputPerMillion: 75.0, + cacheCreationPerMillion: 18.75, + cacheReadPerMillion: 1.5, + }, + 'claude-opus-4-1-20250805': { + inputPerMillion: 15.0, + outputPerMillion: 75.0, + cacheCreationPerMillion: 18.75, + cacheReadPerMillion: 1.5, + }, + // Claude 4.5 Opus ($5/$25) - NEW PRICING! + 'claude-opus-4-5-20251101': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + }, + 'claude-opus-4-5': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + }, + 'claude-opus-4-5-thinking': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + }, + + // --------------------------------------------------------------------------- + // OpenAI Models - Source: better-ccusage + // --------------------------------------------------------------------------- + // GPT-4o + 'gpt-4o': { + inputPerMillion: 2.5, + outputPerMillion: 10.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 1.25, + }, + 'gpt-4o-2024-08-06': { + inputPerMillion: 2.5, + outputPerMillion: 10.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 1.25, + }, + 'gpt-4o-2024-11-20': { + inputPerMillion: 2.5, + outputPerMillion: 10.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 1.25, + }, + 'gpt-4o-mini': { + inputPerMillion: 0.15, + outputPerMillion: 0.6, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.075, + }, + // GPT-4.1 + 'gpt-4.1': { + inputPerMillion: 2.0, + outputPerMillion: 8.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.5, + }, + 'gpt-4.1-mini': { + inputPerMillion: 0.4, + outputPerMillion: 1.6, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.1, + }, + 'gpt-4.1-nano': { + inputPerMillion: 0.1, + outputPerMillion: 0.4, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.025, + }, + // GPT-4.5 + 'gpt-4.5-preview': { + inputPerMillion: 75.0, + outputPerMillion: 150.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 37.5, + }, + // GPT-3.5 Turbo + 'gpt-3.5-turbo': { + inputPerMillion: 1.5, + outputPerMillion: 2.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'gpt-3.5-turbo-0125': { + inputPerMillion: 0.5, + outputPerMillion: 1.5, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + // o1 Reasoning Models + o1: { + inputPerMillion: 15.0, + outputPerMillion: 60.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 7.5, + }, + 'o1-preview': { + inputPerMillion: 15.0, + outputPerMillion: 60.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 7.5, + }, + 'o1-mini': { + inputPerMillion: 3.0, + outputPerMillion: 12.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 1.5, + }, + 'o3-mini': { + inputPerMillion: 1.1, + outputPerMillion: 4.4, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.55, + }, + // OpenAI GPT-5 / Codex - Source: better-ccusage + 'gpt-5': { + inputPerMillion: 1.25, + outputPerMillion: 10.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.125, + }, + 'gpt-5-chat': { + inputPerMillion: 1.25, + outputPerMillion: 10.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.125, + }, + 'gpt-5-codex': { + inputPerMillion: 1.25, + outputPerMillion: 10.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.125, + }, + 'gpt-5-mini': { + inputPerMillion: 0.25, + outputPerMillion: 2.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.025, + }, + 'gpt-5-nano': { + inputPerMillion: 0.05, + outputPerMillion: 0.4, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.005, + }, + 'codex-mini-latest': { + inputPerMillion: 1.5, + outputPerMillion: 6.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.375, + }, + + // --------------------------------------------------------------------------- + // Google Gemini Models - Source: better-ccusage + // --------------------------------------------------------------------------- + // Gemini 2.5 + 'gemini-2.5-flash': { + inputPerMillion: 0.3, + outputPerMillion: 2.5, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.075, + }, + 'gemini-2.5-flash-lite': { + inputPerMillion: 0.1, + outputPerMillion: 0.4, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.025, + }, + 'gemini-2.5-pro': { + inputPerMillion: 1.25, + outputPerMillion: 10.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.3125, + }, + // Gemini 2.0 + 'gemini-2.0-flash': { + inputPerMillion: 0.1, + outputPerMillion: 0.4, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.025, + }, + 'gemini-2.0-flash-exp': { + inputPerMillion: 0.0, + outputPerMillion: 0.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + // Gemini 1.5 + 'gemini-1.5-flash': { + inputPerMillion: 0.075, + outputPerMillion: 0.3, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'gemini-1.5-flash-8b': { + inputPerMillion: 0.0375, + outputPerMillion: 0.15, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'gemini-1.5-pro': { + inputPerMillion: 3.5, + outputPerMillion: 10.5, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + // Gemini 3 - Official pricing (Nov 2025): ≤200k ctx: $2/$12, >200k ctx: $4/$18 + // Using standard ≤200k pricing as default + 'gemini-3-pro-preview': { + inputPerMillion: 2.0, + outputPerMillion: 12.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'gemini-3-pro': { + inputPerMillion: 2.0, + outputPerMillion: 12.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + // High context variant (>200k tokens) + 'gemini-3-pro-high': { + inputPerMillion: 4.0, + outputPerMillion: 18.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + + // --------------------------------------------------------------------------- + // GLM Models (Zhipu AI / Z.AI) - Source: better-ccusage + // --------------------------------------------------------------------------- + 'glm-4.6': { + inputPerMillion: 0.6, + outputPerMillion: 2.2, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.11, + }, + 'glm-4.6-cc-max': { + inputPerMillion: 0.6, + outputPerMillion: 2.2, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.11, + }, + 'glm-4.5': { + inputPerMillion: 0.6, + outputPerMillion: 2.2, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.11, + }, + 'glm-4.5-air': { + inputPerMillion: 0.2, + outputPerMillion: 1.1, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.03, + }, + + // --------------------------------------------------------------------------- + // Kimi Models (Moonshot AI) - Source: better-ccusage + // --------------------------------------------------------------------------- + 'kimi-for-coding': { + inputPerMillion: 0.15, + outputPerMillion: 0.6, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'kimi-k2-0905-preview': { + inputPerMillion: 0.15, + outputPerMillion: 0.6, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'kimi-k2-turbo-preview': { + inputPerMillion: 0.15, + outputPerMillion: 1.15, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'kimi-k2-thinking': { + inputPerMillion: 0.15, + outputPerMillion: 0.6, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'kimi-k2-thinking-turbo': { + inputPerMillion: 0.15, + outputPerMillion: 1.15, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'kimi-k2-instruct': { + inputPerMillion: 1.0, + outputPerMillion: 3.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'kimi-latest': { + inputPerMillion: 2.0, + outputPerMillion: 5.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.15, + }, + 'kimi-latest-128k': { + inputPerMillion: 2.0, + outputPerMillion: 5.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.15, + }, + 'kimi-latest-32k': { + inputPerMillion: 1.0, + outputPerMillion: 3.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.15, + }, + 'kimi-latest-8k': { + inputPerMillion: 0.2, + outputPerMillion: 2.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.15, + }, + 'kimi-thinking-preview': { + inputPerMillion: 30.0, + outputPerMillion: 30.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'moonshot-v1-8k': { + inputPerMillion: 0.2, + outputPerMillion: 2.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'moonshot-v1-32k': { + inputPerMillion: 1.0, + outputPerMillion: 3.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'moonshot-v1-128k': { + inputPerMillion: 2.0, + outputPerMillion: 5.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'moonshot-v1-auto': { + inputPerMillion: 2.0, + outputPerMillion: 5.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + + // --------------------------------------------------------------------------- + // DeepSeek Models - Source: better-ccusage + // --------------------------------------------------------------------------- + 'deepseek-chat': { + inputPerMillion: 0.27, + outputPerMillion: 1.1, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.07, + }, + 'deepseek-reasoner': { + inputPerMillion: 0.55, + outputPerMillion: 2.19, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.14, + }, + 'deepseek-coder': { + inputPerMillion: 0.14, + outputPerMillion: 0.28, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + + // --------------------------------------------------------------------------- + // Mistral Models - Source: better-ccusage + // --------------------------------------------------------------------------- + 'mistral-large-latest': { + inputPerMillion: 2.0, + outputPerMillion: 6.0, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'mistral-medium-latest': { + inputPerMillion: 2.7, + outputPerMillion: 8.1, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'mistral-small-latest': { + inputPerMillion: 0.2, + outputPerMillion: 0.6, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, + 'codestral-latest': { + inputPerMillion: 0.3, + outputPerMillion: 0.9, + cacheCreationPerMillion: 0.0, + cacheReadPerMillion: 0.0, + }, +}; + +// Default pricing for unknown models +const UNKNOWN_MODEL_PRICING: ModelPricing = { + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheCreationPerMillion: 3.75, + cacheReadPerMillion: 0.3, +}; + +// ============================================================================ +// PRICING FUNCTIONS +// ============================================================================ + +/** + * Normalize model name for matching + * Handles variations like provider prefixes and case differences + */ +function normalizeModelName(model: string): string { + // Remove provider prefixes (e.g., "anthropic/claude-..." -> "claude-...") + const normalized = model.toLowerCase().replace(/^[^/]+\//, ''); + return normalized; +} + +/** + * 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 + */ +export function getModelPricing(model: string): ModelPricing { + // Try exact match first + if (PRICING_REGISTRY[model]) { + return PRICING_REGISTRY[model]; + } + + // Try normalized match + const normalized = normalizeModelName(model); + if (PRICING_REGISTRY[normalized]) { + return PRICING_REGISTRY[normalized]; + } + + // Try suffix matching (e.g., "claude-sonnet-4-5" matches "*-claude-sonnet-4-5") + for (const [key, pricing] of Object.entries(PRICING_REGISTRY)) { + if (normalized.endsWith(key) || key.endsWith(normalized)) { + return pricing; + } + } + + // Try partial matching for model families + for (const [key, pricing] of Object.entries(PRICING_REGISTRY)) { + // Match by model family prefix + if (normalized.startsWith(key.split('-').slice(0, 2).join('-'))) { + return pricing; + } + } + + // Fallback to unknown model pricing + return UNKNOWN_MODEL_PRICING; +} + +/** + * Calculate cost in USD from token usage and model + * @param usage - Token counts (input, output, cache creation, cache read) + * @param model - Model name for pricing lookup + * @returns Cost in USD + */ +export function calculateCost(usage: TokenUsage, model: string): number { + const pricing = getModelPricing(model); + + const inputCost = (usage.inputTokens / 1_000_000) * pricing.inputPerMillion; + const outputCost = (usage.outputTokens / 1_000_000) * pricing.outputPerMillion; + const cacheCreationCost = + (usage.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion; + const cacheReadCost = (usage.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion; + + return inputCost + outputCost + cacheCreationCost + cacheReadCost; +} + +/** + * Get list of all known models for UI display + */ +export function getKnownModels(): string[] { + return Object.keys(PRICING_REGISTRY); +} + +/** + * Check if a model has custom pricing (not using fallback) + */ +export function hasCustomPricing(model: string): boolean { + return ( + PRICING_REGISTRY[model] !== undefined || + PRICING_REGISTRY[normalizeModelName(model)] !== undefined + ); +} diff --git a/src/web-server/usage-disk-cache.ts b/src/web-server/usage-disk-cache.ts index a1e69e2b..2396c7be 100644 --- a/src/web-server/usage-disk-cache.ts +++ b/src/web-server/usage-disk-cache.ts @@ -11,7 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import type { DailyUsage, MonthlyUsage, SessionUsage } from 'better-ccusage/data-loader'; +import type { DailyUsage, MonthlyUsage, SessionUsage } from './usage-types'; // Cache configuration const CCS_DIR = path.join(os.homedir(), '.ccs'); @@ -29,7 +29,8 @@ export interface UsageDiskCache { } // Current cache version - increment to invalidate old caches -const CACHE_VERSION = 1; +// v2: Updated model pricing (Opus 4.5: $5/$25, Gemini 3, GLM, Kimi, etc.) +const CACHE_VERSION = 2; /** * Ensure ~/.ccs directory exists diff --git a/src/web-server/usage-routes.ts b/src/web-server/usage-routes.ts index 03e4e882..588a321e 100644 --- a/src/web-server/usage-routes.ts +++ b/src/web-server/usage-routes.ts @@ -1,7 +1,7 @@ /** * Usage Analytics API Routes * - * Provides REST endpoints for Claude Code usage analytics using better-ccusage library. + * Provides REST endpoints for Claude Code usage analytics. * Supports daily, monthly, and session-based usage data aggregation. * * Performance optimizations: @@ -19,10 +19,9 @@ import { loadDailyUsageData, loadMonthlyUsageData, loadSessionData, - type DailyUsage, - type MonthlyUsage, - type SessionUsage, -} from 'better-ccusage/data-loader'; + loadAllUsageData, +} from './data-aggregator'; +import type { DailyUsage, MonthlyUsage, SessionUsage } from './usage-types'; import { readDiskCache, writeDiskCache, @@ -65,39 +64,23 @@ function getInstancePaths(): string[] { } /** - * Load usage data from a specific instance by temporarily setting CLAUDE_CONFIG_DIR - * Returns empty arrays if instance has no usage data + * Load usage data from a specific instance + * Uses custom JSONL parser with instance's projects directory */ async function loadInstanceData(instancePath: string): Promise<{ daily: DailyUsage[]; monthly: MonthlyUsage[]; session: SessionUsage[]; }> { - const originalConfigDir = process.env.CLAUDE_CONFIG_DIR; - try { - // Set CLAUDE_CONFIG_DIR to instance path for better-ccusage to read from - process.env.CLAUDE_CONFIG_DIR = instancePath; - - const [daily, monthly, session] = await Promise.all([ - loadDailyUsageData() as Promise, - loadMonthlyUsageData() as Promise, - loadSessionData() as Promise, - ]); - - return { daily, monthly, session }; + const projectsDir = path.join(instancePath, 'projects'); + const result = await loadAllUsageData({ projectsDir }); + return result; } catch (_err) { // Instance may have no usage data - that's OK const instanceName = path.basename(instancePath); console.log(`[i] No usage data in instance: ${instanceName}`); return { daily: [], monthly: [], session: [] }; - } finally { - // Restore original env var - if (originalConfigDir === undefined) { - delete process.env.CLAUDE_CONFIG_DIR; - } else { - process.env.CLAUDE_CONFIG_DIR = originalConfigDir; - } } } @@ -328,21 +311,21 @@ async function getCachedData(key: string, ttl: number, loader: () => Promise< /** Cached loader for daily usage data */ async function getCachedDailyData(): Promise { return getCachedData('daily', CACHE_TTL.daily, async () => { - return (await loadDailyUsageData()) as DailyUsage[]; + return await loadDailyUsageData(); }); } /** Cached loader for monthly usage data */ async function getCachedMonthlyData(): Promise { return getCachedData('monthly', CACHE_TTL.monthly, async () => { - return (await loadMonthlyUsageData()) as MonthlyUsage[]; + return await loadMonthlyUsageData(); }); } /** Cached loader for session data */ async function getCachedSessionData(): Promise { return getCachedData('session', CACHE_TTL.session, async () => { - return (await loadSessionData()) as SessionUsage[]; + return await loadSessionData(); }); } @@ -360,7 +343,7 @@ export function clearUsageCache(): void { let isRefreshing = false; /** - * Load fresh data from better-ccusage and update both memory and disk caches + * Load fresh data and update both memory and disk caches * Aggregates data from default ~/.claude/ AND all CCS instances */ async function refreshFromSource(): Promise<{ @@ -368,12 +351,8 @@ async function refreshFromSource(): Promise<{ monthly: MonthlyUsage[]; session: SessionUsage[]; }> { - // Load default data (from ~/.claude/ or current CLAUDE_CONFIG_DIR) - const defaultData = await Promise.all([ - loadDailyUsageData() as Promise, - loadMonthlyUsageData() as Promise, - loadSessionData() as Promise, - ]).then(([daily, monthly, session]) => ({ daily, monthly, session })); + // Load default data (from ~/.claude/projects/ or CLAUDE_CONFIG_DIR) + const defaultData = await loadAllUsageData(); // Load data from all CCS instances sequentially (to avoid env var race condition) const instancePaths = getInstancePaths(); diff --git a/src/web-server/usage-types.ts b/src/web-server/usage-types.ts new file mode 100644 index 00000000..1650e0dc --- /dev/null +++ b/src/web-server/usage-types.ts @@ -0,0 +1,68 @@ +/** + * Usage Data Types + * + * Type definitions for aggregated usage data. + * Compatible with better-ccusage interfaces for drop-in replacement. + */ + +// ============================================================================ +// MODEL BREAKDOWN +// ============================================================================ + +/** Per-model token and cost breakdown */ +export interface ModelBreakdown { + modelName: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; +} + +// ============================================================================ +// AGGREGATED USAGE TYPES +// ============================================================================ + +/** Daily usage aggregation (YYYY-MM-DD) */ +export interface DailyUsage { + date: string; + source: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + totalCost: number; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; +} + +/** Monthly usage aggregation (YYYY-MM) */ +export interface MonthlyUsage { + month: string; + source: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalCost: number; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; +} + +/** Session-level usage aggregation */ +export interface SessionUsage { + sessionId: string; + projectPath: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + totalCost: number; + lastActivity: string; + versions: string[]; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; + source: string; +} diff --git a/tests/unit/data-aggregator.test.ts b/tests/unit/data-aggregator.test.ts new file mode 100644 index 00000000..206bddae --- /dev/null +++ b/tests/unit/data-aggregator.test.ts @@ -0,0 +1,280 @@ +/** + * Unit tests for Data Aggregator + */ + +import { describe, expect, test } from 'bun:test'; +import { + aggregateDailyUsage, + aggregateMonthlyUsage, + aggregateSessionUsage, +} from '../../src/web-server/data-aggregator'; +import { type RawUsageEntry } from '../../src/web-server/jsonl-parser'; + +// ============================================================================ +// TEST FIXTURES +// ============================================================================ + +const createEntry = ( + overrides: Partial = {} +): RawUsageEntry => ({ + inputTokens: 1000, + outputTokens: 500, + cacheCreationTokens: 100, + cacheReadTokens: 50, + model: 'claude-sonnet-4-5', + sessionId: 'session-123', + timestamp: '2025-12-09T10:00:00.000Z', + projectPath: '/home/user/project', + version: '2.0.60', + ...overrides, +}); + +// ============================================================================ +// aggregateDailyUsage Tests +// ============================================================================ + +describe('aggregateDailyUsage', () => { + test('aggregates entries by date', () => { + const entries: RawUsageEntry[] = [ + createEntry({ timestamp: '2025-12-09T10:00:00.000Z', inputTokens: 1000 }), + createEntry({ timestamp: '2025-12-09T14:00:00.000Z', inputTokens: 2000 }), + createEntry({ timestamp: '2025-12-08T10:00:00.000Z', inputTokens: 500 }), + ]; + + const result = aggregateDailyUsage(entries); + + expect(result.length).toBe(2); + // Most recent first + expect(result[0].date).toBe('2025-12-09'); + expect(result[0].inputTokens).toBe(3000); // 1000 + 2000 + expect(result[1].date).toBe('2025-12-08'); + expect(result[1].inputTokens).toBe(500); + }); + + test('groups by model within each day', () => { + const entries: RawUsageEntry[] = [ + createEntry({ model: 'claude-sonnet-4-5', inputTokens: 1000 }), + createEntry({ model: 'claude-opus-4-5-20251101', inputTokens: 2000 }), + createEntry({ model: 'claude-sonnet-4-5', inputTokens: 500 }), + ]; + + const result = aggregateDailyUsage(entries); + + expect(result.length).toBe(1); + expect(result[0].modelBreakdowns.length).toBe(2); + expect(result[0].modelsUsed).toContain('claude-sonnet-4-5'); + expect(result[0].modelsUsed).toContain('claude-opus-4-5-20251101'); + + // Find sonnet breakdown + const sonnet = result[0].modelBreakdowns.find( + (b) => b.modelName === 'claude-sonnet-4-5' + ); + expect(sonnet!.inputTokens).toBe(1500); // 1000 + 500 + }); + + test('calculates costs correctly', () => { + const entries: RawUsageEntry[] = [ + createEntry({ + model: 'claude-sonnet-4-5', + inputTokens: 1_000_000, // $3.00 + outputTokens: 1_000_000, // $15.00 + cacheCreationTokens: 0, + cacheReadTokens: 0, + }), + ]; + + const result = aggregateDailyUsage(entries); + + expect(result[0].totalCost).toBeCloseTo(18.0, 2); + expect(result[0].modelBreakdowns[0].cost).toBeCloseTo(18.0, 2); + }); + + test('returns empty array for no entries', () => { + const result = aggregateDailyUsage([]); + expect(result.length).toBe(0); + }); + + test('sorts model breakdowns by cost descending', () => { + const entries: RawUsageEntry[] = [ + createEntry({ model: 'claude-haiku-4-5-20251001', inputTokens: 1000 }), // cheap + createEntry({ model: 'claude-opus-4-5-20251101', inputTokens: 1000 }), // expensive + ]; + + const result = aggregateDailyUsage(entries); + + // Opus should be first (higher cost) + expect(result[0].modelBreakdowns[0].modelName).toBe('claude-opus-4-5-20251101'); + }); + + test('sets source field', () => { + const entries: RawUsageEntry[] = [createEntry()]; + const result = aggregateDailyUsage(entries, 'test-source'); + expect(result[0].source).toBe('test-source'); + }); +}); + +// ============================================================================ +// aggregateMonthlyUsage Tests +// ============================================================================ + +describe('aggregateMonthlyUsage', () => { + test('aggregates entries by month', () => { + const entries: RawUsageEntry[] = [ + createEntry({ timestamp: '2025-12-09T10:00:00.000Z', inputTokens: 1000 }), + createEntry({ timestamp: '2025-12-15T10:00:00.000Z', inputTokens: 2000 }), + createEntry({ timestamp: '2025-11-20T10:00:00.000Z', inputTokens: 500 }), + ]; + + const result = aggregateMonthlyUsage(entries); + + expect(result.length).toBe(2); + // Most recent first + expect(result[0].month).toBe('2025-12'); + expect(result[0].inputTokens).toBe(3000); // 1000 + 2000 + expect(result[1].month).toBe('2025-11'); + expect(result[1].inputTokens).toBe(500); + }); + + test('groups by model within each month', () => { + const entries: RawUsageEntry[] = [ + createEntry({ model: 'claude-sonnet-4-5', inputTokens: 1000 }), + createEntry({ model: 'gemini-2.5-pro', inputTokens: 2000 }), + ]; + + const result = aggregateMonthlyUsage(entries); + + expect(result[0].modelBreakdowns.length).toBe(2); + expect(result[0].modelsUsed).toContain('claude-sonnet-4-5'); + expect(result[0].modelsUsed).toContain('gemini-2.5-pro'); + }); + + test('returns empty array for no entries', () => { + const result = aggregateMonthlyUsage([]); + expect(result.length).toBe(0); + }); +}); + +// ============================================================================ +// aggregateSessionUsage Tests +// ============================================================================ + +describe('aggregateSessionUsage', () => { + test('aggregates entries by sessionId', () => { + const entries: RawUsageEntry[] = [ + createEntry({ sessionId: 'session-A', inputTokens: 1000 }), + createEntry({ sessionId: 'session-A', inputTokens: 2000 }), + createEntry({ sessionId: 'session-B', inputTokens: 500 }), + ]; + + const result = aggregateSessionUsage(entries); + + expect(result.length).toBe(2); + + const sessionA = result.find((s) => s.sessionId === 'session-A'); + expect(sessionA!.inputTokens).toBe(3000); + + const sessionB = result.find((s) => s.sessionId === 'session-B'); + expect(sessionB!.inputTokens).toBe(500); + }); + + test('tracks last activity timestamp', () => { + const entries: RawUsageEntry[] = [ + createEntry({ sessionId: 'session-A', timestamp: '2025-12-09T10:00:00.000Z' }), + createEntry({ sessionId: 'session-A', timestamp: '2025-12-09T14:00:00.000Z' }), + createEntry({ sessionId: 'session-A', timestamp: '2025-12-09T12:00:00.000Z' }), + ]; + + const result = aggregateSessionUsage(entries); + + expect(result[0].lastActivity).toBe('2025-12-09T14:00:00.000Z'); + }); + + test('collects unique versions', () => { + const entries: RawUsageEntry[] = [ + createEntry({ sessionId: 'session-A', version: '2.0.59' }), + createEntry({ sessionId: 'session-A', version: '2.0.60' }), + createEntry({ sessionId: 'session-A', version: '2.0.60' }), // duplicate + ]; + + const result = aggregateSessionUsage(entries); + + expect(result[0].versions.length).toBe(2); + expect(result[0].versions).toContain('2.0.59'); + expect(result[0].versions).toContain('2.0.60'); + }); + + test('includes project path', () => { + const entries: RawUsageEntry[] = [ + createEntry({ sessionId: 'session-A', projectPath: '/home/user/my-project' }), + ]; + + const result = aggregateSessionUsage(entries); + + expect(result[0].projectPath).toBe('/home/user/my-project'); + }); + + test('skips entries without sessionId', () => { + const entries: RawUsageEntry[] = [ + createEntry({ sessionId: '', inputTokens: 1000 }), + createEntry({ sessionId: 'valid-session', inputTokens: 500 }), + ]; + + const result = aggregateSessionUsage(entries); + + expect(result.length).toBe(1); + expect(result[0].sessionId).toBe('valid-session'); + }); + + test('sorts by last activity descending', () => { + const entries: RawUsageEntry[] = [ + createEntry({ sessionId: 'old-session', timestamp: '2025-12-01T10:00:00.000Z' }), + createEntry({ sessionId: 'new-session', timestamp: '2025-12-09T10:00:00.000Z' }), + ]; + + const result = aggregateSessionUsage(entries); + + expect(result[0].sessionId).toBe('new-session'); + expect(result[1].sessionId).toBe('old-session'); + }); + + test('returns empty array for no entries', () => { + const result = aggregateSessionUsage([]); + expect(result.length).toBe(0); + }); +}); + +// ============================================================================ +// Integration: All token types +// ============================================================================ + +describe('token aggregation completeness', () => { + test('aggregates all token types correctly', () => { + const entries: RawUsageEntry[] = [ + createEntry({ + inputTokens: 100, + outputTokens: 200, + cacheCreationTokens: 50, + cacheReadTokens: 25, + }), + createEntry({ + inputTokens: 150, + outputTokens: 100, + cacheCreationTokens: 30, + cacheReadTokens: 10, + }), + ]; + + const daily = aggregateDailyUsage(entries); + + expect(daily[0].inputTokens).toBe(250); + expect(daily[0].outputTokens).toBe(300); + expect(daily[0].cacheCreationTokens).toBe(80); + expect(daily[0].cacheReadTokens).toBe(35); + + // Model breakdown should also have correct totals + expect(daily[0].modelBreakdowns[0].inputTokens).toBe(250); + expect(daily[0].modelBreakdowns[0].outputTokens).toBe(300); + expect(daily[0].modelBreakdowns[0].cacheCreationTokens).toBe(80); + expect(daily[0].modelBreakdowns[0].cacheReadTokens).toBe(35); + }); +}); diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts new file mode 100644 index 00000000..c663d518 --- /dev/null +++ b/tests/unit/jsonl-parser.test.ts @@ -0,0 +1,411 @@ +/** + * Unit tests for JSONL Parser + */ + +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + parseUsageEntry, + parseJsonlFile, + parseProjectDirectory, + scanProjectsDirectory, + findProjectDirectories, + countJsonlFiles, + getDefaultProjectsDir, + type RawUsageEntry, +} from '../../src/web-server/jsonl-parser'; + +// ============================================================================ +// TEST FIXTURES +// ============================================================================ + +const VALID_ASSISTANT_ENTRY = JSON.stringify({ + type: 'assistant', + sessionId: 'test-session-123', + timestamp: '2025-12-09T10:00:00.000Z', + version: '2.0.60', + cwd: '/home/user/project', + message: { + model: 'claude-sonnet-4-5', + usage: { + input_tokens: 1000, + output_tokens: 500, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 100, + }, + }, +}); + +const ASSISTANT_ENTRY_NO_CACHE = JSON.stringify({ + type: 'assistant', + sessionId: 'test-session-456', + timestamp: '2025-12-09T11:00:00.000Z', + message: { + model: 'gemini-2.5-pro', + usage: { + input_tokens: 2000, + output_tokens: 1000, + }, + }, +}); + +const USER_ENTRY = JSON.stringify({ + type: 'user', + sessionId: 'test-session-123', + timestamp: '2025-12-09T09:59:00.000Z', + message: { + role: 'user', + content: 'Hello world', + }, +}); + +const ASSISTANT_NO_USAGE = JSON.stringify({ + type: 'assistant', + sessionId: 'test-session-123', + timestamp: '2025-12-09T10:01:00.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'response' }], + }, +}); + +const FILE_HISTORY_ENTRY = JSON.stringify({ + type: 'file-history-snapshot', + messageId: 'some-uuid', + snapshot: {}, +}); + +// ============================================================================ +// parseUsageEntry Tests +// ============================================================================ + +describe('parseUsageEntry', () => { + test('parses valid assistant entry with full usage data', () => { + const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/home/user/project'); + + expect(result).not.toBeNull(); + expect(result!.inputTokens).toBe(1000); + expect(result!.outputTokens).toBe(500); + expect(result!.cacheCreationTokens).toBe(200); + expect(result!.cacheReadTokens).toBe(100); + expect(result!.model).toBe('claude-sonnet-4-5'); + expect(result!.sessionId).toBe('test-session-123'); + expect(result!.timestamp).toBe('2025-12-09T10:00:00.000Z'); + expect(result!.version).toBe('2.0.60'); + }); + + test('parses assistant entry without cache tokens (defaults to 0)', () => { + const result = parseUsageEntry(ASSISTANT_ENTRY_NO_CACHE, '/home/user/project'); + + expect(result).not.toBeNull(); + expect(result!.inputTokens).toBe(2000); + expect(result!.outputTokens).toBe(1000); + expect(result!.cacheCreationTokens).toBe(0); + expect(result!.cacheReadTokens).toBe(0); + expect(result!.model).toBe('gemini-2.5-pro'); + }); + + test('returns null for user entries', () => { + const result = parseUsageEntry(USER_ENTRY, '/home/user/project'); + expect(result).toBeNull(); + }); + + test('returns null for assistant entries without usage data', () => { + const result = parseUsageEntry(ASSISTANT_NO_USAGE, '/home/user/project'); + expect(result).toBeNull(); + }); + + test('returns null for file-history-snapshot entries', () => { + const result = parseUsageEntry(FILE_HISTORY_ENTRY, '/home/user/project'); + expect(result).toBeNull(); + }); + + test('returns null for empty lines', () => { + expect(parseUsageEntry('', '/test')).toBeNull(); + expect(parseUsageEntry(' ', '/test')).toBeNull(); + expect(parseUsageEntry('\n', '/test')).toBeNull(); + }); + + test('returns null for malformed JSON', () => { + expect(parseUsageEntry('{invalid json}', '/test')).toBeNull(); + expect(parseUsageEntry('not json at all', '/test')).toBeNull(); + }); + + test('includes project path in result', () => { + const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/custom/project/path'); + expect(result!.projectPath).toBe('/custom/project/path'); + }); +}); + +// ============================================================================ +// File Parsing Tests (with temp files) +// ============================================================================ + +describe('parseJsonlFile', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('parses file with mixed entry types', async () => { + const filePath = path.join(tempDir, 'test.jsonl'); + const content = [ + USER_ENTRY, + VALID_ASSISTANT_ENTRY, + FILE_HISTORY_ENTRY, + ASSISTANT_ENTRY_NO_CACHE, + ASSISTANT_NO_USAGE, + ].join('\n'); + + fs.writeFileSync(filePath, content); + + const entries = await parseJsonlFile(filePath, '/test/project'); + + // Only 2 valid assistant entries with usage + expect(entries.length).toBe(2); + expect(entries[0].model).toBe('claude-sonnet-4-5'); + expect(entries[1].model).toBe('gemini-2.5-pro'); + }); + + test('handles empty file', async () => { + const filePath = path.join(tempDir, 'empty.jsonl'); + fs.writeFileSync(filePath, ''); + + const entries = await parseJsonlFile(filePath, '/test'); + expect(entries.length).toBe(0); + }); + + test('returns empty array for non-existent file', async () => { + const entries = await parseJsonlFile('/nonexistent/file.jsonl', '/test'); + expect(entries.length).toBe(0); + }); + + test('handles file with blank lines', async () => { + const filePath = path.join(tempDir, 'blanks.jsonl'); + const content = [ + '', + VALID_ASSISTANT_ENTRY, + '', + ' ', + ASSISTANT_ENTRY_NO_CACHE, + '', + ].join('\n'); + + fs.writeFileSync(filePath, content); + + const entries = await parseJsonlFile(filePath, '/test'); + expect(entries.length).toBe(2); + }); +}); + +// ============================================================================ +// Directory Scanning Tests +// ============================================================================ + +describe('parseProjectDirectory', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'project-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('parses all JSONL files in directory', async () => { + // Create multiple JSONL files + fs.writeFileSync(path.join(tempDir, 'session1.jsonl'), VALID_ASSISTANT_ENTRY); + fs.writeFileSync(path.join(tempDir, 'session2.jsonl'), ASSISTANT_ENTRY_NO_CACHE); + + const entries = await parseProjectDirectory(tempDir); + + expect(entries.length).toBe(2); + }); + + test('ignores non-JSONL files', async () => { + fs.writeFileSync(path.join(tempDir, 'session.jsonl'), VALID_ASSISTANT_ENTRY); + fs.writeFileSync(path.join(tempDir, 'readme.txt'), 'text file'); + fs.writeFileSync(path.join(tempDir, 'data.json'), '{}'); + + const entries = await parseProjectDirectory(tempDir); + + expect(entries.length).toBe(1); + }); + + test('returns empty array for non-existent directory', async () => { + const entries = await parseProjectDirectory('/nonexistent/dir'); + expect(entries.length).toBe(0); + }); +}); + +describe('findProjectDirectories', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'projects-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('finds all subdirectories', () => { + fs.mkdirSync(path.join(tempDir, 'project-a')); + fs.mkdirSync(path.join(tempDir, 'project-b')); + fs.writeFileSync(path.join(tempDir, 'file.txt'), 'not a dir'); + + const dirs = findProjectDirectories(tempDir); + + expect(dirs.length).toBe(2); + expect(dirs).toContain(path.join(tempDir, 'project-a')); + expect(dirs).toContain(path.join(tempDir, 'project-b')); + }); + + test('returns empty array for non-existent directory', () => { + const dirs = findProjectDirectories('/nonexistent/path'); + expect(dirs.length).toBe(0); + }); +}); + +describe('countJsonlFiles', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'count-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('counts JSONL files across multiple project directories', () => { + const project1 = path.join(tempDir, 'project-a'); + const project2 = path.join(tempDir, 'project-b'); + fs.mkdirSync(project1); + fs.mkdirSync(project2); + + fs.writeFileSync(path.join(project1, 'a.jsonl'), ''); + fs.writeFileSync(path.join(project1, 'b.jsonl'), ''); + fs.writeFileSync(path.join(project2, 'c.jsonl'), ''); + fs.writeFileSync(path.join(project1, 'not-jsonl.txt'), ''); + + const count = countJsonlFiles(tempDir); + expect(count).toBe(3); + }); +}); + +// ============================================================================ +// scanProjectsDirectory Tests +// ============================================================================ + +describe('scanProjectsDirectory', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('scans all projects and aggregates entries', async () => { + const project1 = path.join(tempDir, '-home-user-project1'); + const project2 = path.join(tempDir, '-home-user-project2'); + fs.mkdirSync(project1); + fs.mkdirSync(project2); + + fs.writeFileSync(path.join(project1, 'session.jsonl'), VALID_ASSISTANT_ENTRY); + fs.writeFileSync(path.join(project2, 'session.jsonl'), ASSISTANT_ENTRY_NO_CACHE); + + const entries = await scanProjectsDirectory({ projectsDir: tempDir }); + + expect(entries.length).toBe(2); + }); + + test('filters by minDate', async () => { + const project = path.join(tempDir, '-test-project'); + fs.mkdirSync(project); + + const oldEntry = JSON.stringify({ + type: 'assistant', + sessionId: 'old', + timestamp: '2024-01-01T00:00:00.000Z', + message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 100, output_tokens: 50 } }, + }); + const newEntry = JSON.stringify({ + type: 'assistant', + sessionId: 'new', + timestamp: '2025-12-09T00:00:00.000Z', + message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 200, output_tokens: 100 } }, + }); + + fs.writeFileSync(path.join(project, 'session.jsonl'), [oldEntry, newEntry].join('\n')); + + const entries = await scanProjectsDirectory({ + projectsDir: tempDir, + minDate: new Date('2025-01-01'), + }); + + expect(entries.length).toBe(1); + expect(entries[0].sessionId).toBe('new'); + }); + + test('returns empty array for empty directory', async () => { + const entries = await scanProjectsDirectory({ projectsDir: tempDir }); + expect(entries.length).toBe(0); + }); + + test('respects concurrency option', async () => { + // Create 5 projects + for (let i = 0; i < 5; i++) { + const project = path.join(tempDir, `-project-${i}`); + fs.mkdirSync(project); + fs.writeFileSync(path.join(project, 'session.jsonl'), VALID_ASSISTANT_ENTRY); + } + + // Should still work with concurrency of 2 + const entries = await scanProjectsDirectory({ + projectsDir: tempDir, + concurrency: 2, + }); + + expect(entries.length).toBe(5); + }); +}); + +// ============================================================================ +// getDefaultProjectsDir Tests +// ============================================================================ + +describe('getDefaultProjectsDir', () => { + const originalEnv = process.env.CLAUDE_CONFIG_DIR; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalEnv; + } + }); + + test('uses CLAUDE_CONFIG_DIR env var if set', () => { + process.env.CLAUDE_CONFIG_DIR = '/custom/claude'; + const dir = getDefaultProjectsDir(); + expect(dir).toBe('/custom/claude/projects'); + }); + + test('falls back to ~/.claude/projects', () => { + delete process.env.CLAUDE_CONFIG_DIR; + const dir = getDefaultProjectsDir(); + expect(dir).toBe(path.join(os.homedir(), '.claude', 'projects')); + }); +}); diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts new file mode 100644 index 00000000..3b182b8b --- /dev/null +++ b/tests/unit/model-pricing.test.ts @@ -0,0 +1,141 @@ +/** + * Unit tests for model-pricing.ts + */ +import { describe, it, expect } from 'bun:test'; +import { + getModelPricing, + calculateCost, + getKnownModels, + hasCustomPricing, + type TokenUsage, +} from '../../src/web-server/model-pricing'; + +describe('model-pricing', () => { + describe('getModelPricing', () => { + it('should return exact match pricing', () => { + const pricing = getModelPricing('claude-sonnet-4-5-20250929'); + expect(pricing.inputPerMillion).toBe(3.0); + expect(pricing.outputPerMillion).toBe(15.0); + }); + + it('should return pricing for all known models', () => { + const knownModels = getKnownModels(); + expect(knownModels.length).toBeGreaterThanOrEqual(60); // 62 models from better-ccusage integration + + for (const model of knownModels) { + const pricing = getModelPricing(model); + expect(pricing).toBeDefined(); + expect(typeof pricing.inputPerMillion).toBe('number'); + } + }); + + it('should return fallback pricing for unknown models', () => { + const pricing = getModelPricing('unknown-model-xyz'); + expect(pricing.inputPerMillion).toBe(3.0); + expect(pricing.outputPerMillion).toBe(15.0); + }); + + it('should handle provider-prefixed model names', () => { + const pricing = getModelPricing('anthropic/claude-sonnet-4-5'); + expect(pricing).toBeDefined(); + // Should match via normalization + }); + + it('should return different pricing for different model tiers', () => { + const sonnet = getModelPricing('claude-sonnet-4-5'); + const opus = getModelPricing('claude-opus-4-5-20251101'); + const haiku = getModelPricing('claude-haiku-4-5-20251001'); + + expect(opus.inputPerMillion).toBeGreaterThan(sonnet.inputPerMillion); + expect(sonnet.inputPerMillion).toBeGreaterThan(haiku.inputPerMillion); + }); + }); + + describe('calculateCost', () => { + it('should calculate cost correctly for input tokens', () => { + const usage: TokenUsage = { + inputTokens: 1_000_000, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + const cost = calculateCost(usage, 'claude-sonnet-4-5'); + expect(cost).toBe(3.0); // $3.00 per million input tokens + }); + + it('should calculate cost correctly for output tokens', () => { + const usage: TokenUsage = { + inputTokens: 0, + outputTokens: 1_000_000, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + const cost = calculateCost(usage, 'claude-sonnet-4-5'); + expect(cost).toBe(15.0); // $15.00 per million output tokens + }); + + it('should calculate combined cost correctly', () => { + const usage: TokenUsage = { + inputTokens: 500_000, + outputTokens: 100_000, + cacheCreationTokens: 50_000, + cacheReadTokens: 200_000, + }; + const cost = calculateCost(usage, 'claude-sonnet-4-5'); + // 0.5M * 3.0 + 0.1M * 15.0 + 0.05M * 3.75 + 0.2M * 0.30 + // = 1.5 + 1.5 + 0.1875 + 0.06 + expect(cost).toBeCloseTo(3.2475, 4); + }); + + it('should return 0 for zero usage', () => { + const usage: TokenUsage = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + }; + const cost = calculateCost(usage, 'claude-sonnet-4-5'); + expect(cost).toBe(0); + }); + + it('should return 0 cost for free-tier/experimental models', () => { + const usage: TokenUsage = { + inputTokens: 1_000_000, + outputTokens: 500_000, + cacheCreationTokens: 100_000, + cacheReadTokens: 50_000, + }; + const cost = calculateCost(usage, 'gemini-2.0-flash-exp'); + expect(cost).toBe(0); // Experimental models are free + }); + }); + + describe('getKnownModels', () => { + it('should return array of model names', () => { + const models = getKnownModels(); + expect(Array.isArray(models)).toBe(true); + expect(models.length).toBeGreaterThan(0); + }); + + it('should include Claude models', () => { + const models = getKnownModels(); + expect(models.some((m) => m.startsWith('claude-'))).toBe(true); + }); + + it('should include GLM models', () => { + const models = getKnownModels(); + expect(models.some((m) => m.startsWith('glm-'))).toBe(true); + }); + }); + + describe('hasCustomPricing', () => { + it('should return true for known models', () => { + expect(hasCustomPricing('claude-sonnet-4-5')).toBe(true); + expect(hasCustomPricing('glm-4.6')).toBe(true); + }); + + it('should return false for unknown models', () => { + expect(hasCustomPricing('unknown-model-xyz')).toBe(false); + }); + }); +});