UNPKG

claude-usage-tracker

Version:

Advanced analytics for Claude Code usage with cost optimization, conversation length analysis, and rate limit tracking

298 lines 12.1 kB
import { endOfWeek, format, isWithinInterval, startOfWeek } from "date-fns"; import { BATCH_API_DISCOUNT, MODEL_PRICING, RATE_LIMITS, TOKENS_PER_HOUR_ESTIMATES, } from "./config.js"; export function calculateCost(entry) { if (entry.cost !== undefined) return entry.cost; if (entry.costUSD !== undefined) return entry.costUSD; const pricing = MODEL_PRICING[entry.model]; if (!pricing) return 0; const inputCost = (entry.prompt_tokens / 1_000_000) * pricing.input; const outputCost = (entry.completion_tokens / 1_000_000) * pricing.output; const cacheCreationCost = ((entry.cache_creation_input_tokens || 0) / 1_000_000) * pricing.input; const cacheReadCost = ((entry.cache_read_input_tokens || 0) / 1_000_000) * pricing.cached; let totalCost = inputCost + outputCost + cacheCreationCost + cacheReadCost; // Apply batch API discount if applicable if (entry.isBatchAPI) { totalCost *= 1 - BATCH_API_DISCOUNT; } return totalCost; } export function aggregateDailyUsage(entries) { const dailyMap = new Map(); for (const entry of entries) { const date = format(new Date(entry.timestamp), "yyyy-MM-dd"); if (!dailyMap.has(date)) { dailyMap.set(date, { date, totalTokens: 0, promptTokens: 0, completionTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0, conversationCount: 0, models: new Set(), }); } const daily = dailyMap.get(date); if (daily) { daily.totalTokens += entry.total_tokens; daily.promptTokens += entry.prompt_tokens; daily.completionTokens += entry.completion_tokens; daily.cacheCreationTokens += entry.cache_creation_input_tokens || 0; daily.cacheReadTokens += entry.cache_read_input_tokens || 0; daily.cost += calculateCost(entry); daily.models.add(entry.model); } } // Count unique conversations per day const conversationsByDay = new Map(); for (const entry of entries) { const date = format(new Date(entry.timestamp), "yyyy-MM-dd"); if (!conversationsByDay.has(date)) { conversationsByDay.set(date, new Set()); } conversationsByDay.get(date)?.add(entry.conversationId); } for (const [date, conversations] of conversationsByDay) { const daily = dailyMap.get(date); if (daily) { daily.conversationCount = conversations.size; } } return dailyMap; } export function calculateActualTokensPerHour(entries) { // Group entries by conversation and calculate session durations const conversations = new Map(); for (const entry of entries) { if (!conversations.has(entry.conversationId)) { conversations.set(entry.conversationId, { entries: [], startTime: new Date(entry.timestamp), endTime: new Date(entry.timestamp), }); } const conv = conversations.get(entry.conversationId); if (conv) { conv.entries.push(entry); const entryTime = new Date(entry.timestamp); if (entryTime < conv.startTime) conv.startTime = entryTime; if (entryTime > conv.endTime) conv.endTime = entryTime; } } let sonnet4TotalTokens = 0, sonnet4TotalHours = 0; let opus4TotalTokens = 0, opus4TotalHours = 0; for (const conv of conversations.values()) { const durationMs = conv.endTime.getTime() - conv.startTime.getTime(); const durationHours = Math.max(durationMs / (1000 * 60 * 60), 0.1); // Minimum 6 minutes const sonnet4Tokens = conv.entries .filter((e) => e.model.includes("sonnet")) .reduce((sum, e) => sum + e.total_tokens, 0); const opus4Tokens = conv.entries .filter((e) => e.model.includes("opus")) .reduce((sum, e) => sum + e.total_tokens, 0); if (sonnet4Tokens > 0) { sonnet4TotalTokens += sonnet4Tokens; sonnet4TotalHours += durationHours; } if (opus4Tokens > 0) { opus4TotalTokens += opus4Tokens; opus4TotalHours += durationHours; } } return { sonnet4: sonnet4TotalHours > 0 ? sonnet4TotalTokens / sonnet4TotalHours : TOKENS_PER_HOUR_ESTIMATES.sonnet4.min, opus4: opus4TotalHours > 0 ? opus4TotalTokens / opus4TotalHours : TOKENS_PER_HOUR_ESTIMATES.opus4.min, }; } export function getCurrentWeekUsage(entries) { const now = new Date(); const weekStart = startOfWeek(now, { weekStartsOn: 1 }); // Monday const weekEnd = endOfWeek(now, { weekStartsOn: 1 }); const weekEntries = entries.filter((entry) => isWithinInterval(new Date(entry.timestamp), { start: weekStart, end: weekEnd, })); const conversationIds = new Set(weekEntries.map((e) => e.conversationId)); const models = new Set(weekEntries.map((e) => e.model)); const totalTokens = weekEntries.reduce((sum, e) => sum + e.total_tokens, 0); const promptTokens = weekEntries.reduce((sum, e) => sum + e.prompt_tokens, 0); const completionTokens = weekEntries.reduce((sum, e) => sum + e.completion_tokens, 0); const cacheCreationTokens = weekEntries.reduce((sum, e) => sum + (e.cache_creation_input_tokens || 0), 0); const cacheReadTokens = weekEntries.reduce((sum, e) => sum + (e.cache_read_input_tokens || 0), 0); const cost = weekEntries.reduce((sum, e) => sum + calculateCost(e), 0); // Calculate actual tokens per hour from recent usage data (last 2 weeks) const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); const recentEntries = entries.filter((e) => new Date(e.timestamp) >= twoWeeksAgo); const actualRates = calculateActualTokensPerHour(recentEntries); // Get token counts for current week const sonnet4Tokens = weekEntries .filter((e) => e.model.includes("sonnet")) .reduce((sum, e) => sum + e.total_tokens, 0); const opus4Tokens = weekEntries .filter((e) => e.model.includes("opus")) .reduce((sum, e) => sum + e.total_tokens, 0); return { startDate: format(weekStart, "yyyy-MM-dd"), endDate: format(weekEnd, "yyyy-MM-dd"), totalTokens, promptTokens, completionTokens, cacheCreationTokens, cacheReadTokens, cost, conversationCount: conversationIds.size, models, estimatedHours: { sonnet4: { min: sonnet4Tokens / actualRates.sonnet4, max: sonnet4Tokens / actualRates.sonnet4, }, opus4: { min: opus4Tokens / actualRates.opus4, max: opus4Tokens / actualRates.opus4, }, }, }; } export function analyzeHourlyUsage(entries) { const hourlyMap = new Map(); // Initialize all 24 hours for (let hour = 0; hour < 24; hour++) { hourlyMap.set(hour, { hour, totalTokens: 0, cost: 0, conversationCount: 0, sonnetTokens: 0, opusTokens: 0, }); } const conversations = new Set(); for (const entry of entries) { const hour = new Date(entry.timestamp).getUTCHours(); const hourData = hourlyMap.get(hour); if (hourData) { hourData.totalTokens += entry.total_tokens; hourData.cost += calculateCost(entry); if (entry.model.includes("sonnet")) { hourData.sonnetTokens += entry.total_tokens; } else if (entry.model.includes("opus")) { hourData.opusTokens += entry.total_tokens; } // Track unique conversations per hour const convKey = `${hour}-${entry.conversationId}`; if (!conversations.has(convKey)) { conversations.add(convKey); hourData.conversationCount++; } } } return Array.from(hourlyMap.values()); } export function analyzeModelEfficiency(entries) { const modelMap = new Map(); for (const entry of entries) { if (!modelMap.has(entry.model)) { modelMap.set(entry.model, { totalTokens: 0, totalCost: 0, conversations: new Set(), }); } const modelData = modelMap.get(entry.model); if (modelData) { modelData.totalTokens += entry.total_tokens; modelData.totalCost += calculateCost(entry); modelData.conversations.add(entry.conversationId); } } return Array.from(modelMap.entries()).map(([model, data]) => ({ model, avgTokensPerConversation: data.totalTokens / data.conversations.size, avgCostPerConversation: data.totalCost / data.conversations.size, totalConversations: data.conversations.size, totalCost: data.totalCost, costPerToken: data.totalCost / data.totalTokens, })); } export function getEfficiencyInsights(entries) { const hourlyUsage = analyzeHourlyUsage(entries); const modelEfficiency = analyzeModelEfficiency(entries); // Find peak hours (top 3 highest usage hours) const peakHours = hourlyUsage .sort((a, b) => b.totalTokens - a.totalTokens) .slice(0, 3) .map((h) => h.hour) .sort((a, b) => a - b); // Calculate potential savings from smarter model usage const opusEfficiency = modelEfficiency.find((m) => m.model.includes("opus")); const sonnetEfficiency = modelEfficiency.find((m) => m.model.includes("sonnet")); let potentialSavings = 0; let recommendation = "Continue current usage patterns"; if (opusEfficiency && sonnetEfficiency) { const costDifference = opusEfficiency.costPerToken - sonnetEfficiency.costPerToken; // Assume 30% of Opus conversations could use Sonnet const opusTokens = entries .filter((e) => e.model.includes("opus")) .reduce((sum, e) => sum + e.total_tokens, 0); potentialSavings = opusTokens * 0.3 * costDifference; if (potentialSavings > 100) { // $100+ potential savings recommendation = `Consider using Sonnet 4 for simpler tasks. Could save ~${potentialSavings.toFixed(0)}/month by switching 30% of Opus conversations to Sonnet.`; } } return { hourlyUsage, peakHours, modelEfficiency, costSavingsOpportunity: { potentialSavings, recommendation, }, }; } export function getRateLimitInfo(weeklyUsage, plan) { const limits = RATE_LIMITS[plan]; return { plan, weeklyLimits: limits.weekly, currentUsage: weeklyUsage, percentUsed: { sonnet4: { min: (weeklyUsage.estimatedHours.sonnet4.min / limits.weekly.sonnet4.max) * 100, max: (weeklyUsage.estimatedHours.sonnet4.max / limits.weekly.sonnet4.min) * 100, }, opus4: { min: (weeklyUsage.estimatedHours.opus4.min / limits.weekly.opus4.max) * 100, max: (weeklyUsage.estimatedHours.opus4.max / limits.weekly.opus4.min) * 100, }, }, }; } export function calculateBatchAPISavings(entries) { const nonBatchEntries = entries.filter((e) => !e.isBatchAPI); let totalSavings = 0; for (const entry of nonBatchEntries) { const regularCost = calculateCost(entry); const batchEntry = { ...entry, isBatchAPI: true }; const batchCost = calculateCost(batchEntry); totalSavings += regularCost - batchCost; } return totalSavings; } //# sourceMappingURL=analyzer.js.map