UNPKG

claude-usage-tracker

Version:

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

416 lines 19.7 kB
import { calculateCost } from "./analyzer.js"; export class OptimizationAnalyzer { BATCH_API_DISCOUNT = 0.5; // 50% discount MIN_BATCH_COST = 0.001; // Minimum cost to consider for batch processing clusterConversations(entries) { const conversations = this.groupByConversation(entries); const clusters = new Map(); for (const [conversationId, convEntries] of conversations) { const clusterType = this.classifyConversationType(convEntries); const cost = convEntries.reduce((sum, e) => sum + calculateCost(e), 0); const tokens = convEntries.reduce((sum, e) => sum + e.total_tokens, 0); const duration = this.calculateConversationDuration(convEntries); const efficiency = tokens / Math.max(cost, 0.001); if (!clusters.has(clusterType)) { clusters.set(clusterType, { id: clusterType, type: clusterType, conversations: [], avgCost: 0, avgTokens: 0, avgEfficiency: 0, optimizationPotential: 0, recommendations: [], }); } const cluster = clusters.get(clusterType); if (!cluster) continue; cluster.conversations.push({ conversationId, cost, tokens, duration, efficiency, }); } // Calculate cluster statistics and optimization potential for (const cluster of clusters.values()) { this.calculateClusterStats(cluster); this.generateClusterRecommendations(cluster); } return Array.from(clusters.values()).sort((a, b) => b.optimizationPotential - a.optimizationPotential); } identifyBatchProcessingOpportunities(entries) { const conversations = this.groupByConversation(entries); const opportunities = []; for (const [conversationId, convEntries] of conversations) { const currentCost = convEntries.reduce((sum, e) => sum + calculateCost(e), 0); // Skip if cost is too low to matter if (currentCost < this.MIN_BATCH_COST) continue; const batchCost = currentCost * (1 - this.BATCH_API_DISCOUNT); const savings = currentCost - batchCost; const eligibilityScore = this.calculateBatchEligibility(convEntries); // Only include if there's meaningful savings and reasonable eligibility if (savings > 0.001 && eligibilityScore > 0.4) { opportunities.push({ conversationId, currentCost, batchCost, savings, eligibilityScore, reasoning: this.getBatchEligibilityReasoning(convEntries, eligibilityScore), timeToProcess: this.estimateBatchProcessingTime(convEntries), }); } } const sortedOpportunities = opportunities.sort((a, b) => b.savings - a.savings); return { opportunities: sortedOpportunities, totalPotentialSavings: sortedOpportunities.reduce((sum, opp) => sum + opp.savings, 0), }; } generateModelSwitchingRecommendations(entries) { const conversations = this.groupByConversation(entries); const recommendations = []; for (const [conversationId, convEntries] of conversations) { const currentModel = convEntries[0].model; const currentCost = convEntries.reduce((sum, e) => sum + calculateCost(e), 0); // Skip very small conversations if (currentCost < 0.001) continue; const recommendation = this.analyzeModelSwitch(conversationId, convEntries, currentModel, currentCost); if (recommendation) { recommendations.push(recommendation); } } const sortedRecommendations = recommendations.sort((a, b) => Math.abs(b.savings) - Math.abs(a.savings)); return { recommendations: sortedRecommendations, totalPotentialSavings: sortedRecommendations .filter((r) => r.savings > 0) .reduce((sum, r) => sum + r.savings, 0), }; } generateOptimizationSummary(entries) { const batchOpportunities = this.identifyBatchProcessingOpportunities(entries); const modelRecommendations = this.generateModelSwitchingRecommendations(entries); const clusters = this.clusterConversations(entries); const batchProcessingSavings = batchOpportunities.totalPotentialSavings; const modelSwitchingSavings = modelRecommendations.totalPotentialSavings; const efficiencyImprovements = clusters.reduce((sum, cluster) => sum + cluster.optimizationPotential, 0); const recommendations = [ ...batchOpportunities.opportunities.slice(0, 3).map((opp) => ({ type: "batch", description: `Use Batch API for conversation ${opp.conversationId.slice(-8)}`, savings: opp.savings, effort: "low", })), ...modelRecommendations.recommendations .slice(0, 3) .filter((rec) => rec.savings > 0) .map((rec) => ({ type: "model_switch", description: `Switch to ${rec.recommendedModel.includes("sonnet") ? "Sonnet" : "Opus"} for conversation ${rec.conversationId.slice(-8)}`, savings: rec.savings, effort: rec.riskLevel === "low" ? "low" : "medium", })), ...clusters.slice(0, 2).map((cluster) => ({ type: "efficiency", description: `Optimize ${cluster.type} workflow patterns`, savings: cluster.optimizationPotential, effort: "medium", })), ]; return { totalPotentialSavings: batchProcessingSavings + modelSwitchingSavings + efficiencyImprovements, batchProcessingSavings, modelSwitchingSavings, efficiencyImprovements, recommendations: recommendations .sort((a, b) => b.savings - a.savings) .slice(0, 5), }; } groupByConversation(entries) { const conversations = new Map(); for (const entry of entries) { if (!conversations.has(entry.conversationId)) { conversations.set(entry.conversationId, []); } conversations.get(entry.conversationId)?.push(entry); } return conversations; } classifyConversationType(entries) { const totalTokens = entries.reduce((sum, e) => sum + e.total_tokens, 0); const avgTokensPerMessage = totalTokens / entries.length; const conversationLength = entries.length; const cost = entries.reduce((sum, e) => sum + calculateCost(e), 0); // Classification based on patterns if (conversationLength > 20 && avgTokensPerMessage > 3000) { return "coding"; } else if (cost > 2.0 && conversationLength > 10) { return "analysis"; } else if (conversationLength > 15 && avgTokensPerMessage < 2000) { return "writing"; } else if (conversationLength > 5 && avgTokensPerMessage > 4000) { return "debugging"; } else { return "other"; } } calculateConversationDuration(entries) { if (entries.length < 2) return 0; const sortedEntries = [...entries].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); const start = new Date(sortedEntries[0].timestamp); const end = new Date(sortedEntries[sortedEntries.length - 1].timestamp); return (end.getTime() - start.getTime()) / (1000 * 60); // Duration in minutes } calculateClusterStats(cluster) { const conversations = cluster.conversations; cluster.avgCost = conversations.reduce((sum, conv) => sum + conv.cost, 0) / conversations.length; cluster.avgTokens = conversations.reduce((sum, conv) => sum + conv.tokens, 0) / conversations.length; cluster.avgEfficiency = conversations.reduce((sum, conv) => sum + conv.efficiency, 0) / conversations.length; // Calculate optimization potential based on efficiency variance const efficiencies = conversations.map((conv) => conv.efficiency); const maxEfficiency = Math.max(...efficiencies); const currentAvgEfficiency = cluster.avgEfficiency; // Potential savings if all conversations reached max efficiency const improvementFactor = maxEfficiency / Math.max(currentAvgEfficiency, 1); const totalCost = conversations.reduce((sum, conv) => sum + conv.cost, 0); cluster.optimizationPotential = totalCost * (1 - 1 / improvementFactor); } generateClusterRecommendations(cluster) { const recommendations = []; if (cluster.type === "coding" && cluster.avgCost > 1.0) { recommendations.push("Consider breaking down large coding tasks into smaller iterations"); } if (cluster.type === "analysis" && cluster.avgEfficiency < 5000) { recommendations.push("Try providing more structured prompts for analysis tasks"); } if (cluster.type === "debugging" && cluster.conversations.length > 10) { recommendations.push("Create debugging templates to improve efficiency"); } if (cluster.avgCost > 2.0) { recommendations.push("Monitor for opportunities to use Sonnet instead of Opus"); } cluster.recommendations = recommendations; } calculateBatchEligibility(entries) { let score = 0.4; // Base eligibility score // Check for complexity - high token usage reduces batch eligibility const avgTokens = entries.reduce((sum, e) => sum + e.total_tokens, 0) / entries.length; if (avgTokens > 8000) score -= 0.5; // High complexity conversations less suitable for batching if (avgTokens > 15000) score -= 0.3; // Very high complexity // Longer conversations are better for batch processing (if not too complex) if (entries.length > 5 && avgTokens < 8000) score += 0.2; if (entries.length > 10 && avgTokens < 8000) score += 0.1; // Non-interactive conversations are ideal const avgTimeBetweenMessages = this.calculateAvgTimeBetweenMessages(entries); if (avgTimeBetweenMessages > 30) score += 0.2; // 30+ seconds between messages // Higher cost conversations provide more savings (but not if too complex) const totalCost = entries.reduce((sum, e) => sum + calculateCost(e), 0); if (totalCost > 0.01 && avgTokens < 10000) score += 0.1; // Consistent model usage is better for batch const models = new Set(entries.map((e) => e.model)); if (models.size === 1) score += 0.1; return Math.max(0, Math.min(1.0, score)); } calculateAvgTimeBetweenMessages(entries) { if (entries.length < 2) return 0; const sortedEntries = [...entries].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); let totalTime = 0; for (let i = 1; i < sortedEntries.length; i++) { const prevTime = new Date(sortedEntries[i - 1].timestamp).getTime(); const currTime = new Date(sortedEntries[i].timestamp).getTime(); totalTime += (currTime - prevTime) / 1000; // Convert to seconds } return totalTime / (sortedEntries.length - 1); } getBatchEligibilityReasoning(entries, score) { const reasons = []; if (entries.length > 20) reasons.push("long conversation"); if (this.calculateAvgTimeBetweenMessages(entries) > 30) reasons.push("low interactivity"); const totalCost = entries.reduce((sum, e) => sum + calculateCost(e), 0); if (totalCost > 0.5) reasons.push("high cost"); const models = new Set(entries.map((e) => e.model)); if (models.size === 1) reasons.push("consistent model"); return `Eligible due to: ${reasons.join(", ")} (score: ${(score * 100).toFixed(0)}%)`; } estimateBatchProcessingTime(entries) { // Estimate based on token count and complexity const totalTokens = entries.reduce((sum, e) => sum + e.total_tokens, 0); return Math.ceil(totalTokens / 10000) * 30; // ~30 seconds per 10K tokens } analyzeModelSwitch(conversationId, entries, currentModel, currentCost) { const isCurrentlyOpus = currentModel.includes("opus"); const complexity = this.assessConversationComplexity(entries); const hasCodeContext = this.detectCodeContext(entries); // Suggest Sonnet for Opus conversations with low complexity if (isCurrentlyOpus && complexity < 0.3 && !hasCodeContext) { const projectedCost = currentCost * 0.22; // 78% savings with Sonnet return { conversationId, currentModel, recommendedModel: "claude-3.5-sonnet-20241022", currentCost, projectedCost, savings: currentCost - projectedCost, confidence: 0.85, riskLevel: "low", reasoning: "Low complexity conversation suitable for Sonnet with significant cost savings", }; } // Suggest Opus for Sonnet conversations with high complexity if (!isCurrentlyOpus && complexity > 0.7 && currentCost > 0.3) { const projectedCost = currentCost * 4.5; // Opus costs ~4.5x more return { conversationId, currentModel, recommendedModel: "claude-opus-4-20250514", currentCost, projectedCost, savings: currentCost - projectedCost, // Negative savings (additional cost) confidence: 0.65, riskLevel: "medium", reasoning: "High complexity conversation may benefit from Opus capabilities", }; } return null; } assessConversationComplexity(entries) { const avgTokens = entries.reduce((sum, e) => sum + e.total_tokens, 0) / entries.length; const conversationLength = entries.length; const totalCost = entries.reduce((sum, e) => sum + calculateCost(e), 0); let complexity = 0; // High token usage suggests complexity if (avgTokens > 5000) complexity += 0.3; if (avgTokens > 8000) complexity += 0.2; // Long conversations may be complex if (conversationLength > 15) complexity += 0.3; if (conversationLength > 30) complexity += 0.2; // High cost suggests complex reasoning if (totalCost > 1.0) complexity += 0.2; return Math.min(1.0, complexity); } detectCodeContext(entries) { // Simple heuristic: moderate length conversations with consistent token usage const avgTokens = entries.reduce((sum, e) => sum + e.total_tokens, 0) / entries.length; return entries.length > 5 && avgTokens > 2000 && avgTokens < 8000; } // Interface methods expected by tests analyzeConversationClusters(entries) { const conversations = this.groupByConversation(entries); const clusters = []; // Group conversations by similarity (token count, cost, model) const clusterMap = new Map(); for (const [conversationId, convEntries] of conversations) { const avgTokens = convEntries.reduce((sum, e) => sum + e.total_tokens, 0) / convEntries.length; const totalCost = convEntries.reduce((sum, e) => sum + calculateCost(e), 0); const model = convEntries[0].model; // Create cluster key based on model and complexity const complexity = this.calculateComplexityScore(convEntries); const clusterKey = `${model}-${Math.floor(complexity * 4)}`; // 4 complexity buckets if (!clusterMap.has(clusterKey)) { clusterMap.set(clusterKey, []); } clusterMap.get(clusterKey).push(conversationId); } // Convert to cluster analysis format for (const [clusterKey, conversationIds] of clusterMap) { // Include all clusters, even single conversations for analysis const clusterEntries = conversationIds.flatMap((id) => conversations.get(id) || []); const avgTokens = clusterEntries.reduce((sum, e) => sum + e.total_tokens, 0) / clusterEntries.length; const totalCost = clusterEntries.reduce((sum, e) => sum + calculateCost(e), 0); const avgCost = totalCost / conversationIds.length; const complexity = this.calculateComplexityScore(clusterEntries); // Calculate optimization potential const isOpus = clusterKey.includes("opus"); const potentialSavings = isOpus && complexity < 0.5 ? totalCost * 0.78 : 0; clusters.push({ conversationIds, characteristics: { avgTokens, avgCost, complexity, }, optimization: { potentialSavings, recommendation: potentialSavings > 0 ? "Switch to Sonnet for cost savings" : "Current model appropriate for complexity", }, }); } return { clusters, totalConversations: conversations.size, avgClusterSize: clusters.length > 0 ? clusters.reduce((sum, c) => sum + c.conversationIds.length, 0) / clusters.length : 0, }; } removeBatchOverlaps(opportunities) { const used = new Set(); const filtered = []; // Sort by group size (descending) to prefer larger batches const sorted = [...opportunities].sort((a, b) => b.conversationIds.length - a.conversationIds.length); for (const opportunity of sorted) { const hasOverlap = opportunity.conversationIds.some((id) => used.has(id)); if (!hasOverlap) { opportunity.conversationIds.forEach((id) => used.add(id)); filtered.push(opportunity); } } return filtered; } calculateComplexityScore(entries) { if (entries.length === 0) return 0; const conversationLength = entries.length; const avgTokensPerMessage = entries.reduce((sum, e) => sum + e.total_tokens, 0) / entries.length; const totalCost = entries.reduce((sum, e) => sum + calculateCost(e), 0); let score = 0; if (conversationLength > 10) score += 0.3; if (avgTokensPerMessage > 5000) score += 0.4; if (totalCost > 1.0) score += 0.3; return Math.min(1.0, score); } } //# sourceMappingURL=optimization-analytics.js.map