From 9506327343a594816b5023caeddb11eef276fb82 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 9 Dec 2025 22:46:10 -0500 Subject: [PATCH 1/8] refactor(analytics): inline sessions table component --- .../components/analytics/sessions-table.tsx | 269 ------------------ ui/src/hooks/use-usage.ts | 16 -- ui/src/pages/analytics.tsx | 251 ++++++---------- 3 files changed, 90 insertions(+), 446 deletions(-) delete mode 100644 ui/src/components/analytics/sessions-table.tsx diff --git a/ui/src/components/analytics/sessions-table.tsx b/ui/src/components/analytics/sessions-table.tsx deleted file mode 100644 index 956ac4a6..00000000 --- a/ui/src/components/analytics/sessions-table.tsx +++ /dev/null @@ -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 ; - } - - if (!data || data.sessions.length === 0) { - return ( -
- -

No sessions found

-

Start using Claude Code to see session history

-
- ); - } - - return ( -
- {/* Search Bar */} -
-
- - { - setSearchTerm(e.target.value); - setCurrentPage(0); - }} - className="pl-8" - /> -
-
- - {/* Table */} -
- - - - Session ID - Profile - Model - Duration - Tokens - Cost - Requests - Last Used - - - - {paginatedSessions.map((session) => ( - - {session.id.slice(0, 8)}... - - {session.profile} - - {session.model} - {session.duration ? formatDuration(session.duration) : '-'} - -
- - {formatNumber(session.tokens)} -
-
- -
- $ - {session.cost.toFixed(4)} -
-
- {session.requests} - - {formatDistanceToNow(new Date(session.startTime), { addSuffix: true })} - -
- ))} -
-
-
- - {/* Pagination */} - {totalPages > 1 && ( -
-

- Showing {currentPage * pageSize + 1} to{' '} - {Math.min((currentPage + 1) * pageSize, filteredSessions?.length || 0)} of{' '} - {filteredSessions?.length} sessions -

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

+ + Detected Anomalies +

+

+ Unusual usage patterns detected in the selected period +

+
+ + {/* Summary badges */} +
+ {Array.from(anomalyTypes).map((type) => { + const config = ANOMALY_CONFIG[type]; + const Icon = config.icon; + const count = anomalies.filter((a) => a.type === type).length; + return ( + + + {count} {config.label} + + ); + })} +
+ + {/* Anomaly list */} +
+ {anomalies.slice(0, 10).map((anomaly, index) => { + const config = ANOMALY_CONFIG[anomaly.type]; + const Icon = config.icon; + + return ( +
+
+ +
+
+ {anomaly.date} + {anomaly.model && ( + + {truncateModel(anomaly.model)} + + )} +
+

{anomaly.message}

+
+
+
+ ); + })} + {anomalies.length > 10 && ( +
+ +{anomalies.length - 10} more anomalies +
+ )} +
+
+
+ ); +} + +function truncateModel(model: string): string { + if (model.length <= 20) return model; + // Try to extract the meaningful part + const parts = model.split('-'); + if (parts.length >= 3) { + return parts.slice(0, 3).join('-') + '...'; + } + return model.slice(0, 17) + '...'; +} diff --git a/ui/src/components/analytics/cache-efficiency-card.tsx b/ui/src/components/analytics/cache-efficiency-card.tsx new file mode 100644 index 00000000..eb04bff4 --- /dev/null +++ b/ui/src/components/analytics/cache-efficiency-card.tsx @@ -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 ( + + + + + + + + + ); + } + + if (!metrics || metrics.totalCacheTokens === 0) { + return ( + + + + + Cache Efficiency + + + +

No cache data available

+
+
+ ); + } + + return ( + + + + + Cache Efficiency + + + + {/* Primary metric: Savings */} +
+
+ + ${metrics.estimatedSavings.toFixed(2)} +
+

+ Estimated Savings +

+
+ + {/* Secondary metrics row */} +
+ {/* Cache Hit Rate */} +
+
+ + {metrics.cacheHitRate.toFixed(0)}% +
+

Hit Rate

+
+ + {/* Cache Cost */} +
+ ${metrics.cacheCost.toFixed(2)} +

Cache Cost

+
+
+ + {/* Cache breakdown bar */} +
+
+ Reads: {formatCompact(metrics.totalCacheReads)} + Writes: {formatCompact(metrics.totalCacheWrites)} +
+
+
+
+
+
+ +
+ Read + + +
+ Write + +
+
+ + + ); +} + +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(); +} diff --git a/ui/src/components/analytics/model-breakdown-chart.tsx b/ui/src/components/analytics/model-breakdown-chart.tsx index e96045fe..9ef39a0e 100644 --- a/ui/src/components/analytics/model-breakdown-chart.tsx +++ b/ui/src/components/analytics/model-breakdown-chart.tsx @@ -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 (
-

{data.name}

+

{item.name}

- {formatNumber(data.value)} ({data.percentage.toFixed(1)}%) + {formatNumber(item.value)} ({item.percentage.toFixed(1)}%)

-

${data.cost.toFixed(4)}

+

${item.cost.toFixed(4)}

); }; @@ -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" > diff --git a/ui/src/components/analytics/model-details-content.tsx b/ui/src/components/analytics/model-details-content.tsx new file mode 100644 index 00000000..2b813283 --- /dev/null +++ b/ui/src/components/analytics/model-details-content.tsx @@ -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 ( +
+ {/* Header */} +
+
+ +

