BMAD-METHOD/.claude/tools/cost/cost-tracker.mjs

395 lines
13 KiB
JavaScript

#!/usr/bin/env node
/**
* Enterprise Cost Tracking System
*
* Implements Claude SDK cost tracking best practices:
* - Message ID deduplication to prevent double-charging
* - Per-agent cost tracking for workflow optimization
* - Real-time usage monitoring and budget alerts
* - Comprehensive cost reporting and analytics
*
* Based on: https://docs.claude.com/en/docs/agent-sdk/cost-tracking.md
*
* @version 2.0.0
* @date 2025-11-13
*/
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PROJECT_ROOT = path.resolve(__dirname, '../../..');
// ============================================================================
// Pricing Constants (as of 2025-01-13)
// ============================================================================
const PRICING = {
'claude-sonnet-4-5': {
input_tokens: 0.00003, // $3 per MTok
output_tokens: 0.00015, // $15 per MTok
cache_read_tokens: 0.0000075 // $0.75 per MTok
},
'claude-opus-4-1': {
input_tokens: 0.00015, // $15 per MTok
output_tokens: 0.00075, // $75 per MTok
cache_read_tokens: 0.0000375 // $3.75 per MTok
},
'claude-haiku-4': {
input_tokens: 0.000001, // $0.10 per MTok
output_tokens: 0.000005, // $0.50 per MTok
cache_read_tokens: 0.0000005 // $0.05 per MTok
}
};
// ============================================================================
// Cost Tracker Class
// ============================================================================
class CostTracker {
constructor(sessionId, options = {}) {
this.sessionId = sessionId;
this.options = {
enableAlerts: options.enableAlerts !== false,
budgetLimit: options.budgetLimit || null,
alertThreshold: options.alertThreshold || 0.80, // Alert at 80% of budget
savePath: options.savePath || path.join(PROJECT_ROOT, '.claude/context/history/costs'),
...options
};
// Track processed message IDs to prevent double-counting
this.processedMessageIds = new Set();
// Usage aggregation
this.usage = {
total: {
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
total_cost_usd: 0
},
by_agent: {},
by_model: {},
messages: []
};
// Budget alerts
this.budgetAlerts = [];
}
/**
* Process a message and track its usage
* Implements message ID deduplication as per SDK docs
*/
processMessage(message, agent = 'unknown', model = 'claude-sonnet-4-5') {
// Skip if not an assistant message with usage data
if (message.type !== 'assistant' || !message.usage) {
return null;
}
// Deduplicate based on message ID
if (this.processedMessageIds.has(message.id)) {
console.log(` ⊘ Skipping duplicate message: ${message.id}`);
return null;
}
// Mark as processed
this.processedMessageIds.add(message.id);
const usage = message.usage;
// Calculate cost
const cost = this.calculateCost(usage, model);
// Create usage record
const record = {
message_id: message.id,
timestamp: new Date().toISOString(),
agent,
model,
usage: {
input_tokens: usage.input_tokens || 0,
output_tokens: usage.output_tokens || 0,
cache_creation_tokens: usage.cache_creation_input_tokens || 0,
cache_read_tokens: usage.cache_read_input_tokens || 0
},
cost_usd: cost,
authoritative: message.total_cost_usd !== undefined
};
// Update total usage
this.usage.total.input_tokens += record.usage.input_tokens;
this.usage.total.output_tokens += record.usage.output_tokens;
this.usage.total.cache_creation_tokens += record.usage.cache_creation_tokens;
this.usage.total.cache_read_tokens += record.usage.cache_read_tokens;
this.usage.total.total_cost_usd += cost;
// Update per-agent usage
if (!this.usage.by_agent[agent]) {
this.usage.by_agent[agent] = {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
total_cost_usd: 0,
message_count: 0
};
}
this.usage.by_agent[agent].input_tokens += record.usage.input_tokens;
this.usage.by_agent[agent].output_tokens += record.usage.output_tokens;
this.usage.by_agent[agent].cache_read_tokens += record.usage.cache_read_tokens;
this.usage.by_agent[agent].total_cost_usd += cost;
this.usage.by_agent[agent].message_count++;
// Update per-model usage
if (!this.usage.by_model[model]) {
this.usage.by_model[model] = {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
total_cost_usd: 0
};
}
this.usage.by_model[model].input_tokens += record.usage.input_tokens;
this.usage.by_model[model].output_tokens += record.usage.output_tokens;
this.usage.by_model[model].cache_read_tokens += record.usage.cache_read_tokens;
this.usage.by_model[model].total_cost_usd += cost;
// Store record
this.usage.messages.push(record);
// Check budget
if (this.options.enableAlerts) {
this.checkBudget();
}
console.log(` 💰 Cost: $${cost.toFixed(6)} (${agent}, ${record.usage.output_tokens} tokens)`);
return record;
}
/**
* Calculate cost based on usage and model
*/
calculateCost(usage, model) {
const pricing = PRICING[model] || PRICING['claude-sonnet-4-5'];
const inputCost = (usage.input_tokens || 0) * pricing.input_tokens;
const outputCost = (usage.output_tokens || 0) * pricing.output_tokens;
const cacheReadCost = (usage.cache_read_input_tokens || 0) * pricing.cache_read_tokens;
return inputCost + outputCost + cacheReadCost;
}
/**
* Check budget and emit alerts
*/
checkBudget() {
if (!this.options.budgetLimit) return;
const currentCost = this.usage.total.total_cost_usd;
const budgetUsed = currentCost / this.options.budgetLimit;
if (budgetUsed >= 1.0 && !this.budgetAlerts.includes('exceeded')) {
this.budgetAlerts.push('exceeded');
console.error(`\n⚠️ BUDGET EXCEEDED: $${currentCost.toFixed(2)} / $${this.options.budgetLimit.toFixed(2)}`);
} else if (budgetUsed >= this.options.alertThreshold && !this.budgetAlerts.includes('warning')) {
this.budgetAlerts.push('warning');
console.warn(`\n⚠️ Budget Warning: ${(budgetUsed * 100).toFixed(1)}% used ($${currentCost.toFixed(2)} / $${this.options.budgetLimit.toFixed(2)})`);
}
}
/**
* Get current usage summary
*/
getSummary() {
return {
session_id: this.sessionId,
total_cost_usd: this.usage.total.total_cost_usd,
total_tokens: this.usage.total.input_tokens + this.usage.total.output_tokens,
messages_processed: this.usage.messages.length,
by_agent: this.usage.by_agent,
by_model: this.usage.by_model,
budget_status: this.options.budgetLimit ? {
limit: this.options.budgetLimit,
used: this.usage.total.total_cost_usd,
percentage: (this.usage.total.total_cost_usd / this.options.budgetLimit) * 100,
alerts: this.budgetAlerts
} : null
};
}
/**
* Save cost report to file
*/
async save() {
const filePath = path.join(this.options.savePath, `${this.sessionId}.json`);
const report = {
session_id: this.sessionId,
generated_at: new Date().toISOString(),
total: this.usage.total,
by_agent: this.usage.by_agent,
by_model: this.usage.by_model,
messages: this.usage.messages,
budget: this.options.budgetLimit ? {
limit: this.options.budgetLimit,
used: this.usage.total.total_cost_usd,
percentage: (this.usage.total.total_cost_usd / this.options.budgetLimit) * 100,
alerts: this.budgetAlerts
} : null
};
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(report, null, 2));
console.log(` ✓ Cost report saved: ${filePath}`);
return filePath;
}
/**
* Generate cost report
*/
generateReport() {
const lines = [];
lines.push('# Cost Report');
lines.push('');
lines.push(`**Session**: ${this.sessionId}`);
lines.push(`**Generated**: ${new Date().toISOString()}`);
lines.push('');
lines.push('## Total Cost');
lines.push('');
lines.push(`- **Total**: $${this.usage.total.total_cost_usd.toFixed(4)}`);
lines.push(`- **Input Tokens**: ${this.usage.total.input_tokens.toLocaleString()}`);
lines.push(`- **Output Tokens**: ${this.usage.total.output_tokens.toLocaleString()}`);
lines.push(`- **Cache Read Tokens**: ${this.usage.total.cache_read_tokens.toLocaleString()}`);
lines.push(`- **Messages**: ${this.usage.messages.length}`);
lines.push('');
lines.push('## Cost by Agent');
lines.push('');
lines.push('| Agent | Messages | Input Tokens | Output Tokens | Cache Read | Cost |');
lines.push('|-------|----------|--------------|---------------|------------|------|');
for (const [agent, usage] of Object.entries(this.usage.by_agent)) {
lines.push(`| ${agent} | ${usage.message_count} | ${usage.input_tokens.toLocaleString()} | ${usage.output_tokens.toLocaleString()} | ${usage.cache_read_tokens.toLocaleString()} | $${usage.total_cost_usd.toFixed(4)} |`);
}
lines.push('');
lines.push('## Cost by Model');
lines.push('');
lines.push('| Model | Input Tokens | Output Tokens | Cache Read | Cost |');
lines.push('|-------|--------------|---------------|------------|------|');
for (const [model, usage] of Object.entries(this.usage.by_model)) {
lines.push(`| ${model} | ${usage.input_tokens.toLocaleString()} | ${usage.output_tokens.toLocaleString()} | ${usage.cache_read_tokens.toLocaleString()} | $${usage.total_cost_usd.toFixed(4)} |`);
}
lines.push('');
if (this.options.budgetLimit) {
lines.push('## Budget Status');
lines.push('');
lines.push(`- **Limit**: $${this.options.budgetLimit.toFixed(2)}`);
lines.push(`- **Used**: $${this.usage.total.total_cost_usd.toFixed(2)}`);
lines.push(`- **Remaining**: $${(this.options.budgetLimit - this.usage.total.total_cost_usd).toFixed(2)}`);
lines.push(`- **Percentage**: ${((this.usage.total.total_cost_usd / this.options.budgetLimit) * 100).toFixed(1)}%`);
if (this.budgetAlerts.length > 0) {
lines.push('');
lines.push('**Alerts**:');
for (const alert of this.budgetAlerts) {
lines.push(`- ${alert}`);
}
}
}
return lines.join('\n');
}
/**
* Get cost optimization recommendations
*/
getOptimizationRecommendations() {
const recommendations = [];
// Check cache usage
const cacheEfficiency = this.usage.total.cache_read_tokens /
(this.usage.total.input_tokens || 1);
if (cacheEfficiency < 0.1) {
recommendations.push({
type: 'cache_optimization',
priority: 'high',
message: 'Low cache hit rate detected. Consider implementing prompt caching for repeated contexts.',
potential_savings: this.usage.total.total_cost_usd * 0.25 // Estimate 25% savings
});
}
// Check model selection
const agentCosts = Object.entries(this.usage.by_agent)
.sort((a, b) => b[1].total_cost_usd - a[1].total_cost_usd);
for (const [agent, usage] of agentCosts) {
const avgTokensPerMessage = usage.output_tokens / (usage.message_count || 1);
if (avgTokensPerMessage < 500 && usage.total_cost_usd > 0.01) {
recommendations.push({
type: 'model_downgrade',
priority: 'medium',
agent,
message: `Agent "${agent}" produces short outputs. Consider using Claude Haiku for cost savings.`,
potential_savings: usage.total_cost_usd * 0.90 // Estimate 90% savings
});
}
}
return recommendations;
}
}
// ============================================================================
// Billing Aggregator for Multi-Project Tracking
// ============================================================================
class BillingAggregator {
constructor() {
this.projects = new Map();
}
addSession(projectId, costTracker) {
if (!this.projects.has(projectId)) {
this.projects.set(projectId, []);
}
this.projects.get(projectId).push(costTracker);
}
getProjectCost(projectId) {
const sessions = this.projects.get(projectId) || [];
return sessions.reduce((total, tracker) =>
total + tracker.usage.total.total_cost_usd, 0
);
}
getAllProjectsCost() {
const costs = {};
for (const [projectId, sessions] of this.projects.entries()) {
costs[projectId] = this.getProjectCost(projectId);
}
return costs;
}
}
// ============================================================================
// Export
// ============================================================================
export { CostTracker, BillingAggregator, PRICING };