mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-09 12:23:30 +00:00
Merge pull request #71 from kaitranntt/dev
feat(release): promote dev to main - web dashboard, analytics, and multi-account support
This commit is contained in:
@@ -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
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "5.13.0",
|
||||
"version": "5.13.0-dev.4",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -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",
|
||||
|
||||
Vendored
-75
@@ -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[];
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+303
-52
@@ -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,17 @@ import {
|
||||
loadDailyUsageData,
|
||||
loadMonthlyUsageData,
|
||||
loadSessionData,
|
||||
type DailyUsage,
|
||||
type MonthlyUsage,
|
||||
type SessionUsage,
|
||||
} from 'better-ccusage/data-loader';
|
||||
loadAllUsageData,
|
||||
} from './data-aggregator';
|
||||
import type {
|
||||
DailyUsage,
|
||||
MonthlyUsage,
|
||||
SessionUsage,
|
||||
Anomaly,
|
||||
AnomalySummary,
|
||||
TokenBreakdown,
|
||||
} from './usage-types';
|
||||
import { getModelPricing } from './model-pricing';
|
||||
import {
|
||||
readDiskCache,
|
||||
writeDiskCache,
|
||||
@@ -65,39 +72,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 +319,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 +351,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 +359,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();
|
||||
@@ -627,6 +614,45 @@ function errorResponse(res: Response, error: unknown, defaultMessage: string): v
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cost breakdown for token categories
|
||||
* Uses weighted average pricing across models in the dataset
|
||||
*/
|
||||
function calculateTokenBreakdownCosts(dailyData: DailyUsage[]): TokenBreakdown {
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let inputCost = 0;
|
||||
let outputCost = 0;
|
||||
let cacheCreationCost = 0;
|
||||
let cacheReadCost = 0;
|
||||
|
||||
for (const day of dailyData) {
|
||||
for (const breakdown of day.modelBreakdowns) {
|
||||
const pricing = getModelPricing(breakdown.modelName);
|
||||
|
||||
inputTokens += breakdown.inputTokens;
|
||||
outputTokens += breakdown.outputTokens;
|
||||
cacheCreationTokens += breakdown.cacheCreationTokens;
|
||||
cacheReadTokens += breakdown.cacheReadTokens;
|
||||
|
||||
inputCost += (breakdown.inputTokens / 1_000_000) * pricing.inputPerMillion;
|
||||
outputCost += (breakdown.outputTokens / 1_000_000) * pricing.outputPerMillion;
|
||||
cacheCreationCost +=
|
||||
(breakdown.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion;
|
||||
cacheReadCost += (breakdown.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
input: { tokens: inputTokens, cost: Math.round(inputCost * 100) / 100 },
|
||||
output: { tokens: outputTokens, cost: Math.round(outputCost * 100) / 100 },
|
||||
cacheCreation: { tokens: cacheCreationTokens, cost: Math.round(cacheCreationCost * 100) / 100 },
|
||||
cacheRead: { tokens: cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/usage/summary
|
||||
*
|
||||
@@ -646,17 +672,23 @@ usageRoutes.get(
|
||||
// Calculate totals
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
let totalCacheTokens = 0;
|
||||
let totalCacheCreationTokens = 0;
|
||||
let totalCacheReadTokens = 0;
|
||||
let totalCost = 0;
|
||||
|
||||
for (const day of filtered) {
|
||||
totalInputTokens += day.inputTokens;
|
||||
totalOutputTokens += day.outputTokens;
|
||||
totalCacheTokens += day.cacheCreationTokens + day.cacheReadTokens;
|
||||
totalCacheCreationTokens += day.cacheCreationTokens;
|
||||
totalCacheReadTokens += day.cacheReadTokens;
|
||||
totalCost += day.totalCost;
|
||||
}
|
||||
|
||||
const totalTokens = totalInputTokens + totalOutputTokens;
|
||||
const totalCacheTokens = totalCacheCreationTokens + totalCacheReadTokens;
|
||||
|
||||
// Calculate detailed token breakdown with costs
|
||||
const tokenBreakdown = calculateTokenBreakdownCosts(filtered);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -665,7 +697,10 @@ usageRoutes.get(
|
||||
totalInputTokens,
|
||||
totalOutputTokens,
|
||||
totalCacheTokens,
|
||||
totalCacheCreationTokens,
|
||||
totalCacheReadTokens,
|
||||
totalCost: Math.round(totalCost * 100) / 100,
|
||||
tokenBreakdown,
|
||||
totalDays: filtered.length,
|
||||
averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0,
|
||||
averageCostPerDay:
|
||||
@@ -731,14 +766,15 @@ usageRoutes.get(
|
||||
const dailyData = await getCachedDailyData();
|
||||
const filtered = filterByDateRange(dailyData, since, until);
|
||||
|
||||
// Aggregate model usage across all days
|
||||
// Aggregate model usage across all days with detailed breakdown
|
||||
const modelMap = new Map<
|
||||
string,
|
||||
{
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cost: number;
|
||||
}
|
||||
>();
|
||||
@@ -749,13 +785,15 @@ usageRoutes.get(
|
||||
model: breakdown.modelName,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 0,
|
||||
};
|
||||
|
||||
existing.inputTokens += breakdown.inputTokens;
|
||||
existing.outputTokens += breakdown.outputTokens;
|
||||
existing.cacheTokens += breakdown.cacheCreationTokens + breakdown.cacheReadTokens;
|
||||
existing.cacheCreationTokens += breakdown.cacheCreationTokens;
|
||||
existing.cacheReadTokens += breakdown.cacheReadTokens;
|
||||
existing.cost += breakdown.cost;
|
||||
|
||||
modelMap.set(breakdown.modelName, existing);
|
||||
@@ -766,17 +804,46 @@ usageRoutes.get(
|
||||
const models = Array.from(modelMap.values());
|
||||
const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0);
|
||||
|
||||
// Add percentage and sort by tokens
|
||||
// Add percentage, cost breakdown, and I/O ratio
|
||||
const result = models
|
||||
.map((m) => ({
|
||||
...m,
|
||||
tokens: m.inputTokens + m.outputTokens,
|
||||
cost: Math.round(m.cost * 100) / 100,
|
||||
percentage:
|
||||
totalTokens > 0
|
||||
? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10
|
||||
: 0,
|
||||
}))
|
||||
.map((m) => {
|
||||
const pricing = getModelPricing(m.model);
|
||||
|
||||
// Calculate cost breakdown
|
||||
const inputCost = (m.inputTokens / 1_000_000) * pricing.inputPerMillion;
|
||||
const outputCost = (m.outputTokens / 1_000_000) * pricing.outputPerMillion;
|
||||
const cacheCreationCost =
|
||||
(m.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion;
|
||||
const cacheReadCost = (m.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion;
|
||||
|
||||
// Calculate I/O ratio
|
||||
const ioRatio = m.outputTokens > 0 ? m.inputTokens / m.outputTokens : 0;
|
||||
|
||||
return {
|
||||
model: m.model,
|
||||
tokens: m.inputTokens + m.outputTokens,
|
||||
inputTokens: m.inputTokens,
|
||||
outputTokens: m.outputTokens,
|
||||
cacheCreationTokens: m.cacheCreationTokens,
|
||||
cacheReadTokens: m.cacheReadTokens,
|
||||
cacheTokens: m.cacheCreationTokens + m.cacheReadTokens,
|
||||
cost: Math.round(m.cost * 100) / 100,
|
||||
percentage:
|
||||
totalTokens > 0
|
||||
? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10
|
||||
: 0,
|
||||
costBreakdown: {
|
||||
input: { tokens: m.inputTokens, cost: Math.round(inputCost * 100) / 100 },
|
||||
output: { tokens: m.outputTokens, cost: Math.round(outputCost * 100) / 100 },
|
||||
cacheCreation: {
|
||||
tokens: m.cacheCreationTokens,
|
||||
cost: Math.round(cacheCreationCost * 100) / 100,
|
||||
},
|
||||
cacheRead: { tokens: m.cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 },
|
||||
},
|
||||
ioRatio: Math.round(ioRatio * 10) / 10,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.tokens - a.tokens);
|
||||
|
||||
res.json({
|
||||
@@ -921,3 +988,187 @@ usageRoutes.get('/status', (_req: Request, res: Response) => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ANOMALY DETECTION
|
||||
// ============================================================================
|
||||
|
||||
/** Anomaly detection thresholds */
|
||||
const ANOMALY_THRESHOLDS = {
|
||||
HIGH_INPUT_TOKENS: 10_000_000, // 10M tokens/day/model
|
||||
HIGH_IO_RATIO: 100, // 100x input/output ratio
|
||||
COST_SPIKE_MULTIPLIER: 2, // 2x average daily cost
|
||||
HIGH_CACHE_READ_TOKENS: 1_000_000_000, // 1B cache read tokens
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect anomalies in usage data
|
||||
*/
|
||||
function detectAnomalies(dailyData: DailyUsage[]): Anomaly[] {
|
||||
const anomalies: Anomaly[] = [];
|
||||
|
||||
// Calculate average daily cost for spike detection
|
||||
const totalCost = dailyData.reduce((sum, day) => sum + day.totalCost, 0);
|
||||
const avgDailyCost = dailyData.length > 0 ? totalCost / dailyData.length : 0;
|
||||
const costSpikeThreshold = avgDailyCost * ANOMALY_THRESHOLDS.COST_SPIKE_MULTIPLIER;
|
||||
|
||||
for (const day of dailyData) {
|
||||
// Check for cost spikes
|
||||
if (avgDailyCost > 0 && day.totalCost > costSpikeThreshold) {
|
||||
const multiplier = Math.round((day.totalCost / avgDailyCost) * 10) / 10;
|
||||
anomalies.push({
|
||||
date: day.date,
|
||||
type: 'cost_spike',
|
||||
value: day.totalCost,
|
||||
threshold: avgDailyCost,
|
||||
message: `Cost ${multiplier}x above daily average ($${Math.round(day.totalCost)} vs $${Math.round(avgDailyCost)})`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check per-model anomalies
|
||||
for (const breakdown of day.modelBreakdowns) {
|
||||
// High input tokens per model
|
||||
if (breakdown.inputTokens > ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) {
|
||||
const multiplier =
|
||||
Math.round((breakdown.inputTokens / ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS) * 10) / 10;
|
||||
anomalies.push({
|
||||
date: day.date,
|
||||
type: 'high_input',
|
||||
model: breakdown.modelName,
|
||||
value: breakdown.inputTokens,
|
||||
threshold: ANOMALY_THRESHOLDS.HIGH_INPUT_TOKENS,
|
||||
message: `Input tokens ${multiplier}x above threshold (${formatTokenCount(breakdown.inputTokens)})`,
|
||||
});
|
||||
}
|
||||
|
||||
// High I/O ratio
|
||||
if (breakdown.outputTokens > 0) {
|
||||
const ioRatio = breakdown.inputTokens / breakdown.outputTokens;
|
||||
if (ioRatio > ANOMALY_THRESHOLDS.HIGH_IO_RATIO) {
|
||||
const multiplier = Math.round((ioRatio / ANOMALY_THRESHOLDS.HIGH_IO_RATIO) * 10) / 10;
|
||||
anomalies.push({
|
||||
date: day.date,
|
||||
type: 'high_io_ratio',
|
||||
model: breakdown.modelName,
|
||||
value: ioRatio,
|
||||
threshold: ANOMALY_THRESHOLDS.HIGH_IO_RATIO,
|
||||
message: `I/O ratio ${multiplier}x above threshold (${Math.round(ioRatio)}:1)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// High cache read tokens
|
||||
if (breakdown.cacheReadTokens > ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) {
|
||||
const multiplier =
|
||||
Math.round((breakdown.cacheReadTokens / ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS) * 10) /
|
||||
10;
|
||||
anomalies.push({
|
||||
date: day.date,
|
||||
type: 'high_cache_read',
|
||||
model: breakdown.modelName,
|
||||
value: breakdown.cacheReadTokens,
|
||||
threshold: ANOMALY_THRESHOLDS.HIGH_CACHE_READ_TOKENS,
|
||||
message: `Cache reads ${multiplier}x above threshold (${formatTokenCount(breakdown.cacheReadTokens)})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
return anomalies.sort((a, b) => b.date.localeCompare(a.date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format token count for human readability
|
||||
*/
|
||||
function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1_000_000_000) {
|
||||
return `${(tokens / 1_000_000_000).toFixed(1)}B`;
|
||||
} else if (tokens >= 1_000_000) {
|
||||
return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||
} else if (tokens >= 1_000) {
|
||||
return `${(tokens / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return tokens.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize anomalies by type
|
||||
*/
|
||||
function summarizeAnomalies(anomalies: Anomaly[]): AnomalySummary {
|
||||
const uniqueDates = new Set<string>();
|
||||
let highInputDays = 0;
|
||||
let highIoRatioDays = 0;
|
||||
let costSpikeDays = 0;
|
||||
let highCacheReadDays = 0;
|
||||
|
||||
// Track unique dates per anomaly type
|
||||
const highInputDates = new Set<string>();
|
||||
const highIoRatioDates = new Set<string>();
|
||||
const costSpikeDates = new Set<string>();
|
||||
const highCacheReadDates = new Set<string>();
|
||||
|
||||
for (const anomaly of anomalies) {
|
||||
uniqueDates.add(anomaly.date);
|
||||
|
||||
switch (anomaly.type) {
|
||||
case 'high_input':
|
||||
highInputDates.add(anomaly.date);
|
||||
break;
|
||||
case 'high_io_ratio':
|
||||
highIoRatioDates.add(anomaly.date);
|
||||
break;
|
||||
case 'cost_spike':
|
||||
costSpikeDates.add(anomaly.date);
|
||||
break;
|
||||
case 'high_cache_read':
|
||||
highCacheReadDates.add(anomaly.date);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
highInputDays = highInputDates.size;
|
||||
highIoRatioDays = highIoRatioDates.size;
|
||||
costSpikeDays = costSpikeDates.size;
|
||||
highCacheReadDays = highCacheReadDates.size;
|
||||
|
||||
return {
|
||||
totalAnomalies: anomalies.length,
|
||||
highInputDays,
|
||||
highIoRatioDays,
|
||||
costSpikeDays,
|
||||
highCacheReadDays,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/usage/insights
|
||||
*
|
||||
* Returns anomaly detection results for usage patterns.
|
||||
* Query: ?since=YYYYMMDD&until=YYYYMMDD
|
||||
*/
|
||||
usageRoutes.get(
|
||||
'/insights',
|
||||
async (req: Request<object, object, object, UsageQuery>, res: Response) => {
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
|
||||
const dailyData = await getCachedDailyData();
|
||||
const filtered = filterByDateRange(dailyData, since, until);
|
||||
|
||||
const anomalies = detectAnomalies(filtered);
|
||||
const summary = summarizeAnomalies(anomalies);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
anomalies,
|
||||
summary,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 'Failed to fetch usage insights');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ANALYTICS INSIGHTS TYPES
|
||||
// ============================================================================
|
||||
|
||||
/** Token category with count and cost */
|
||||
export interface TokenCategoryCost {
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
/** Breakdown of tokens by type with individual costs */
|
||||
export interface TokenBreakdown {
|
||||
input: TokenCategoryCost;
|
||||
output: TokenCategoryCost;
|
||||
cacheCreation: TokenCategoryCost;
|
||||
cacheRead: TokenCategoryCost;
|
||||
}
|
||||
|
||||
/** Anomaly types for usage pattern detection */
|
||||
export type AnomalyType =
|
||||
| 'high_input' // >10M tokens/day/model
|
||||
| 'high_io_ratio' // >100x input/output ratio
|
||||
| 'cost_spike' // >2x daily average cost
|
||||
| 'high_cache_read'; // >1B cache read tokens
|
||||
|
||||
/** Single anomaly detection result */
|
||||
export interface Anomaly {
|
||||
date: string;
|
||||
type: AnomalyType;
|
||||
model?: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Summary of all detected anomalies */
|
||||
export interface AnomalySummary {
|
||||
totalAnomalies: number;
|
||||
highInputDays: number;
|
||||
highIoRatioDays: number;
|
||||
costSpikeDays: number;
|
||||
highCacheReadDays: number;
|
||||
}
|
||||
|
||||
/** Insights API response */
|
||||
export interface UsageInsights {
|
||||
anomalies: Anomaly[];
|
||||
summary: AnomalySummary;
|
||||
}
|
||||
|
||||
/** Extended model usage with cost breakdown */
|
||||
export interface ExtendedModelUsage {
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
percentage: number;
|
||||
costBreakdown: TokenBreakdown;
|
||||
ioRatio: number;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
@@ -228,6 +229,8 @@
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||
|
||||
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
@@ -792,6 +795,8 @@
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Cache Efficiency Card Component
|
||||
*
|
||||
* Displays cache usage metrics including hit rate, savings estimate,
|
||||
* and cache read/write breakdown.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Database, TrendingUp, Zap } from 'lucide-react';
|
||||
import type { UsageSummary } from '@/hooks/use-usage';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CacheEfficiencyCardProps {
|
||||
data: UsageSummary | undefined;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CacheEfficiencyCard({ data, isLoading, className }: CacheEfficiencyCardProps) {
|
||||
const metrics = useMemo(() => {
|
||||
if (!data) return null;
|
||||
|
||||
const totalCacheTokens = data.totalCacheCreationTokens + data.totalCacheReadTokens;
|
||||
const cacheHitRate =
|
||||
totalCacheTokens > 0 ? (data.totalCacheReadTokens / totalCacheTokens) * 100 : 0;
|
||||
|
||||
// Estimate savings: cache reads cost ~90% less than regular input
|
||||
// Savings = cacheReadTokens * (inputRate - cacheReadRate)
|
||||
const inputCost = data.tokenBreakdown.input.cost;
|
||||
const inputTokens = data.tokenBreakdown.input.tokens || 1;
|
||||
const cacheReadCost = data.tokenBreakdown.cacheRead.cost;
|
||||
const cacheReadTokens = data.tokenBreakdown.cacheRead.tokens || 1;
|
||||
|
||||
const inputRate = inputTokens > 0 ? inputCost / (inputTokens / 1_000_000) : 0;
|
||||
const cacheReadRate = cacheReadTokens > 0 ? cacheReadCost / (cacheReadTokens / 1_000_000) : 0;
|
||||
|
||||
const estimatedSavings =
|
||||
inputRate > 0 && cacheReadRate < inputRate
|
||||
? (data.totalCacheReadTokens / 1_000_000) * (inputRate - cacheReadRate)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
cacheHitRate,
|
||||
estimatedSavings: Math.max(0, estimatedSavings),
|
||||
totalCacheReads: data.totalCacheReadTokens,
|
||||
totalCacheWrites: data.totalCacheCreationTokens,
|
||||
totalCacheTokens,
|
||||
cacheCost: data.tokenBreakdown.cacheRead.cost + data.tokenBreakdown.cacheCreation.cost,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full', className)}>
|
||||
<CardHeader className="px-3 py-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!metrics || metrics.totalCacheTokens === 0) {
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full', className)}>
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
Cache Efficiency
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground text-center">No cache data available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full shadow-sm', className)}>
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Database className="w-4 h-4" />
|
||||
Cache Efficiency
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 flex flex-col justify-center gap-3">
|
||||
{/* Primary metric: Savings */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 text-emerald-600 dark:text-emerald-400">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
<span className="text-2xl font-bold">${metrics.estimatedSavings.toFixed(2)}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||
Estimated Savings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Secondary metrics row */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* Cache Hit Rate */}
|
||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Zap className="w-3.5 h-3.5 text-amber-500" />
|
||||
<span className="text-lg font-bold">{metrics.cacheHitRate.toFixed(0)}%</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Hit Rate</p>
|
||||
</div>
|
||||
|
||||
{/* Cache Cost */}
|
||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||
<span className="text-lg font-bold">${metrics.cacheCost.toFixed(2)}</span>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Cache Cost</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache breakdown bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>Reads: {formatCompact(metrics.totalCacheReads)}</span>
|
||||
<span>Writes: {formatCompact(metrics.totalCacheWrites)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: '#9e2a2b',
|
||||
width: `${(metrics.totalCacheReads / metrics.totalCacheTokens) * 100}%`,
|
||||
}}
|
||||
title={`Cache Reads: ${metrics.totalCacheReads.toLocaleString()}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: '#e09f3e',
|
||||
width: `${(metrics.totalCacheWrites / metrics.totalCacheTokens) * 100}%`,
|
||||
}}
|
||||
title={`Cache Writes: ${metrics.totalCacheWrites.toLocaleString()}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: '#9e2a2b' }} />
|
||||
Read
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: '#e09f3e' }} />
|
||||
Write
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCompact(num: number): string {
|
||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||
return num.toString();
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
|
||||
name: item.model,
|
||||
value: item.tokens,
|
||||
cost: item.cost,
|
||||
requests: item.requests,
|
||||
percentage: item.percentage,
|
||||
fill: getModelColor(item.model),
|
||||
}));
|
||||
@@ -47,18 +46,18 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
|
||||
if (!active || !payload) return null;
|
||||
|
||||
const payloadArray = payload as Array<{
|
||||
payload: { name: string; value: number; cost: number; requests: number; percentage: number };
|
||||
payload: { name: string; value: number; cost: number; percentage: number };
|
||||
}>;
|
||||
if (!payloadArray.length) return null;
|
||||
|
||||
const data = payloadArray[0].payload;
|
||||
const item = payloadArray[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-lg text-xs">
|
||||
<p className="font-medium mb-1">{data.name}</p>
|
||||
<p className="font-medium mb-1">{item.name}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{formatNumber(data.value)} ({data.percentage.toFixed(1)}%)
|
||||
{formatNumber(item.value)} ({item.percentage.toFixed(1)}%)
|
||||
</p>
|
||||
<p className="text-muted-foreground">${data.cost.toFixed(4)}</p>
|
||||
<p className="text-muted-foreground">${item.cost.toFixed(4)}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -77,8 +76,8 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderLabel}
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
innerRadius={50}
|
||||
outerRadius={70}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowDownRight, ArrowUpRight, Database, Gauge, Sparkles } from 'lucide-react';
|
||||
import type { ModelUsage } from '@/hooks/use-usage';
|
||||
|
||||
interface ModelDetailsContentProps {
|
||||
model: ModelUsage;
|
||||
}
|
||||
|
||||
export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
|
||||
const ioRatioStatus = getIoRatioStatus(model.ioRatio);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0" />
|
||||
<h4 className="font-semibold leading-none truncate" title={model.model}>
|
||||
{model.model}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="text-[10px] h-5 px-1.5">
|
||||
{model.percentage.toFixed(1)}% usage
|
||||
</Badge>
|
||||
<Badge variant={ioRatioStatus.variant} className="text-[10px] h-5 px-1.5">
|
||||
{model.ioRatio.toFixed(0)}:1 I/O
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||
<p className="text-lg font-bold">${model.cost.toFixed(2)}</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Total Cost</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||
<p className="text-lg font-bold">{formatCompactNumber(model.tokens)}</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Total Tokens</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Breakdown */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Token Breakdown
|
||||
</h5>
|
||||
<div className="space-y-1">
|
||||
<TokenRow
|
||||
label="Input"
|
||||
tokens={model.inputTokens}
|
||||
cost={model.costBreakdown.input.cost}
|
||||
color="#335c67"
|
||||
icon={ArrowDownRight}
|
||||
/>
|
||||
<TokenRow
|
||||
label="Output"
|
||||
tokens={model.outputTokens}
|
||||
cost={model.costBreakdown.output.cost}
|
||||
color="#fff3b0"
|
||||
icon={ArrowUpRight}
|
||||
/>
|
||||
<TokenRow
|
||||
label="Cache Write"
|
||||
tokens={model.cacheCreationTokens}
|
||||
cost={model.costBreakdown.cacheCreation.cost}
|
||||
color="#e09f3e"
|
||||
icon={Database}
|
||||
/>
|
||||
<TokenRow
|
||||
label="Cache Read"
|
||||
tokens={model.cacheReadTokens}
|
||||
cost={model.costBreakdown.cacheRead.cost}
|
||||
color="#9e2a2b"
|
||||
icon={Database}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* I/O Ratio Info */}
|
||||
<div className="p-2.5 rounded-md border bg-muted/20 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Input/Output Ratio</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
{ioRatioStatus.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TokenRowProps {
|
||||
label: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
color: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
function TokenRow({ label, tokens, cost, color, icon: Icon }: TokenRowProps) {
|
||||
if (tokens === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="w-1 h-6 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium truncate">{label}</span>
|
||||
<span className="font-mono text-muted-foreground">${cost.toFixed(3)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
<span>{formatNumber(tokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getIoRatioStatus(ratio: number): {
|
||||
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
description: string;
|
||||
} {
|
||||
if (ratio >= 200) {
|
||||
return {
|
||||
variant: 'destructive',
|
||||
description: 'Extended thinking or large context loading. Expected for reasoning models.',
|
||||
};
|
||||
}
|
||||
if (ratio >= 50) {
|
||||
return {
|
||||
variant: 'secondary',
|
||||
description: 'More input than output. Typical for analysis tasks.',
|
||||
};
|
||||
}
|
||||
if (ratio >= 5) {
|
||||
return {
|
||||
variant: 'outline',
|
||||
description: 'Balanced input/output ratio for typical coding tasks.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
variant: 'default',
|
||||
description: 'More output than input. Generation-heavy workload.',
|
||||
};
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
function formatCompactNumber(num: number): string {
|
||||
if (num >= 1000000000) return `${(num / 1000000000).toFixed(1)}B`;
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
||||
return num.toString();
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Session Stats Card Component
|
||||
*
|
||||
* Displays session usage metrics including active sessions, average duration,
|
||||
* and session cost breakdown.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Clock, Users, Zap, Terminal } from 'lucide-react';
|
||||
import type { PaginatedSessions } from '@/hooks/use-usage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface SessionStatsCardProps {
|
||||
data: PaginatedSessions | undefined;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SessionStatsCard({ data, isLoading, className }: SessionStatsCardProps) {
|
||||
const stats = useMemo(() => {
|
||||
if (!data?.sessions || data.sessions.length === 0) return null;
|
||||
|
||||
const sessions = data.sessions;
|
||||
const totalSessions = data.total;
|
||||
|
||||
// Calculate average tokens per session
|
||||
const totalTokens = sessions.reduce((sum, s) => sum + (s.inputTokens + s.outputTokens), 0);
|
||||
const avgTokens = Math.round(totalTokens / sessions.length);
|
||||
|
||||
// Calculate total cost for visible sessions
|
||||
const totalCost = sessions.reduce((sum, s) => sum + s.cost, 0);
|
||||
const avgCost = totalCost / sessions.length;
|
||||
|
||||
// Most recent session
|
||||
const lastSession = sessions[0];
|
||||
const lastActive = lastSession
|
||||
? formatDistanceToNow(new Date(lastSession.lastActivity), { addSuffix: true })
|
||||
: 'N/A';
|
||||
|
||||
return {
|
||||
totalSessions,
|
||||
avgTokens,
|
||||
avgCost,
|
||||
lastActive,
|
||||
recentSessions: sessions.slice(0, 3),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full', className)}>
|
||||
<CardHeader className="px-3 py-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full', className)}>
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Session Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground text-center">No session data available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full shadow-sm', className)}>
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Session Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 flex flex-col gap-4">
|
||||
{/* Key Metrics Grid */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* Total Sessions */}
|
||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 text-blue-600 dark:text-blue-400">
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-xl font-bold">{stats.totalSessions}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||
Total Sessions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Avg Cost */}
|
||||
<div className="p-2 rounded-md bg-muted/50 border text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 text-green-600 dark:text-green-400">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span className="text-xl font-bold">${stats.avgCost.toFixed(2)}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5">
|
||||
Avg Cost/Session
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity List */}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground font-medium mb-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Recent Activity
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{stats.recentSessions.map((session) => (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className="flex items-center justify-between text-xs p-1.5 rounded bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="font-medium truncate" title={session.projectPath}>
|
||||
{session.projectPath.split('/').pop()}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(session.lastActivity), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-2">
|
||||
<div className="font-mono">${session.cost.toFixed(2)}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{formatCompact(session.inputTokens + session.outputTokens)} toks
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCompact(num: number): string {
|
||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||
return num.toString();
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Sessions Table Component
|
||||
*
|
||||
* Displays session history with pagination and filtering.
|
||||
* Shows session duration, tokens, cost, and metadata.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ChevronLeft, ChevronRight, Search, Clock, Zap, DollarSign } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PaginatedSessions } from '@/hooks/use-usage';
|
||||
|
||||
interface SessionsTableProps {
|
||||
data?: PaginatedSessions;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function SessionsTable({ data, isLoading }: SessionsTableProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
// Get sessions array (stable reference for memoization)
|
||||
const sessions = useMemo(() => data?.sessions ?? [], [data?.sessions]);
|
||||
|
||||
// Filter sessions based on search term
|
||||
const filteredSessions = useMemo(() => {
|
||||
if (!searchTerm) return sessions;
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
return sessions.filter(
|
||||
(session) =>
|
||||
session.profile.toLowerCase().includes(term) ||
|
||||
session.model.toLowerCase().includes(term) ||
|
||||
session.id.toLowerCase().includes(term)
|
||||
);
|
||||
}, [sessions, searchTerm]);
|
||||
|
||||
// Pagination for filtered data
|
||||
const pageSize = 10;
|
||||
const paginatedSessions = useMemo(() => {
|
||||
if (!filteredSessions) return [];
|
||||
const start = currentPage * pageSize;
|
||||
return filteredSessions.slice(start, start + pageSize);
|
||||
}, [filteredSessions, currentPage]);
|
||||
|
||||
const totalPages = Math.ceil((filteredSessions?.length || 0) / pageSize);
|
||||
|
||||
if (isLoading) {
|
||||
return <SessionsTableSkeleton />;
|
||||
}
|
||||
|
||||
if (!data || data.sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Clock className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-1">No sessions found</h3>
|
||||
<p className="text-muted-foreground">Start using Claude Code to see session history</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search Bar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by profile, model, or session ID..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(0);
|
||||
}}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Session ID</TableHead>
|
||||
<TableHead>Profile</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead className="text-right">Tokens</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Requests</TableHead>
|
||||
<TableHead>Last Used</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedSessions.map((session) => (
|
||||
<TableRow key={session.id} className="hover:bg-muted/50">
|
||||
<TableCell className="font-mono text-xs">{session.id.slice(0, 8)}...</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{session.profile}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{session.model}</TableCell>
|
||||
<TableCell>{session.duration ? formatDuration(session.duration) : '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Zap className="h-3 w-3 text-muted-foreground" />
|
||||
{formatNumber(session.tokens)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<DollarSign className="h-3 w-3 text-muted-foreground" />$
|
||||
{session.cost.toFixed(4)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{session.requests}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(session.startTime), { addSuffix: true })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {currentPage * pageSize + 1} to{' '}
|
||||
{Math.min((currentPage + 1) * pageSize, filteredSessions?.length || 0)} of{' '}
|
||||
{filteredSessions?.length} sessions
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const page = i;
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn('w-8 h-8 p-0')}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
>
|
||||
{page + 1}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
// Skeleton loading state
|
||||
function SessionsTableSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Session ID</TableHead>
|
||||
<TableHead>Profile</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead className="text-right">Tokens</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Requests</TableHead>
|
||||
<TableHead>Last Used</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[60px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[80px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[60px]" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className="h-4 w-[80px]" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className="h-4 w-[70px]" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className="h-4 w-[60px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[80px]" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Token Breakdown Chart Component
|
||||
*
|
||||
* Displays token usage breakdown by type (input, output, cache).
|
||||
* Shows stacked bar chart with cost breakdown.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { TokenBreakdown } from '@/hooks/use-usage';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TokenBreakdownChartProps {
|
||||
data?: TokenBreakdown;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
input: '#3b82f6', // blue-500
|
||||
output: '#f97316', // orange-500
|
||||
cacheCreation: '#06b6d4', // cyan-500
|
||||
cacheRead: '#22c55e', // green-500
|
||||
};
|
||||
|
||||
export function TokenBreakdownChart({ data, isLoading, className }: TokenBreakdownChartProps) {
|
||||
const chartData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'Input',
|
||||
tokens: data.input.tokens,
|
||||
cost: data.input.cost,
|
||||
fill: COLORS.input,
|
||||
},
|
||||
{
|
||||
name: 'Output',
|
||||
tokens: data.output.tokens,
|
||||
cost: data.output.cost,
|
||||
fill: COLORS.output,
|
||||
},
|
||||
{
|
||||
name: 'Cache Write',
|
||||
tokens: data.cacheCreation.tokens,
|
||||
cost: data.cacheCreation.cost,
|
||||
fill: COLORS.cacheCreation,
|
||||
},
|
||||
{
|
||||
name: 'Cache Read',
|
||||
tokens: data.cacheRead.tokens,
|
||||
cost: data.cacheRead.cost,
|
||||
fill: COLORS.cacheRead,
|
||||
},
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
// Calculate totals for percentages
|
||||
const totals = useMemo(() => {
|
||||
const totalTokens = chartData.reduce((sum, d) => sum + d.tokens, 0);
|
||||
const totalCost = chartData.reduce((sum, d) => sum + d.cost, 0);
|
||||
return { totalTokens, totalCost };
|
||||
}, [chartData]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className={cn('h-[250px] w-full', className)} />;
|
||||
}
|
||||
|
||||
if (!data || chartData.every((d) => d.tokens === 0)) {
|
||||
return (
|
||||
<div className={cn('h-[250px] flex items-center justify-center', className)}>
|
||||
<p className="text-muted-foreground">No token data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 30, left: 70, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
/>
|
||||
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ className: 'stroke-muted' }}
|
||||
tickFormatter={(value) => formatNumber(value)}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ className: 'stroke-muted' }}
|
||||
width={60}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const item = payload[0].payload as (typeof chartData)[0];
|
||||
const tokenPercent =
|
||||
totals.totalTokens > 0
|
||||
? ((item.tokens / totals.totalTokens) * 100).toFixed(1)
|
||||
: '0';
|
||||
const costPercent =
|
||||
totals.totalCost > 0 ? ((item.cost / totals.totalCost) * 100).toFixed(1) : '0';
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{item.name}</p>
|
||||
<p className="text-sm">
|
||||
Tokens: {formatNumber(item.tokens)} ({tokenPercent}%)
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Cost: ${item.cost.toFixed(2)} ({costPercent}%)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Legend
|
||||
formatter={(value) => <span className="text-xs">{value}</span>}
|
||||
wrapperStyle={{ paddingTop: '10px' }}
|
||||
/>
|
||||
|
||||
<Bar dataKey="tokens" name="Tokens" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Cost breakdown summary */}
|
||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
{chartData.map((item) => (
|
||||
<div key={item.name} className="flex items-center gap-2 p-2 rounded-md bg-muted/50">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.fill }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{item.name}</p>
|
||||
<p className="text-muted-foreground">${item.cost.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000000) {
|
||||
return `${(num / 1000000000).toFixed(1)}B`;
|
||||
}
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CheckCircle2, Zap, Gauge, DollarSign, Database, Lightbulb } from 'lucide-react';
|
||||
import type { Anomaly, AnomalySummary, AnomalyType } from '@/hooks/use-usage';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface UsageInsightsCardProps {
|
||||
anomalies?: Anomaly[];
|
||||
summary?: AnomalySummary;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ANOMALY_CONFIG: Record<
|
||||
AnomalyType,
|
||||
{
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
> = {
|
||||
high_input: {
|
||||
icon: Zap,
|
||||
color: 'text-yellow-600 dark:text-yellow-400',
|
||||
label: 'High Input',
|
||||
description: 'Unusually high input token usage detected.',
|
||||
},
|
||||
high_io_ratio: {
|
||||
icon: Gauge,
|
||||
color: 'text-orange-600 dark:text-orange-400',
|
||||
label: 'High I/O Ratio',
|
||||
description: 'Output tokens are significantly higher than input tokens.',
|
||||
},
|
||||
cost_spike: {
|
||||
icon: DollarSign,
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
label: 'Cost Spike',
|
||||
description: 'Daily cost is significantly higher than average.',
|
||||
},
|
||||
high_cache_read: {
|
||||
icon: Database,
|
||||
color: 'text-cyan-600 dark:text-cyan-400',
|
||||
label: 'Heavy Caching',
|
||||
description: 'High volume of cache read operations.',
|
||||
},
|
||||
};
|
||||
|
||||
export function UsageInsightsCard({
|
||||
anomalies = [],
|
||||
summary,
|
||||
isLoading,
|
||||
className,
|
||||
}: UsageInsightsCardProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full', className)}>
|
||||
<CardHeader className="px-4 py-3 border-b">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Lightbulb className="w-4 h-4 text-muted-foreground" />
|
||||
Usage Insights
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 flex-1 flex items-center justify-center">
|
||||
<div className="animate-pulse flex flex-col items-center gap-2 opacity-50">
|
||||
<div className="h-8 w-8 bg-muted rounded-full" />
|
||||
<div className="h-4 w-32 bg-muted rounded" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnomalies = summary && summary.totalAnomalies > 0;
|
||||
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full overflow-hidden', className)}>
|
||||
<CardHeader className="px-4 py-3 border-b bg-muted/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Lightbulb
|
||||
className={cn('w-4 h-4', hasAnomalies ? 'text-amber-500' : 'text-green-500')}
|
||||
/>
|
||||
Usage Insights
|
||||
</CardTitle>
|
||||
{hasAnomalies ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="h-5 px-1.5 text-[10px] uppercase font-bold tracking-wider"
|
||||
>
|
||||
Attention Needed
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 text-[10px] uppercase font-bold tracking-wider text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
|
||||
>
|
||||
Healthy
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0 flex-1 min-h-0 flex flex-col">
|
||||
{hasAnomalies ? (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="divide-y">
|
||||
{anomalies.map((anomaly, index) => {
|
||||
const config = ANOMALY_CONFIG[anomaly.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div key={index} className="p-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 rounded-lg shrink-0 bg-muted/30',
|
||||
config.color
|
||||
.replace('text-', 'bg-')
|
||||
.replace('600', '100')
|
||||
.replace('400', '900/20')
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4', config.color)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium text-sm">{config.label}</p>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{anomaly.date}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{anomaly.message}
|
||||
</p>
|
||||
{anomaly.model && (
|
||||
<div className="pt-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1 py-0 h-5 font-mono"
|
||||
>
|
||||
{anomaly.model}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center text-muted-foreground">
|
||||
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<p className="font-medium text-foreground">No anomalies detected</p>
|
||||
<p className="text-xs mt-1 max-w-[200px]">
|
||||
Your usage patterns look normal for the selected period.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
* Usage Summary Cards Component
|
||||
*
|
||||
* Displays key metrics in a card grid layout.
|
||||
* Shows total tokens, cost, requests, and average tokens per request.
|
||||
* Shows total tokens, cost, cache tokens, and average cost per day.
|
||||
*/
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { TrendingUp, DollarSign, Zap, FileText } from 'lucide-react';
|
||||
import { DollarSign, Database, FileText, ArrowDownRight, ArrowUpRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UsageSummary } from '@/hooks/use-usage';
|
||||
|
||||
@@ -19,8 +19,8 @@ interface UsageSummaryCardsProps {
|
||||
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -37,6 +37,11 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate cache cost percentage
|
||||
const cacheCost =
|
||||
(data?.tokenBreakdown?.cacheCreation?.cost ?? 0) + (data?.tokenBreakdown?.cacheRead?.cost ?? 0);
|
||||
const cacheCostPercent = data?.totalCost ? Math.round((cacheCost / data.totalCost) * 100) : 0;
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total Tokens',
|
||||
@@ -45,6 +50,7 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
||||
format: (v: number) => formatNumber(v),
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
|
||||
subtitle: `${formatNumber(data?.totalInputTokens ?? 0)} in / ${formatNumber(data?.totalOutputTokens ?? 0)} out`,
|
||||
},
|
||||
{
|
||||
title: 'Total Cost',
|
||||
@@ -53,27 +59,39 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
||||
format: (v: number) => `$${v.toFixed(2)}`,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/20',
|
||||
subtitle: `$${data?.averageCostPerDay?.toFixed(2) ?? '0.00'}/day avg`,
|
||||
},
|
||||
{
|
||||
title: 'Total Requests',
|
||||
value: data?.totalRequests ?? 0,
|
||||
icon: Zap,
|
||||
title: 'Cache Tokens',
|
||||
value: data?.totalCacheTokens ?? 0,
|
||||
icon: Database,
|
||||
format: (v: number) => formatNumber(v),
|
||||
color: 'text-cyan-600',
|
||||
bgColor: 'bg-cyan-100 dark:bg-cyan-900/20',
|
||||
subtitle: `$${cacheCost.toFixed(2)} (${cacheCostPercent}% of cost)`,
|
||||
},
|
||||
{
|
||||
title: 'Input Cost',
|
||||
value: data?.tokenBreakdown?.input?.cost ?? 0,
|
||||
icon: ArrowDownRight,
|
||||
format: (v: number) => `$${v.toFixed(2)}`,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-100 dark:bg-purple-900/20',
|
||||
subtitle: `${formatNumber(data?.tokenBreakdown?.input?.tokens ?? 0)} tokens`,
|
||||
},
|
||||
{
|
||||
title: 'Avg Tokens/Request',
|
||||
value: data?.averageTokensPerRequest ?? 0,
|
||||
icon: TrendingUp,
|
||||
format: (v: number) => formatNumber(Math.round(v)),
|
||||
title: 'Output Cost',
|
||||
value: data?.tokenBreakdown?.output?.cost ?? 0,
|
||||
icon: ArrowUpRight,
|
||||
format: (v: number) => `$${v.toFixed(2)}`,
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-100 dark:bg-orange-900/20',
|
||||
subtitle: `${formatNumber(data?.tokenBreakdown?.output?.tokens ?? 0)} tokens`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{cards.map((card, index) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
@@ -83,6 +101,9 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground truncate">{card.title}</p>
|
||||
<p className="text-xl font-bold truncate">{card.format(card.value)}</p>
|
||||
{card.subtitle && (
|
||||
<p className="text-[10px] text-muted-foreground truncate">{card.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('p-2 rounded-lg shrink-0', card.bgColor)}>
|
||||
<Icon className={cn('h-4 w-4', card.color)} />
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
+90
-29
@@ -7,39 +7,88 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
// Types
|
||||
export interface TokenCategoryCost {
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface TokenBreakdown {
|
||||
input: TokenCategoryCost;
|
||||
output: TokenCategoryCost;
|
||||
cacheCreation: TokenCategoryCost;
|
||||
cacheRead: TokenCategoryCost;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
totalTokens: number;
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
totalCacheTokens: number;
|
||||
totalCacheCreationTokens: number;
|
||||
totalCacheReadTokens: number;
|
||||
totalCost: number;
|
||||
totalRequests: number;
|
||||
averageTokensPerRequest: number;
|
||||
dailyUsage: DailyUsage[];
|
||||
tokenBreakdown: TokenBreakdown;
|
||||
totalDays: number;
|
||||
averageTokensPerDay: number;
|
||||
averageCostPerDay: number;
|
||||
}
|
||||
|
||||
export interface DailyUsage {
|
||||
date: string;
|
||||
tokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: number;
|
||||
cost: number;
|
||||
requests: number;
|
||||
modelsUsed: number;
|
||||
}
|
||||
|
||||
export interface ModelUsage {
|
||||
model: string;
|
||||
tokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheTokens: number;
|
||||
cost: number;
|
||||
requests: number;
|
||||
percentage: number;
|
||||
costBreakdown: TokenBreakdown;
|
||||
ioRatio: number;
|
||||
}
|
||||
|
||||
export type AnomalyType = 'high_input' | 'high_io_ratio' | 'cost_spike' | 'high_cache_read';
|
||||
|
||||
export interface Anomaly {
|
||||
date: string;
|
||||
type: AnomalyType;
|
||||
model?: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AnomalySummary {
|
||||
totalAnomalies: number;
|
||||
highInputDays: number;
|
||||
highIoRatioDays: number;
|
||||
costSpikeDays: number;
|
||||
highCacheReadDays: number;
|
||||
}
|
||||
|
||||
export interface UsageInsights {
|
||||
anomalies: Anomaly[];
|
||||
summary: AnomalySummary;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
duration?: number;
|
||||
tokens: number;
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cost: number;
|
||||
requests: number;
|
||||
profile: string;
|
||||
model: string;
|
||||
lastActivity: string;
|
||||
modelsUsed: string[];
|
||||
}
|
||||
|
||||
export interface PaginatedSessions {
|
||||
@@ -132,6 +181,14 @@ export const usageApi = {
|
||||
},
|
||||
/** Get cache status including last fetch timestamp */
|
||||
status: () => request<UsageStatus>('/usage/status'),
|
||||
/** Get usage insights including anomaly detection */
|
||||
insights: (options?: UsageQueryOptions) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
||||
if (options?.profile) params.append('profile', options.profile);
|
||||
return request<UsageInsights>(`/usage/insights?${params}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Helper function to match existing API client pattern
|
||||
@@ -175,22 +232,6 @@ export function useModelUsage(options?: UsageQueryOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSessions(options?: UsageQueryOptions) {
|
||||
return useQuery({
|
||||
queryKey: ['usage', 'sessions', options],
|
||||
queryFn: () => usageApi.sessions(options),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonthlyUsage(months?: number, profile?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['usage', 'monthly', months, profile],
|
||||
queryFn: () => usageApi.monthly(months, profile),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to refresh all usage data
|
||||
* Clears server-side cache and invalidates React Query cache
|
||||
@@ -220,3 +261,23 @@ export function useUsageStatus() {
|
||||
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get usage insights with anomaly detection
|
||||
* Returns detected anomalies and summary statistics
|
||||
*/
|
||||
export function useUsageInsights(options?: UsageQueryOptions) {
|
||||
return useQuery({
|
||||
queryKey: ['usage', 'insights', options],
|
||||
queryFn: () => usageApi.insights(options),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useSessions(options?: UsageQueryOptions) {
|
||||
return useQuery({
|
||||
queryKey: ['usage', 'sessions', options],
|
||||
queryFn: () => usageApi.sessions(options),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
--popover: oklch(0.9635 0.0067 97.35); /* Match background */
|
||||
--popover-foreground: oklch(0.2 0.02 40); /* Match foreground */
|
||||
|
||||
--card: oklch(0.9635 0.0067 97.35); /* Match background */
|
||||
--card-foreground: oklch(0.2 0.02 40); /* Match foreground */
|
||||
|
||||
--destructive: oklch(0.577 0.245 27.325); /* Red 600 */
|
||||
--destructive-foreground: oklch(0.9635 0.0067 97.35); /* White */
|
||||
|
||||
/* Sidebar colors - Light */
|
||||
--sidebar: oklch(0.9635 0.0067 97.35); /* Pampas */
|
||||
--sidebar-foreground: oklch(0.2 0.02 40);
|
||||
@@ -69,6 +75,12 @@
|
||||
--popover: oklch(0.21 0.006 100); /* Match dark bg */
|
||||
--popover-foreground: oklch(0.9635 0.0067 97.35); /* Match dark fg */
|
||||
|
||||
--card: oklch(0.21 0.006 100); /* Match dark bg */
|
||||
--card-foreground: oklch(0.9635 0.0067 97.35); /* Match dark fg */
|
||||
|
||||
--destructive: oklch(0.396 0.141 25.723); /* Red 900 */
|
||||
--destructive-foreground: oklch(0.9635 0.0067 97.35); /* White */
|
||||
|
||||
/* Sidebar Dark Theme */
|
||||
--sidebar: oklch(0.21 0.006 100); /* Match bg */
|
||||
--sidebar-foreground: oklch(0.9635 0.0067 97.35);
|
||||
@@ -92,6 +104,14 @@
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
|
||||
+231
-180
@@ -1,34 +1,44 @@
|
||||
/**
|
||||
* Analytics Page
|
||||
*
|
||||
* Displays Claude Code usage analytics with charts and tables.
|
||||
* Features daily/monthly views, trend charts, model breakdown, and session history.
|
||||
* Displays Claude Code usage analytics with charts.
|
||||
* Features trend charts, model breakdown, cost analysis, and anomaly detection.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover';
|
||||
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
||||
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
|
||||
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
|
||||
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
|
||||
import { SessionsTable } from '@/components/analytics/sessions-table';
|
||||
import { TrendingUp, PieChart, Clock, Calendar, RefreshCw } from 'lucide-react';
|
||||
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
||||
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
||||
import { UsageInsightsCard } from '@/components/analytics/usage-insights-card';
|
||||
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
useUsageSummary,
|
||||
useUsageTrends,
|
||||
useModelUsage,
|
||||
useSessions,
|
||||
useRefreshUsage,
|
||||
useUsageStatus,
|
||||
useUsageInsights,
|
||||
useSessions,
|
||||
type ModelUsage,
|
||||
} from '@/hooks/use-usage';
|
||||
import { getModelColor } from '@/lib/utils';
|
||||
|
||||
type ViewMode = 'daily' | 'monthly' | 'sessions';
|
||||
// Format token count to human-readable (K/M/B)
|
||||
function formatTokens(num: number): string {
|
||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`;
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
export function AnalyticsPage() {
|
||||
// Default to last 30 days
|
||||
@@ -36,8 +46,10 @@ export function AnalyticsPage() {
|
||||
from: subDays(new Date(), 30),
|
||||
to: new Date(),
|
||||
});
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('daily');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<ModelUsage | null>(null);
|
||||
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const popoverAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Refresh hook
|
||||
const refreshUsage = useRefreshUsage();
|
||||
@@ -61,10 +73,8 @@ export function AnalyticsPage() {
|
||||
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
|
||||
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
|
||||
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
|
||||
const { data: sessions, isLoading: isSessionsLoading } = useSessions({
|
||||
...apiOptions,
|
||||
limit: 50,
|
||||
});
|
||||
const { data: insights, isLoading: isInsightsLoading } = useUsageInsights(apiOptions);
|
||||
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
|
||||
const { data: status } = useUsageStatus();
|
||||
|
||||
// Format "Last updated" text
|
||||
@@ -73,6 +83,18 @@ export function AnalyticsPage() {
|
||||
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
|
||||
}, [status?.lastFetch]);
|
||||
|
||||
// Handle model click for popover
|
||||
const handleModelClick = useCallback((model: ModelUsage, event: React.MouseEvent) => {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setPopoverPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
|
||||
setSelectedModel(model);
|
||||
}, []);
|
||||
|
||||
const handlePopoverClose = useCallback(() => {
|
||||
setSelectedModel(null);
|
||||
setPopoverPosition(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-3rem)] overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden p-4 pb-14 space-y-4">
|
||||
@@ -90,6 +112,7 @@ export function AnalyticsPage() {
|
||||
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
|
||||
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
|
||||
{ label: 'Month', range: { from: startOfMonth(new Date()), to: new Date() } },
|
||||
{ label: 'All Time', range: { from: undefined, to: new Date() } },
|
||||
]}
|
||||
/>
|
||||
{lastUpdatedText && (
|
||||
@@ -112,163 +135,191 @@ export function AnalyticsPage() {
|
||||
{/* Summary Cards */}
|
||||
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
|
||||
|
||||
{/* Main Content Tabs */}
|
||||
<Tabs
|
||||
value={viewMode}
|
||||
onValueChange={(v) => setViewMode(v as ViewMode)}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3 h-9 mb-4">
|
||||
<TabsTrigger value="daily" className="text-xs">
|
||||
Daily
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="monthly" className="text-xs">
|
||||
Monthly
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sessions" className="text-xs">
|
||||
Sessions
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col min-h-0 gap-3">
|
||||
{/* Usage Trend Chart - Full Width */}
|
||||
<Card className="flex flex-col flex-1 min-h-0 shadow-sm">
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Usage Trends
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
||||
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} className="h-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{/* Daily View */}
|
||||
<TabsContent value="daily" className="flex flex-col gap-4 m-0 h-full overflow-hidden">
|
||||
{/* Usage Trend Chart - Full Width */}
|
||||
<Card className="flex flex-col flex-1 min-h-0 shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Usage Trends
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
||||
<UsageTrendChart
|
||||
data={trends || []}
|
||||
isLoading={isTrendsLoading}
|
||||
className="h-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom Row - Model Usage & Cost */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 min-h-0">
|
||||
{/* Model Distribution */}
|
||||
<Card className="flex flex-col h-full min-h-0 shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<PieChart className="w-4 h-4" />
|
||||
Model Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
||||
<div className="flex w-full h-full items-center">
|
||||
<div className="flex-1 h-full min-w-0">
|
||||
<ModelBreakdownChart
|
||||
data={models || []}
|
||||
isLoading={isModelsLoading}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[220px] shrink-0 pl-4 space-y-2 overflow-y-auto max-h-full">
|
||||
{models?.slice(0, 8).map((model) => (
|
||||
<div key={model.model} className="flex items-center gap-2 text-xs">
|
||||
{/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-10 gap-3 flex-1 min-h-0">
|
||||
{/* Cost by Model - 4/10 width with breakdown */}
|
||||
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
Cost by Model
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 overflow-y-auto">
|
||||
{isModelsLoading ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{[...(models || [])]
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.map((model) => (
|
||||
<button
|
||||
key={model.model}
|
||||
className="group flex items-center text-xs w-full hover:bg-muted/50 rounded px-2 py-1.5 transition-colors cursor-pointer gap-3"
|
||||
onClick={(e) => handleModelClick(model, e)}
|
||||
title="Click for details"
|
||||
>
|
||||
{/* Model name */}
|
||||
<div className="flex items-center gap-2 min-w-0 w-[180px] shrink-0">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: getModelColor(model.model) }}
|
||||
/>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="flex justify-between items-baseline gap-2">
|
||||
<span className="font-medium truncate" title={model.model}>
|
||||
{model.model}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{model.percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-medium truncate group-hover:underline underline-offset-2">
|
||||
{model.model}
|
||||
</span>
|
||||
</div>
|
||||
{/* Cost breakdown mini-bar */}
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: '#335c67',
|
||||
width: `${model.cost > 0 ? (model.costBreakdown.input.cost / model.cost) * 100 : 0}%`,
|
||||
}}
|
||||
title={`Input: $${model.costBreakdown.input.cost.toFixed(2)}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: '#fff3b0',
|
||||
width: `${model.cost > 0 ? (model.costBreakdown.output.cost / model.cost) * 100 : 0}%`,
|
||||
}}
|
||||
title={`Output: $${model.costBreakdown.output.cost.toFixed(2)}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: '#e09f3e',
|
||||
width: `${model.cost > 0 ? (model.costBreakdown.cacheCreation.cost / model.cost) * 100 : 0}%`,
|
||||
}}
|
||||
title={`Cache Write: $${model.costBreakdown.cacheCreation.cost.toFixed(2)}`}
|
||||
/>
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
backgroundColor: '#9e2a2b',
|
||||
width: `${model.cost > 0 ? (model.costBreakdown.cacheRead.cost / model.cost) * 100 : 0}%`,
|
||||
}}
|
||||
title={`Cache Read: $${model.costBreakdown.cacheRead.cost.toFixed(2)}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Token count */}
|
||||
<span className="text-[10px] text-muted-foreground w-14 text-right shrink-0">
|
||||
{formatTokens(model.tokens)}
|
||||
</span>
|
||||
{/* Total cost */}
|
||||
<span className="font-mono font-medium w-16 text-right shrink-0">
|
||||
${model.cost.toFixed(2)}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 opacity-0 group-hover:opacity-50 transition-opacity shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-3 pt-2 px-2 text-[10px] text-muted-foreground border-t mt-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: '#335c67' }}
|
||||
/>
|
||||
Input
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full border border-muted-foreground/30"
|
||||
style={{ backgroundColor: '#fff3b0' }}
|
||||
/>
|
||||
Output
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: '#e09f3e' }}
|
||||
/>
|
||||
Cache Write
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: '#9e2a2b' }}
|
||||
/>
|
||||
Cache Read
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
<Card className="flex flex-col h-full min-h-0 shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cost by Model</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2 flex-1 min-h-0 overflow-y-auto">
|
||||
{isModelsLoading ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{[...(models || [])]
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.map((model) => (
|
||||
<div
|
||||
key={model.model}
|
||||
className="flex items-center justify-between text-xs border-b border-border/50 pb-2 last:border-0 last:pb-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: getModelColor(model.model) }}
|
||||
/>
|
||||
<span className="font-medium" title={model.model}>
|
||||
{model.model}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground whitespace-nowrap font-mono">
|
||||
${model.cost.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Monthly View */}
|
||||
<TabsContent value="monthly" className="m-0 h-full">
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Monthly Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
<UsageTrendChart
|
||||
data={trends || []}
|
||||
isLoading={isTrendsLoading}
|
||||
granularity="monthly"
|
||||
className="h-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Sessions View */}
|
||||
<TabsContent value="sessions" className="m-0 h-full">
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
Session History
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 flex-1 overflow-hidden">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<SessionsTable data={sessions} isLoading={isSessionsLoading} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Model Distribution - 2/10 width */}
|
||||
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-2">
|
||||
<CardHeader className="px-3 py-2">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<PieChart className="w-4 h-4" />
|
||||
Model Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
||||
<ModelBreakdownChart
|
||||
data={models || []}
|
||||
isLoading={isModelsLoading}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Session Stats - 2/10 width */}
|
||||
<SessionStatsCard
|
||||
data={sessions}
|
||||
isLoading={isSessionsLoading}
|
||||
className="lg:col-span-2"
|
||||
/>
|
||||
|
||||
{/* Usage Insights - 2/10 width */}
|
||||
<UsageInsightsCard
|
||||
anomalies={insights?.anomalies}
|
||||
summary={insights?.summary}
|
||||
isLoading={isInsightsLoading}
|
||||
className="lg:col-span-2"
|
||||
/>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
{/* Model Details Popover - positioned at cursor */}
|
||||
<Popover open={!!selectedModel} onOpenChange={(open) => !open && handlePopoverClose()}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
ref={popoverAnchorRef}
|
||||
className="fixed pointer-events-none"
|
||||
style={{
|
||||
left: popoverPosition?.x ?? 0,
|
||||
top: popoverPosition?.y ?? 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent className="w-80 p-3" side="top" align="center">
|
||||
{selectedModel && <ModelDetailsContent model={selectedModel} />}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -289,6 +340,26 @@ export function AnalyticsSkeleton() {
|
||||
|
||||
{/* Bottom Row Skeletons */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Cost Breakdown Skeleton */}
|
||||
<Card className="flex flex-col min-h-[250px]">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="w-2.5 h-2.5 rounded-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Model Usage Skeleton */}
|
||||
<Card className="flex flex-col min-h-[250px]">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
@@ -310,26 +381,6 @@ export function AnalyticsSkeleton() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cost Breakdown Skeleton */}
|
||||
<Card className="flex flex-col min-h-[250px]">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="w-2.5 h-2.5 rounded-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user