Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Comprehensive cost tracking system implementation including: - Cost report generator with multiple formats (summary, detailed, audit) - Full CLI integration with cost management commands - Claude session cost tracking and estimation - Professional markdown reports with frontmatter/contentmatter - Automatic cost note generation for issue implementations - Complete test coverage (33 test cases) - Database integration with finance schema initialization 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
396 lines
15 KiB
Python
396 lines
15 KiB
Python
"""
|
|
Claude Session Cost Tracker for Issue Implementation.
|
|
|
|
This module tracks Claude session costs during issue implementation and generates
|
|
cost notes documenting the expenses associated with completing specific issues.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime, date
|
|
from decimal import Decimal
|
|
from typing import Dict, Any, Optional, List
|
|
from pathlib import Path
|
|
|
|
from .cost_manager import CostItemManager, CostItem
|
|
from .report_generator import CostReportGenerator, ReportConfig
|
|
|
|
|
|
class SessionCostTracker:
|
|
"""Tracks Claude session costs and generates cost notes for issues."""
|
|
|
|
def __init__(self, db_path: str):
|
|
"""
|
|
Initialize session cost tracker.
|
|
|
|
Args:
|
|
db_path: Path to SQLite database file
|
|
"""
|
|
self.db_path = db_path
|
|
self.cost_manager = CostItemManager(db_path)
|
|
self.report_generator = CostReportGenerator(db_path)
|
|
|
|
def estimate_session_cost(self, input_tokens: int, output_tokens: int,
|
|
model: str = "claude-sonnet-4") -> Dict[str, Any]:
|
|
"""
|
|
Estimate Claude session cost based on token usage.
|
|
|
|
Args:
|
|
input_tokens: Number of input tokens used
|
|
output_tokens: Number of output tokens generated
|
|
model: Claude model used (affects pricing)
|
|
|
|
Returns:
|
|
Dictionary with cost breakdown
|
|
"""
|
|
# Claude pricing (as of 2025 - these would be updated regularly)
|
|
pricing = {
|
|
"claude-sonnet-4": {
|
|
"input_per_million": 3.00, # $3 per million input tokens
|
|
"output_per_million": 15.00 # $15 per million output tokens
|
|
},
|
|
"claude-sonnet-3.5": {
|
|
"input_per_million": 3.00,
|
|
"output_per_million": 15.00
|
|
},
|
|
"claude-haiku": {
|
|
"input_per_million": 0.25,
|
|
"output_per_million": 1.25
|
|
}
|
|
}
|
|
|
|
if model not in pricing:
|
|
model = "claude-sonnet-4" # Default fallback
|
|
|
|
rates = pricing[model]
|
|
|
|
# Calculate costs in USD
|
|
input_cost_usd = (input_tokens / 1_000_000) * rates["input_per_million"]
|
|
output_cost_usd = (output_tokens / 1_000_000) * rates["output_per_million"]
|
|
total_cost_usd = input_cost_usd + output_cost_usd
|
|
|
|
# Convert to EUR (approximate rate - in practice would use real-time rates)
|
|
usd_to_eur = 0.92 # Approximate conversion rate
|
|
total_cost_eur = total_cost_usd * usd_to_eur
|
|
|
|
return {
|
|
"model": model,
|
|
"input_tokens": input_tokens,
|
|
"output_tokens": output_tokens,
|
|
"total_tokens": input_tokens + output_tokens,
|
|
"input_cost_usd": round(input_cost_usd, 4),
|
|
"output_cost_usd": round(output_cost_usd, 4),
|
|
"total_cost_usd": round(total_cost_usd, 4),
|
|
"total_cost_eur": round(total_cost_eur, 4),
|
|
"pricing_rates": rates,
|
|
"conversion_rate": usd_to_eur
|
|
}
|
|
|
|
def create_issue_cost_item(self, issue_id: int, issue_title: str,
|
|
session_cost: Dict[str, Any],
|
|
session_date: Optional[date] = None) -> Optional[int]:
|
|
"""
|
|
Create a cost item for an issue implementation session.
|
|
|
|
Args:
|
|
issue_id: Issue ID number
|
|
issue_title: Title of the issue
|
|
session_cost: Cost breakdown from estimate_session_cost
|
|
session_date: Date of the session (defaults to today)
|
|
|
|
Returns:
|
|
ID of created cost item, or None if creation failed
|
|
"""
|
|
if session_date is None:
|
|
session_date = date.today()
|
|
|
|
# Get or create AI & ML Services category
|
|
ai_category = self.cost_manager.get_category_by_name('AI & ML Services')
|
|
if not ai_category:
|
|
# Create the category if it doesn't exist
|
|
category_id = self.cost_manager.create_category(
|
|
'AI & ML Services',
|
|
'Claude sessions and AI-powered development tools'
|
|
)
|
|
else:
|
|
category_id = ai_category['id']
|
|
|
|
# Create descriptive name and description
|
|
name = f"Claude Session - Issue #{issue_id}"
|
|
description = (
|
|
f"Claude {session_cost['model']} session for implementing '{issue_title}'. "
|
|
f"{session_cost['total_tokens']:,} tokens "
|
|
f"({session_cost['input_tokens']:,} input, {session_cost['output_tokens']:,} output)"
|
|
)
|
|
|
|
# Create cost item
|
|
cost_item = CostItem(
|
|
category_id=category_id,
|
|
name=name,
|
|
description=description,
|
|
cost_type='one_time',
|
|
amount_eur=Decimal(str(session_cost['total_cost_eur'])),
|
|
currency='EUR',
|
|
starting_from_date=session_date
|
|
)
|
|
|
|
cost_item_id = self.cost_manager.create_cost_item(cost_item)
|
|
|
|
if cost_item_id:
|
|
print(f"✅ Created cost item for Issue #{issue_id}: €{session_cost['total_cost_eur']:.4f}")
|
|
|
|
return cost_item_id
|
|
|
|
def generate_issue_cost_note(self, issue_id: int, issue_title: str,
|
|
session_cost: Dict[str, Any],
|
|
implementation_summary: Optional[str] = None,
|
|
session_date: Optional[date] = None) -> str:
|
|
"""
|
|
Generate a cost note document for an issue implementation.
|
|
|
|
Args:
|
|
issue_id: Issue ID number
|
|
issue_title: Title of the issue
|
|
session_cost: Cost breakdown from estimate_session_cost
|
|
implementation_summary: Summary of what was implemented
|
|
session_date: Date of the session
|
|
|
|
Returns:
|
|
Markdown content for the cost note
|
|
"""
|
|
if session_date is None:
|
|
session_date = date.today()
|
|
|
|
# Prepare frontmatter
|
|
frontmatter = {
|
|
"note_type": "issue_cost_tracking",
|
|
"issue_id": issue_id,
|
|
"issue_title": issue_title,
|
|
"session_date": session_date.isoformat(),
|
|
"claude_model": session_cost['model'],
|
|
"total_cost_eur": session_cost['total_cost_eur'],
|
|
"total_cost_usd": session_cost['total_cost_usd'],
|
|
"total_tokens": session_cost['total_tokens'],
|
|
"generated_at": datetime.now().isoformat()
|
|
}
|
|
|
|
# Prepare contentmatter
|
|
contentmatter = {
|
|
"cost_tracking": {
|
|
"issue": {
|
|
"id": issue_id,
|
|
"title": issue_title,
|
|
"implementation_date": session_date.isoformat()
|
|
},
|
|
"session": {
|
|
"model": session_cost['model'],
|
|
"token_usage": {
|
|
"input_tokens": session_cost['input_tokens'],
|
|
"output_tokens": session_cost['output_tokens'],
|
|
"total_tokens": session_cost['total_tokens']
|
|
},
|
|
"costs": {
|
|
"input_cost_usd": session_cost['input_cost_usd'],
|
|
"output_cost_usd": session_cost['output_cost_usd'],
|
|
"total_cost_usd": session_cost['total_cost_usd'],
|
|
"total_cost_eur": session_cost['total_cost_eur'],
|
|
"conversion_rate": session_cost['conversion_rate']
|
|
},
|
|
"pricing_rates": session_cost['pricing_rates']
|
|
}
|
|
}
|
|
}
|
|
|
|
# Build content
|
|
content_lines = [
|
|
f"# Issue #{issue_id} Implementation Cost",
|
|
f"**Issue**: {issue_title}",
|
|
f"**Date**: {session_date.strftime('%Y-%m-%d')}",
|
|
f"**Claude Model**: {session_cost['model']}",
|
|
"",
|
|
"## Cost Summary",
|
|
f"- **Total Cost**: €{session_cost['total_cost_eur']:.4f} (${session_cost['total_cost_usd']:.4f} USD)",
|
|
f"- **Token Usage**: {session_cost['total_tokens']:,} tokens",
|
|
f"- **Input Tokens**: {session_cost['input_tokens']:,} tokens @ ${session_cost['pricing_rates']['input_per_million']:.2f}/M",
|
|
f"- **Output Tokens**: {session_cost['output_tokens']:,} tokens @ ${session_cost['pricing_rates']['output_per_million']:.2f}/M",
|
|
"",
|
|
"## Cost Breakdown",
|
|
"",
|
|
"| Component | Tokens | Rate ($/M) | Cost (USD) | Cost (EUR) |",
|
|
"|-----------|--------|------------|------------|------------|",
|
|
f"| Input | {session_cost['input_tokens']:,} | ${session_cost['pricing_rates']['input_per_million']:.2f} | ${session_cost['input_cost_usd']:.4f} | €{session_cost['input_cost_usd'] * session_cost['conversion_rate']:.4f} |",
|
|
f"| Output | {session_cost['output_tokens']:,} | ${session_cost['pricing_rates']['output_per_million']:.2f} | ${session_cost['output_cost_usd']:.4f} | €{session_cost['output_cost_usd'] * session_cost['conversion_rate']:.4f} |",
|
|
f"| **Total** | {session_cost['total_tokens']:,} | - | ${session_cost['total_cost_usd']:.4f} | €{session_cost['total_cost_eur']:.4f} |",
|
|
"",
|
|
]
|
|
|
|
if implementation_summary:
|
|
content_lines.extend([
|
|
"## Implementation Summary",
|
|
implementation_summary,
|
|
""
|
|
])
|
|
|
|
content_lines.extend([
|
|
"## Cost Allocation",
|
|
f"This cost has been allocated to the 'AI & ML Services' category as a one-time expense for issue #{issue_id} implementation.",
|
|
"",
|
|
"## Notes",
|
|
f"- Currency conversion rate: 1 USD = {session_cost['conversion_rate']:.3f} EUR",
|
|
f"- Pricing based on {session_cost['model']} rates as of {session_date}",
|
|
"- Token counts and costs are estimates based on session usage",
|
|
])
|
|
|
|
content = "\n".join(content_lines)
|
|
|
|
return self._assemble_cost_note(frontmatter, content, contentmatter)
|
|
|
|
def _assemble_cost_note(self, frontmatter: Dict[str, Any], content: str,
|
|
contentmatter: Dict[str, Any]) -> str:
|
|
"""Assemble complete cost note with frontmatter and contentmatter."""
|
|
# Convert frontmatter to YAML
|
|
fm_lines = ["---"]
|
|
for key, value in frontmatter.items():
|
|
if isinstance(value, str):
|
|
fm_lines.append(f'{key}: "{value}"')
|
|
else:
|
|
fm_lines.append(f'{key}: {value}')
|
|
fm_lines.append("---")
|
|
|
|
# Convert contentmatter to HTML comment
|
|
cm_json = json.dumps(contentmatter, indent=2)
|
|
cm_lines = [
|
|
"<!--",
|
|
"contentmatter:",
|
|
cm_json,
|
|
"-->"
|
|
]
|
|
|
|
# Assemble final document
|
|
return "\n".join([
|
|
"\n".join(fm_lines),
|
|
"",
|
|
content,
|
|
"",
|
|
"\n".join(cm_lines)
|
|
])
|
|
|
|
def save_issue_cost_note(self, issue_id: int, cost_note_content: str,
|
|
output_dir: str = "cost_notes") -> str:
|
|
"""
|
|
Save issue cost note to file.
|
|
|
|
Args:
|
|
issue_id: Issue ID number
|
|
cost_note_content: Generated cost note content
|
|
output_dir: Directory to save cost notes
|
|
|
|
Returns:
|
|
Path to saved file
|
|
"""
|
|
# Create output directory
|
|
output_path = Path(output_dir)
|
|
output_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Generate filename
|
|
today = date.today().strftime('%Y-%m-%d')
|
|
filename = f"issue_{issue_id:03d}_cost_{today}.md"
|
|
file_path = output_path / filename
|
|
|
|
# Save file
|
|
with open(file_path, 'w', encoding='utf-8') as f:
|
|
f.write(cost_note_content)
|
|
|
|
return str(file_path)
|
|
|
|
def track_issue_completion(self, issue_id: int, issue_title: str,
|
|
input_tokens: int, output_tokens: int,
|
|
model: str = "claude-sonnet-4",
|
|
implementation_summary: Optional[str] = None,
|
|
save_note: bool = True,
|
|
output_dir: str = "cost_notes") -> Dict[str, Any]:
|
|
"""
|
|
Complete workflow: estimate cost, create cost item, generate note.
|
|
|
|
Args:
|
|
issue_id: Issue ID number
|
|
issue_title: Title of the issue
|
|
input_tokens: Number of input tokens used
|
|
output_tokens: Number of output tokens generated
|
|
model: Claude model used
|
|
implementation_summary: Summary of implementation
|
|
save_note: Whether to save the cost note to file
|
|
output_dir: Directory to save cost notes
|
|
|
|
Returns:
|
|
Dictionary with tracking results
|
|
"""
|
|
# Estimate session cost
|
|
session_cost = self.estimate_session_cost(input_tokens, output_tokens, model)
|
|
|
|
# Create cost item in database
|
|
cost_item_id = self.create_issue_cost_item(issue_id, issue_title, session_cost)
|
|
|
|
# Generate cost note
|
|
cost_note = self.generate_issue_cost_note(
|
|
issue_id, issue_title, session_cost, implementation_summary
|
|
)
|
|
|
|
# Save cost note if requested
|
|
saved_path = None
|
|
if save_note:
|
|
saved_path = self.save_issue_cost_note(issue_id, cost_note, output_dir)
|
|
|
|
return {
|
|
"issue_id": issue_id,
|
|
"issue_title": issue_title,
|
|
"session_cost": session_cost,
|
|
"cost_item_id": cost_item_id,
|
|
"cost_note": cost_note,
|
|
"saved_path": saved_path,
|
|
"tracking_successful": cost_item_id is not None
|
|
}
|
|
|
|
def get_issue_costs_summary(self, issue_ids: Optional[List[int]] = None) -> Dict[str, Any]:
|
|
"""
|
|
Get summary of costs for specific issues or all AI service costs.
|
|
|
|
Args:
|
|
issue_ids: Optional list of issue IDs to filter by
|
|
|
|
Returns:
|
|
Summary of issue implementation costs
|
|
"""
|
|
# Get AI & ML Services category
|
|
ai_category = self.cost_manager.get_category_by_name('AI & ML Services')
|
|
if not ai_category:
|
|
return {"total_costs": 0.0, "issue_count": 0, "items": []}
|
|
|
|
# Get cost items in AI category
|
|
items = self.cost_manager.list_cost_items(
|
|
active_only=True,
|
|
category_id=ai_category['id']
|
|
)
|
|
|
|
# Filter by issue IDs if specified
|
|
if issue_ids:
|
|
filtered_items = []
|
|
for item in items:
|
|
# Extract issue ID from name (format: "Claude Session - Issue #123")
|
|
if "Issue #" in item['name']:
|
|
try:
|
|
item_issue_id = int(item['name'].split("Issue #")[1].split()[0])
|
|
if item_issue_id in issue_ids:
|
|
filtered_items.append(item)
|
|
except (IndexError, ValueError):
|
|
continue
|
|
items = filtered_items
|
|
|
|
total_cost = sum(float(item['amount_eur']) for item in items)
|
|
|
|
return {
|
|
"total_costs": total_cost,
|
|
"issue_count": len(items),
|
|
"items": items,
|
|
"currency": "EUR"
|
|
} |