+ {model.model} +

+
+
+ + {model.percentage.toFixed(1)}% usage + + + {model.ioRatio.toFixed(0)}:1 I/O + +
+
+ + {/* Stats Grid */} +
+
+

${model.cost.toFixed(2)}

+

Total Cost

+
+
+

{formatCompactNumber(model.tokens)}

+

Total Tokens

+
+
+ + {/* Token Breakdown */} +
+
+ Token Breakdown +
+
+ + + + +
+
+ + {/* I/O Ratio Info */} +
+
+ + Input/Output Ratio +
+

+ {ioRatioStatus.description} +

+
+
+ ); +} + +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 ( +
+
+
+
+ {label} + ${cost.toFixed(3)} +
+
+ + {formatNumber(tokens)} +
+
+
+ ); +} + +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(); +} diff --git a/ui/src/components/analytics/session-stats-card.tsx b/ui/src/components/analytics/session-stats-card.tsx new file mode 100644 index 00000000..2637467d --- /dev/null +++ b/ui/src/components/analytics/session-stats-card.tsx @@ -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 ( + + + + + + + + + ); + } + + if (!stats) { + return ( + + + + + Session Stats + + + +

No session data available

+
+
+ ); + } + + return ( + + + + + Session Stats + + + + {/* Key Metrics Grid */} +
+ {/* Total Sessions */} +
+
+ + {stats.totalSessions} +
+

+ Total Sessions +

+
+ + {/* Avg Cost */} +
+
+ + ${stats.avgCost.toFixed(2)} +
+

+ Avg Cost/Session +

+
+
+ + {/* Recent Activity List */} +
+
+ + Recent Activity +
+
+ {stats.recentSessions.map((session) => ( +
+
+ + {session.projectPath.split('/').pop()} + + + {formatDistanceToNow(new Date(session.lastActivity), { addSuffix: true })} + +
+
+
${session.cost.toFixed(2)}
+
+ {formatCompact(session.inputTokens + session.outputTokens)} toks +
+
+
+ ))} +
+
+
+
+ ); +} + +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(); +} diff --git a/ui/src/components/analytics/token-breakdown-chart.tsx b/ui/src/components/analytics/token-breakdown-chart.tsx new file mode 100644 index 00000000..01f90913 --- /dev/null +++ b/ui/src/components/analytics/token-breakdown-chart.tsx @@ -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 ; + } + + if (!data || chartData.every((d) => d.tokens === 0)) { + return ( +
+

No token data available

+
+ ); + } + + return ( +
+ + + + + formatNumber(value)} + /> + + + + { + 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 ( +
+

{item.name}

+

+ Tokens: {formatNumber(item.tokens)} ({tokenPercent}%) +

+

+ Cost: ${item.cost.toFixed(2)} ({costPercent}%) +

+
+ ); + }} + /> + + {value}} + wrapperStyle={{ paddingTop: '10px' }} + /> + + +
+
+ + {/* Cost breakdown summary */} +
+ {chartData.map((item) => ( +
+
+
+

{item.name}

+

${item.cost.toFixed(2)}

+
+
+ ))} +
+
+ ); +} + +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(); +} diff --git a/ui/src/components/analytics/usage-summary-cards.tsx b/ui/src/components/analytics/usage-summary-cards.tsx index 9be107c5..cde345f2 100644 --- a/ui/src/components/analytics/usage-summary-cards.tsx +++ b/ui/src/components/analytics/usage-summary-cards.tsx @@ -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 ( -
- {[1, 2, 3, 4].map((i) => ( +
+ {[1, 2, 3, 4, 5].map((i) => (
@@ -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 ( -
+
{cards.map((card, index) => { const Icon = card.icon; return ( @@ -83,6 +101,9 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {

{card.title}

{card.format(card.value)}

+ {card.subtitle && ( +

{card.subtitle}

+ )}
diff --git a/ui/src/components/ui/popover.tsx b/ui/src/components/ui/popover.tsx new file mode 100644 index 00000000..07b74e1a --- /dev/null +++ b/ui/src/components/ui/popover.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts index 69dfe88e..3bcbde88 100644 --- a/ui/src/hooks/use-usage.ts +++ b/ui/src/hooks/use-usage.ts @@ -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('/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(`/usage/insights?${params}`); + }, }; // Helper function to match existing API client pattern @@ -204,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 + }); +} diff --git a/ui/src/index.css b/ui/src/index.css index 1cdd21ed..1fcc98c7 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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); diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index 6f102607..0605c248 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -2,29 +2,44 @@ * Analytics Page * * Displays Claude Code usage analytics with charts. - * Features trend charts, model breakdown, and cost analysis. + * 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 { 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 { TrendingUp, PieChart, RefreshCw } from 'lucide-react'; +import { ModelDetailsContent } from '@/components/analytics/model-details-content'; +import { SessionStatsCard } from '@/components/analytics/session-stats-card'; +import { AnomalyAlertBadge } from '@/components/analytics/anomaly-alert-badge'; +import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react'; import { useUsageSummary, useUsageTrends, useModelUsage, useRefreshUsage, useUsageStatus, + useUsageInsights, + useSessions, + type ModelUsage, } from '@/hooks/use-usage'; import { getModelColor } from '@/lib/utils'; +// 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 const [dateRange, setDateRange] = useState({ @@ -32,6 +47,9 @@ export function AnalyticsPage() { to: new Date(), }); const [isRefreshing, setIsRefreshing] = useState(false); + const [selectedModel, setSelectedModel] = useState(null); + const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null); + const popoverAnchorRef = useRef(null); // Refresh hook const refreshUsage = useRefreshUsage(); @@ -55,6 +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: insights, isLoading: isInsightsLoading } = useUsageInsights(apiOptions); + const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 }); const { data: status } = useUsageStatus(); // Format "Last updated" text @@ -63,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 (
@@ -73,6 +105,10 @@ export function AnalyticsPage() {

Track usage & insights

+ {/* Anomaly Alert Badge */} + {!isInsightsLoading && insights && ( + + )} {/* Main Content */} -
+
{/* Usage Trend Chart - Full Width */} - - + + Usage Trends - + - {/* Bottom Row - Model Usage & Cost */} -
- {/* Model Distribution */} - - - - - Model Usage + {/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (4) */} +
+ {/* Cost by Model - 4/10 width with breakdown */} + + + + + Cost by Model - -
-
- -
-
- {models?.slice(0, 8).map((model) => ( -
-
-
-
- - {model.model} - - - {model.percentage.toFixed(1)}% - -
-
-
- ))} -
-
- - - - {/* Cost Breakdown */} - - - Cost by Model - - + {isModelsLoading ? ( ) : ( -
+
{[...(models || [])] .sort((a, b) => b.cost - a.cost) .map((model) => ( -
handleModelClick(model, e)} + title="Click for details" > -
+ {/* Model name */} +
- + {model.model}
- - ${model.cost.toFixed(4)} + {/* Cost breakdown mini-bar */} +
+
+
0 ? (model.costBreakdown.input.cost / model.cost) * 100 : 0}%`, + }} + title={`Input: $${model.costBreakdown.input.cost.toFixed(2)}`} + /> +
0 ? (model.costBreakdown.output.cost / model.cost) * 100 : 0}%`, + }} + title={`Output: $${model.costBreakdown.output.cost.toFixed(2)}`} + /> +
0 ? (model.costBreakdown.cacheCreation.cost / model.cost) * 100 : 0}%`, + }} + title={`Cache Write: $${model.costBreakdown.cacheCreation.cost.toFixed(2)}`} + /> +
0 ? (model.costBreakdown.cacheRead.cost / model.cost) * 100 : 0}%`, + }} + title={`Cache Read: $${model.costBreakdown.cacheRead.cost.toFixed(2)}`} + /> +
+
+ {/* Token count */} + + {formatTokens(model.tokens)} -
+ {/* Total cost */} + + ${model.cost.toFixed(2)} + + + ))} + {/* Legend */} +
+ +
+ Input + + +
+ Output + + +
+ Cache Write + + +
+ Cache Read + +
)} + + {/* Model Distribution - 2/10 width */} + + + + + Model Usage + + + + + + + + {/* Session Stats - 4/10 width */} +
+ + {/* Model Details Popover - positioned at cursor */} + !open && handlePopoverClose()}> + +
+ + + {selectedModel && } + +
@@ -218,6 +336,26 @@ export function AnalyticsSkeleton() { {/* Bottom Row Skeletons */}
+ {/* Cost Breakdown Skeleton */} + + + + + +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+ + +
+ +
+ ))} +
+
+
+ {/* Model Usage Skeleton */} @@ -239,26 +377,6 @@ export function AnalyticsSkeleton() {
- - {/* Cost Breakdown Skeleton */} - - - - - -
- {[1, 2, 3, 4, 5].map((i) => ( -
-
- - -
- -
- ))} -
-
-
); From aa1e9323a6a576d9f508aba3f3a7eaad684af763 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 10 Dec 2025 07:39:14 +0000 Subject: [PATCH 6/8] chore(release): 5.13.0-dev.3 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index ae3ea690..7082a002 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.13.0-dev.2 +5.13.0-dev.3 diff --git a/package.json b/package.json index 19f1c2fe..fae27f39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.13.0-dev.2", + "version": "5.13.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 824c3baecfb7795f848909240b95bfeb9e6c1b87 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 02:45:22 -0500 Subject: [PATCH 7/8] feat(ui): replace anomaly alert badge with usage insights card --- .../analytics/anomaly-alert-badge.tsx | 128 ------------- .../analytics/usage-insights-card.tsx | 168 ++++++++++++++++++ ui/src/pages/analytics.tsx | 18 +- 3 files changed, 179 insertions(+), 135 deletions(-) delete mode 100644 ui/src/components/analytics/anomaly-alert-badge.tsx create mode 100644 ui/src/components/analytics/usage-insights-card.tsx diff --git a/ui/src/components/analytics/anomaly-alert-badge.tsx b/ui/src/components/analytics/anomaly-alert-badge.tsx deleted file mode 100644 index b1eabb46..00000000 --- a/ui/src/components/analytics/anomaly-alert-badge.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Anomaly Alert Badge Component - * - * Displays detected usage anomalies with visual indicators. - * Shows high input, I/O ratio, cost spikes, and cache read alerts. - */ - -import { useState } from 'react'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { AlertTriangle, ChevronDown, Zap, Gauge, DollarSign, Database } from 'lucide-react'; -import type { Anomaly, AnomalySummary, AnomalyType } from '@/hooks/use-usage'; -import { cn } from '@/lib/utils'; - -interface AnomalyAlertBadgeProps { - anomalies: Anomaly[]; - summary: AnomalySummary; - className?: string; -} - -const ANOMALY_CONFIG: Record< - AnomalyType, - { icon: React.ComponentType<{ className?: string }>; color: string; label: string } -> = { - high_input: { icon: Zap, color: 'text-yellow-600', label: 'High Input' }, - high_io_ratio: { icon: Gauge, color: 'text-orange-600', label: 'High I/O Ratio' }, - cost_spike: { icon: DollarSign, color: 'text-red-600', label: 'Cost Spike' }, - high_cache_read: { icon: Database, color: 'text-cyan-600', label: 'Heavy Caching' }, -}; - -export function AnomalyAlertBadge({ anomalies, summary, className }: AnomalyAlertBadgeProps) { - const [open, setOpen] = useState(false); - - if (summary.totalAnomalies === 0) { - return ( - - No anomalies - - ); - } - - // Get unique anomaly types for badges - const anomalyTypes = new Set(anomalies.map((a) => a.type)); - - return ( - - - - - -
-

- - Detected Anomalies -

-

- Unusual usage patterns detected in the selected period -

-
- - {/* Summary badges */} -
- {Array.from(anomalyTypes).map((type) => { - const config = ANOMALY_CONFIG[type]; - const Icon = config.icon; - const count = anomalies.filter((a) => a.type === type).length; - return ( - - - {count} {config.label} - - ); - })} -
- - {/* Anomaly list */} -
- {anomalies.slice(0, 10).map((anomaly, index) => { - const config = ANOMALY_CONFIG[anomaly.type]; - const Icon = config.icon; - - return ( -
-
- -
-
- {anomaly.date} - {anomaly.model && ( - - {truncateModel(anomaly.model)} - - )} -
-

{anomaly.message}

-
-
-
- ); - })} - {anomalies.length > 10 && ( -
- +{anomalies.length - 10} more anomalies -
- )} -
-
-
- ); -} - -function truncateModel(model: string): string { - if (model.length <= 20) return model; - // Try to extract the meaningful part - const parts = model.split('-'); - if (parts.length >= 3) { - return parts.slice(0, 3).join('-') + '...'; - } - return model.slice(0, 17) + '...'; -} diff --git a/ui/src/components/analytics/usage-insights-card.tsx b/ui/src/components/analytics/usage-insights-card.tsx new file mode 100644 index 00000000..b7b48585 --- /dev/null +++ b/ui/src/components/analytics/usage-insights-card.tsx @@ -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 ( + + + + + Usage Insights + + + +
+
+
+
+ + + ); + } + + const hasAnomalies = summary && summary.totalAnomalies > 0; + + return ( + + +
+ + + Usage Insights + + {hasAnomalies ? ( + + Attention Needed + + ) : ( + + Healthy + + )} +
+
+ + + {hasAnomalies ? ( + +
+ {anomalies.map((anomaly, index) => { + const config = ANOMALY_CONFIG[anomaly.type]; + const Icon = config.icon; + + return ( +
+
+
+ +
+
+
+

{config.label}

+ + {anomaly.date} + +
+

+ {anomaly.message} +

+ {anomaly.model && ( +
+ + {anomaly.model} + +
+ )} +
+
+
+ ); + })} +
+
+ ) : ( +
+
+ +
+

No anomalies detected

+

+ Your usage patterns look normal for the selected period. +

+
+ )} +
+
+ ); +} diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx index 0605c248..f0f03dc1 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -18,7 +18,7 @@ import { UsageTrendChart } from '@/components/analytics/usage-trend-chart'; import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart'; import { ModelDetailsContent } from '@/components/analytics/model-details-content'; import { SessionStatsCard } from '@/components/analytics/session-stats-card'; -import { AnomalyAlertBadge } from '@/components/analytics/anomaly-alert-badge'; +import { UsageInsightsCard } from '@/components/analytics/usage-insights-card'; import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react'; import { useUsageSummary, @@ -105,10 +105,6 @@ export function AnalyticsPage() {

Track usage & insights

- {/* Anomaly Alert Badge */} - {!isInsightsLoading && insights && ( - - )} - {/* Session Stats - 4/10 width */} + {/* Session Stats - 2/10 width */} + + {/* Usage Insights - 2/10 width */} +
From 2b26de60a55f3832510b0f71841450f4af98c3f7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 10 Dec 2025 08:00:50 +0000 Subject: [PATCH 8/8] chore(release): 5.13.0-dev.4 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 7082a002..6d213e10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.13.0-dev.3 +5.13.0-dev.4 diff --git a/package.json b/package.json index fae27f39..17eef2dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.13.0-dev.3", + "version": "5.13.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",