395 lines
13 KiB
JavaScript
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 };
|