feat(usage): add internal data aggregation and cost tracking

This commit is contained in:
kaitranntt
2025-12-09 23:41:47 -05:00
parent 876d1187f9
commit 49b4065186
12 changed files with 2250 additions and 117 deletions
-3
View File
@@ -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=="],
-1
View File
@@ -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",
-75
View File
@@ -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<DailyUsage[]>;
export function loadMonthlyUsageData(options?: DataLoaderOptions): Promise<MonthlyUsage[]>;
export function loadSessionData(options?: DataLoaderOptions): Promise<SessionUsage[]>;
}
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[];
+405
View File
@@ -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<string, RawUsageEntry[]>();
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<string, ModelAccumulator>();
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<string, RawUsageEntry[]>();
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<string, ModelAccumulator>();
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<string, RawUsageEntry[]>();
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<string, ModelAccumulator>();
const versions = new Set<string>();
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<DailyUsage[]> {
const entries = await scanProjectsDirectory(options);
return aggregateDailyUsage(entries);
}
/**
* Load monthly usage data (replaces better-ccusage loadMonthlyUsageData)
*/
export async function loadMonthlyUsageData(options?: ParserOptions): Promise<MonthlyUsage[]> {
const entries = await scanProjectsDirectory(options);
return aggregateMonthlyUsage(entries);
}
/**
* Load session data (replaces better-ccusage loadSessionData)
*/
export async function loadSessionData(options?: ParserOptions): Promise<SessionUsage[]> {
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),
};
}
+251
View File
@@ -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<RawUsageEntry[]> {
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<RawUsageEntry[]> {
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<RawUsageEntry[]> {
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;
}
+676
View File
@@ -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<string, ModelPricing> = {
// ---------------------------------------------------------------------------
// 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
);
}
+3 -2
View File
@@ -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
+15 -36
View File
@@ -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<DailyUsage[]>,
loadMonthlyUsageData() as Promise<MonthlyUsage[]>,
loadSessionData() as Promise<SessionUsage[]>,
]);
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<T>(key: string, ttl: number, loader: () => Promise<
/** Cached loader for daily usage data */
async function getCachedDailyData(): Promise<DailyUsage[]> {
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<MonthlyUsage[]> {
return getCachedData('monthly', CACHE_TTL.monthly, async () => {
return (await loadMonthlyUsageData()) as MonthlyUsage[];
return await loadMonthlyUsageData();
});
}
/** Cached loader for session data */
async function getCachedSessionData(): Promise<SessionUsage[]> {
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<DailyUsage[]>,
loadMonthlyUsageData() as Promise<MonthlyUsage[]>,
loadSessionData() as Promise<SessionUsage[]>,
]).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();
+68
View File
@@ -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;
}
+280
View File
@@ -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> = {}
): 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);
});
});
+411
View File
@@ -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'));
});
});
+141
View File
@@ -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);
});
});
});