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
JavaScript
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