codeframe-ai 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
"""Metrics and cost tracking for LLM API calls (Sprint 10 Phase 5).
|
|
2
|
+
|
|
3
|
+
This module provides token usage tracking and cost estimation for LLM calls
|
|
4
|
+
across agents and projects. It supports:
|
|
5
|
+
|
|
6
|
+
- Recording token usage per LLM call (async and sync)
|
|
7
|
+
- Cost calculation for Claude models (Sonnet 4.5, Opus 4, Haiku 4)
|
|
8
|
+
- Cost aggregation by project, agent, model, task, and workspace
|
|
9
|
+
- Timeline-based token usage statistics
|
|
10
|
+
- Export to CSV and JSON
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
>>> from codeframe.lib.metrics_tracker import MetricsTracker
|
|
14
|
+
>>> from codeframe.platform_store.database import Database
|
|
15
|
+
>>> from codeframe.core.models import CallType
|
|
16
|
+
>>>
|
|
17
|
+
>>> db = Database("state.db")
|
|
18
|
+
>>> db.initialize()
|
|
19
|
+
>>> tracker = MetricsTracker(db=db)
|
|
20
|
+
>>>
|
|
21
|
+
>>> # Record token usage after LLM call (sync)
|
|
22
|
+
>>> usage_id = tracker.record_token_usage_sync(
|
|
23
|
+
... task_id=27,
|
|
24
|
+
... agent_id="backend-001",
|
|
25
|
+
... project_id=1,
|
|
26
|
+
... model_name="claude-sonnet-4-5",
|
|
27
|
+
... input_tokens=1000,
|
|
28
|
+
... output_tokens=500,
|
|
29
|
+
... call_type=CallType.TASK_EXECUTION
|
|
30
|
+
... )
|
|
31
|
+
>>>
|
|
32
|
+
>>> # Get project costs
|
|
33
|
+
>>> costs = await tracker.get_project_costs(project_id=1)
|
|
34
|
+
>>> print(f"Total: ${costs['total_cost_usd']:.2f}")
|
|
35
|
+
Total: $0.01
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import csv
|
|
39
|
+
import json
|
|
40
|
+
import logging
|
|
41
|
+
import re
|
|
42
|
+
from datetime import datetime, timedelta, timezone
|
|
43
|
+
from typing import Any, Dict, List, Optional, Union
|
|
44
|
+
from codeframe.core.models import CallType, TokenUsage
|
|
45
|
+
from codeframe.platform_store.database import Database
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
# Model pricing as of 2025-11 (per million tokens)
|
|
50
|
+
# Source: Anthropic pricing page
|
|
51
|
+
MODEL_PRICING = {
|
|
52
|
+
"claude-sonnet-4-5": {"input": 3.00, "output": 15.00},
|
|
53
|
+
"claude-opus-4": {"input": 15.00, "output": 75.00},
|
|
54
|
+
"claude-haiku-4": {"input": 0.80, "output": 4.00},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Regex to strip -YYYYMMDD date suffixes from Anthropic API model names
|
|
58
|
+
# (e.g., "claude-sonnet-4-5-20250514" → "claude-sonnet-4-5")
|
|
59
|
+
_DATE_SUFFIX_RE = re.compile(r"-\d{8}$")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def normalize_model_name(raw_model: str) -> str:
|
|
63
|
+
"""Normalize a model name by stripping date suffixes.
|
|
64
|
+
|
|
65
|
+
The Anthropic API returns model names like 'claude-sonnet-4-5-20250514'
|
|
66
|
+
but our pricing dict uses 'claude-sonnet-4-5'. This function strips
|
|
67
|
+
the date suffix and returns the canonical name.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
raw_model: Raw model name from the API (e.g., 'claude-sonnet-4-5-20250514')
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Normalized model name (e.g., 'claude-sonnet-4-5')
|
|
74
|
+
"""
|
|
75
|
+
# If it already matches a known model, return as-is
|
|
76
|
+
if raw_model in MODEL_PRICING:
|
|
77
|
+
return raw_model
|
|
78
|
+
|
|
79
|
+
# Try stripping date suffix (8 digits at the end)
|
|
80
|
+
stripped = _DATE_SUFFIX_RE.sub("", raw_model)
|
|
81
|
+
if stripped in MODEL_PRICING:
|
|
82
|
+
return stripped
|
|
83
|
+
|
|
84
|
+
# Unknown model - return as-is
|
|
85
|
+
return raw_model
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class MetricsTracker:
|
|
89
|
+
"""Tracks token usage and costs for LLM API calls.
|
|
90
|
+
|
|
91
|
+
This class provides methods to record token usage, calculate costs,
|
|
92
|
+
and retrieve aggregated statistics for projects and agents.
|
|
93
|
+
|
|
94
|
+
Attributes:
|
|
95
|
+
db: Database instance for persistence
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
>>> tracker = MetricsTracker(db=database)
|
|
99
|
+
>>> usage_id = await tracker.record_token_usage(
|
|
100
|
+
... task_id=1,
|
|
101
|
+
... agent_id="backend-001",
|
|
102
|
+
... project_id=1,
|
|
103
|
+
... model_name="claude-sonnet-4-5",
|
|
104
|
+
... input_tokens=1000,
|
|
105
|
+
... output_tokens=500
|
|
106
|
+
... )
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, db: Database):
|
|
110
|
+
"""Initialize MetricsTracker.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
db: Database instance for storing token usage records
|
|
114
|
+
"""
|
|
115
|
+
self.db = db
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def calculate_cost(model_name: str, input_tokens: int, output_tokens: int) -> float:
|
|
119
|
+
"""Calculate estimated cost in USD for an LLM call.
|
|
120
|
+
|
|
121
|
+
Uses current Anthropic pricing (as of 2025-11):
|
|
122
|
+
- Claude Sonnet 4.5: $3.00 input / $15.00 output per MTok
|
|
123
|
+
- Claude Opus 4: $15.00 input / $75.00 output per MTok
|
|
124
|
+
- Claude Haiku 4: $0.80 input / $4.00 output per MTok
|
|
125
|
+
|
|
126
|
+
Handles model names with date suffixes (e.g., 'claude-sonnet-4-5-20250514')
|
|
127
|
+
by normalizing them first. Unknown models return $0.00 cost instead of
|
|
128
|
+
raising, to avoid crashing the agent during recording.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
model_name: Model identifier (e.g., "claude-sonnet-4-5" or "claude-sonnet-4-5-20250514")
|
|
132
|
+
input_tokens: Number of input tokens
|
|
133
|
+
output_tokens: Number of output tokens
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Estimated cost in USD (rounded to 6 decimal places), or 0.0 for unknown models
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
>>> cost = MetricsTracker.calculate_cost(
|
|
140
|
+
... "claude-sonnet-4-5", 1000, 500
|
|
141
|
+
... )
|
|
142
|
+
>>> print(f"${cost:.4f}")
|
|
143
|
+
$0.0105
|
|
144
|
+
"""
|
|
145
|
+
normalized = normalize_model_name(model_name)
|
|
146
|
+
|
|
147
|
+
if normalized not in MODEL_PRICING:
|
|
148
|
+
logger.warning(
|
|
149
|
+
f"Unknown model '{model_name}' (normalized: '{normalized}'). "
|
|
150
|
+
f"Returning $0.00 cost. Supported: {', '.join(MODEL_PRICING.keys())}"
|
|
151
|
+
)
|
|
152
|
+
return 0.0
|
|
153
|
+
|
|
154
|
+
prices = MODEL_PRICING[normalized]
|
|
155
|
+
|
|
156
|
+
# Calculate cost: (tokens * price_per_mtok) / 1,000,000
|
|
157
|
+
input_cost = (input_tokens * prices["input"]) / 1_000_000
|
|
158
|
+
output_cost = (output_tokens * prices["output"]) / 1_000_000
|
|
159
|
+
total_cost = input_cost + output_cost
|
|
160
|
+
|
|
161
|
+
# Round to 6 decimal places for precision
|
|
162
|
+
return round(total_cost, 6)
|
|
163
|
+
|
|
164
|
+
async def record_token_usage(
|
|
165
|
+
self,
|
|
166
|
+
task_id: Optional[Union[int, str]],
|
|
167
|
+
agent_id: str,
|
|
168
|
+
project_id: int,
|
|
169
|
+
model_name: str,
|
|
170
|
+
input_tokens: int,
|
|
171
|
+
output_tokens: int,
|
|
172
|
+
call_type: CallType = CallType.OTHER,
|
|
173
|
+
session_id: Optional[str] = None, # NEW: SDK session tracking
|
|
174
|
+
) -> int:
|
|
175
|
+
"""Record token usage for an LLM call.
|
|
176
|
+
|
|
177
|
+
This method calculates the estimated cost and saves the usage record
|
|
178
|
+
to the database for later aggregation and analysis.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
task_id: Task ID if this call is related to a task (None for non-task calls)
|
|
182
|
+
agent_id: ID of the agent making the call
|
|
183
|
+
project_id: Project ID
|
|
184
|
+
model_name: Model identifier (e.g., "claude-sonnet-4-5")
|
|
185
|
+
input_tokens: Number of input tokens
|
|
186
|
+
output_tokens: Number of output tokens
|
|
187
|
+
call_type: Type of call (TASK_EXECUTION, CODE_REVIEW, COORDINATION, OTHER)
|
|
188
|
+
session_id: Optional SDK session ID for conversation tracking
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Database ID of the created token usage record
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
ValueError: If model_name is unknown or token counts are negative
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
>>> usage_id = await tracker.record_token_usage(
|
|
198
|
+
... task_id=27,
|
|
199
|
+
... agent_id="backend-001",
|
|
200
|
+
... project_id=1,
|
|
201
|
+
... model_name="claude-sonnet-4-5",
|
|
202
|
+
... input_tokens=1500,
|
|
203
|
+
... output_tokens=800,
|
|
204
|
+
... call_type=CallType.TASK_EXECUTION
|
|
205
|
+
... )
|
|
206
|
+
"""
|
|
207
|
+
# Validate inputs
|
|
208
|
+
if input_tokens < 0 or output_tokens < 0:
|
|
209
|
+
raise ValueError("Token counts cannot be negative")
|
|
210
|
+
|
|
211
|
+
# Calculate cost (returns 0.0 for unknown models)
|
|
212
|
+
estimated_cost = self.calculate_cost(model_name, input_tokens, output_tokens)
|
|
213
|
+
|
|
214
|
+
# Create TokenUsage model
|
|
215
|
+
token_usage = TokenUsage(
|
|
216
|
+
task_id=task_id,
|
|
217
|
+
actual_cost_usd=None,
|
|
218
|
+
agent_id=agent_id,
|
|
219
|
+
project_id=project_id,
|
|
220
|
+
model_name=model_name,
|
|
221
|
+
input_tokens=input_tokens,
|
|
222
|
+
output_tokens=output_tokens,
|
|
223
|
+
estimated_cost_usd=estimated_cost,
|
|
224
|
+
call_type=call_type,
|
|
225
|
+
session_id=session_id,
|
|
226
|
+
timestamp=datetime.now(timezone.utc),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Save to database
|
|
230
|
+
usage_id = self.db.save_token_usage(token_usage)
|
|
231
|
+
|
|
232
|
+
logger.info(
|
|
233
|
+
f"Recorded token usage: agent={agent_id}, model={model_name}, "
|
|
234
|
+
f"tokens={input_tokens + output_tokens}, cost=${estimated_cost:.6f}"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return usage_id
|
|
238
|
+
|
|
239
|
+
def record_token_usage_sync(
|
|
240
|
+
self,
|
|
241
|
+
task_id: Optional[Union[int, str]],
|
|
242
|
+
agent_id: str,
|
|
243
|
+
project_id: int,
|
|
244
|
+
model_name: str,
|
|
245
|
+
input_tokens: int,
|
|
246
|
+
output_tokens: int,
|
|
247
|
+
call_type: CallType = CallType.OTHER,
|
|
248
|
+
session_id: Optional[str] = None,
|
|
249
|
+
) -> int:
|
|
250
|
+
"""Record token usage for an LLM call (synchronous version).
|
|
251
|
+
|
|
252
|
+
Identical to record_token_usage but synchronous, for use from
|
|
253
|
+
synchronous code paths like the ReactAgent.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
task_id: Task ID if this call is related to a task (None for non-task calls)
|
|
257
|
+
agent_id: ID of the agent making the call
|
|
258
|
+
project_id: Project ID
|
|
259
|
+
model_name: Model identifier (e.g., "claude-sonnet-4-5")
|
|
260
|
+
input_tokens: Number of input tokens
|
|
261
|
+
output_tokens: Number of output tokens
|
|
262
|
+
call_type: Type of call (TASK_EXECUTION, CODE_REVIEW, COORDINATION, OTHER)
|
|
263
|
+
session_id: Optional SDK session ID for conversation tracking
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Database ID of the created token usage record
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
ValueError: If token counts are negative
|
|
270
|
+
"""
|
|
271
|
+
if input_tokens < 0 or output_tokens < 0:
|
|
272
|
+
raise ValueError("Token counts cannot be negative")
|
|
273
|
+
|
|
274
|
+
estimated_cost = self.calculate_cost(model_name, input_tokens, output_tokens)
|
|
275
|
+
|
|
276
|
+
token_usage = TokenUsage(
|
|
277
|
+
task_id=task_id,
|
|
278
|
+
actual_cost_usd=None,
|
|
279
|
+
agent_id=agent_id,
|
|
280
|
+
project_id=project_id,
|
|
281
|
+
model_name=model_name,
|
|
282
|
+
input_tokens=input_tokens,
|
|
283
|
+
output_tokens=output_tokens,
|
|
284
|
+
estimated_cost_usd=estimated_cost,
|
|
285
|
+
call_type=call_type,
|
|
286
|
+
session_id=session_id,
|
|
287
|
+
timestamp=datetime.now(timezone.utc),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
usage_id = self.db.save_token_usage(token_usage)
|
|
291
|
+
|
|
292
|
+
logger.info(
|
|
293
|
+
f"Recorded token usage (sync): agent={agent_id}, model={model_name}, "
|
|
294
|
+
f"tokens={input_tokens + output_tokens}, cost=${estimated_cost:.6f}"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
return usage_id
|
|
298
|
+
|
|
299
|
+
def get_task_token_summary(self, task_id: int) -> Dict[str, Any]:
|
|
300
|
+
"""Get aggregated token usage summary for a single task.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
task_id: Task ID to summarize
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Dictionary with aggregated token data:
|
|
307
|
+
{
|
|
308
|
+
"task_id": int,
|
|
309
|
+
"total_input_tokens": int,
|
|
310
|
+
"total_output_tokens": int,
|
|
311
|
+
"total_tokens": int,
|
|
312
|
+
"total_cost_usd": float,
|
|
313
|
+
"call_count": int,
|
|
314
|
+
}
|
|
315
|
+
"""
|
|
316
|
+
return self.db.get_task_token_summary(task_id)
|
|
317
|
+
|
|
318
|
+
def get_workspace_costs(
|
|
319
|
+
self,
|
|
320
|
+
start_date: Optional[datetime] = None,
|
|
321
|
+
end_date: Optional[datetime] = None,
|
|
322
|
+
) -> Dict[str, Any]:
|
|
323
|
+
"""Get aggregated costs across all tasks in the workspace.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
start_date: Optional start of date range (inclusive)
|
|
327
|
+
end_date: Optional end of date range (inclusive)
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Dictionary with cost breakdown:
|
|
331
|
+
{
|
|
332
|
+
"total_cost_usd": float,
|
|
333
|
+
"total_tokens": int,
|
|
334
|
+
"total_calls": int,
|
|
335
|
+
}
|
|
336
|
+
"""
|
|
337
|
+
records = self.db.get_workspace_token_usage(
|
|
338
|
+
start_date=start_date, end_date=end_date
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
result: Dict[str, Any] = {
|
|
342
|
+
"total_cost_usd": 0.0,
|
|
343
|
+
"total_tokens": 0,
|
|
344
|
+
"total_calls": len(records),
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
for record in records:
|
|
348
|
+
result["total_cost_usd"] += record["estimated_cost_usd"]
|
|
349
|
+
result["total_tokens"] += record["input_tokens"] + record["output_tokens"]
|
|
350
|
+
|
|
351
|
+
result["total_cost_usd"] = round(result["total_cost_usd"], 6)
|
|
352
|
+
return result
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def export_to_csv(records: List[Dict[str, Any]], output_path: str) -> None:
|
|
356
|
+
"""Export token usage records to a CSV file.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
records: List of token usage record dictionaries
|
|
360
|
+
output_path: Path to write the CSV file
|
|
361
|
+
"""
|
|
362
|
+
fieldnames = [
|
|
363
|
+
"id", "task_id", "agent_id", "project_id", "model_name",
|
|
364
|
+
"input_tokens", "output_tokens", "estimated_cost_usd",
|
|
365
|
+
"actual_cost_usd", "call_type", "session_id", "timestamp",
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
with open(output_path, "w", newline="") as f:
|
|
369
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
|
370
|
+
writer.writeheader()
|
|
371
|
+
for record in records:
|
|
372
|
+
writer.writerow(record)
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def export_to_json(records: List[Dict[str, Any]], output_path: str) -> None:
|
|
376
|
+
"""Export token usage records to a JSON file with metadata.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
records: List of token usage record dictionaries
|
|
380
|
+
output_path: Path to write the JSON file
|
|
381
|
+
"""
|
|
382
|
+
# Convert sqlite3.Row objects to plain dicts if needed
|
|
383
|
+
serializable_records = []
|
|
384
|
+
for record in records:
|
|
385
|
+
row = dict(record)
|
|
386
|
+
# Ensure all values are JSON-serializable
|
|
387
|
+
for key, value in row.items():
|
|
388
|
+
if isinstance(value, datetime):
|
|
389
|
+
row[key] = value.isoformat()
|
|
390
|
+
serializable_records.append(row)
|
|
391
|
+
|
|
392
|
+
data = {
|
|
393
|
+
"metadata": {
|
|
394
|
+
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
395
|
+
"record_count": len(serializable_records),
|
|
396
|
+
},
|
|
397
|
+
"records": serializable_records,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
with open(output_path, "w") as f:
|
|
401
|
+
json.dump(data, f, indent=2, default=str)
|
|
402
|
+
|
|
403
|
+
async def get_project_costs(
|
|
404
|
+
self,
|
|
405
|
+
project_id: int,
|
|
406
|
+
start_date: Optional[datetime] = None,
|
|
407
|
+
end_date: Optional[datetime] = None,
|
|
408
|
+
) -> Dict[str, Any]:
|
|
409
|
+
"""Get total costs and breakdown for a project.
|
|
410
|
+
|
|
411
|
+
Aggregates all token usage records for the project and provides
|
|
412
|
+
breakdowns by agent and model. Optionally filter by date range.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
project_id: Project ID to get costs for
|
|
416
|
+
start_date: Optional start of date range (inclusive)
|
|
417
|
+
end_date: Optional end of date range (inclusive)
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
Dictionary with cost breakdown:
|
|
421
|
+
{
|
|
422
|
+
"project_id": int,
|
|
423
|
+
"total_cost_usd": float,
|
|
424
|
+
"total_tokens": int,
|
|
425
|
+
"total_calls": int,
|
|
426
|
+
"by_agent": [
|
|
427
|
+
{"agent_id": str, "cost_usd": float, "total_tokens": int, "call_count": int},
|
|
428
|
+
...
|
|
429
|
+
],
|
|
430
|
+
"by_model": [
|
|
431
|
+
{"model_name": str, "cost_usd": float, "total_tokens": int, "call_count": int},
|
|
432
|
+
...
|
|
433
|
+
]
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
Example:
|
|
437
|
+
>>> costs = await tracker.get_project_costs(project_id=1)
|
|
438
|
+
>>> print(f"Total: ${costs['total_cost_usd']:.2f}")
|
|
439
|
+
>>> for agent in costs['by_agent']:
|
|
440
|
+
... print(f" {agent['agent_id']}: ${agent['cost_usd']:.2f}")
|
|
441
|
+
"""
|
|
442
|
+
# Get usage records for project (optionally filtered by date)
|
|
443
|
+
usage_records = self.db.get_token_usage(
|
|
444
|
+
project_id=project_id, start_date=start_date, end_date=end_date
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Initialize result
|
|
448
|
+
result = {
|
|
449
|
+
"project_id": project_id,
|
|
450
|
+
"total_cost_usd": 0.0,
|
|
451
|
+
"total_tokens": 0,
|
|
452
|
+
"total_calls": len(usage_records),
|
|
453
|
+
"by_agent": [],
|
|
454
|
+
"by_model": [],
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if not usage_records:
|
|
458
|
+
return result
|
|
459
|
+
|
|
460
|
+
# Aggregate by agent
|
|
461
|
+
agent_stats: Dict[str, Dict[str, Any]] = {}
|
|
462
|
+
model_stats: Dict[str, Dict[str, Any]] = {}
|
|
463
|
+
|
|
464
|
+
for record in usage_records:
|
|
465
|
+
cost = record["estimated_cost_usd"]
|
|
466
|
+
tokens = record["input_tokens"] + record["output_tokens"]
|
|
467
|
+
agent_id = record["agent_id"]
|
|
468
|
+
model_name = record["model_name"]
|
|
469
|
+
|
|
470
|
+
# Update totals
|
|
471
|
+
result["total_cost_usd"] += cost
|
|
472
|
+
result["total_tokens"] += tokens
|
|
473
|
+
|
|
474
|
+
# Update agent stats
|
|
475
|
+
if agent_id not in agent_stats:
|
|
476
|
+
agent_stats[agent_id] = {
|
|
477
|
+
"agent_id": agent_id,
|
|
478
|
+
"cost_usd": 0.0,
|
|
479
|
+
"total_tokens": 0,
|
|
480
|
+
"call_count": 0,
|
|
481
|
+
}
|
|
482
|
+
agent_stats[agent_id]["cost_usd"] += cost
|
|
483
|
+
agent_stats[agent_id]["total_tokens"] += tokens
|
|
484
|
+
agent_stats[agent_id]["call_count"] += 1
|
|
485
|
+
|
|
486
|
+
# Update model stats
|
|
487
|
+
if model_name not in model_stats:
|
|
488
|
+
model_stats[model_name] = {
|
|
489
|
+
"model_name": model_name,
|
|
490
|
+
"cost_usd": 0.0,
|
|
491
|
+
"total_tokens": 0,
|
|
492
|
+
"call_count": 0,
|
|
493
|
+
}
|
|
494
|
+
model_stats[model_name]["cost_usd"] += cost
|
|
495
|
+
model_stats[model_name]["total_tokens"] += tokens
|
|
496
|
+
model_stats[model_name]["call_count"] += 1
|
|
497
|
+
|
|
498
|
+
# Convert to lists and round costs
|
|
499
|
+
result["total_cost_usd"] = round(result["total_cost_usd"], 6) # type: ignore[call-overload]
|
|
500
|
+
result["by_agent"] = [
|
|
501
|
+
{**stats, "cost_usd": round(stats["cost_usd"], 6)}
|
|
502
|
+
for stats in agent_stats.values()
|
|
503
|
+
]
|
|
504
|
+
result["by_model"] = [
|
|
505
|
+
{**stats, "cost_usd": round(stats["cost_usd"], 6)}
|
|
506
|
+
for stats in model_stats.values()
|
|
507
|
+
]
|
|
508
|
+
|
|
509
|
+
return result
|
|
510
|
+
|
|
511
|
+
async def get_agent_costs(self, agent_id: str) -> Dict[str, Any]:
|
|
512
|
+
"""Get costs for a specific agent across all projects.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
agent_id: Agent ID to get costs for
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
Dictionary with cost breakdown:
|
|
519
|
+
{
|
|
520
|
+
"agent_id": str,
|
|
521
|
+
"total_cost_usd": float,
|
|
522
|
+
"total_tokens": int,
|
|
523
|
+
"total_calls": int,
|
|
524
|
+
"by_call_type": [
|
|
525
|
+
{"call_type": str, "cost_usd": float, "calls": int},
|
|
526
|
+
...
|
|
527
|
+
],
|
|
528
|
+
"by_project": [
|
|
529
|
+
{"project_id": int, "cost_usd": float},
|
|
530
|
+
...
|
|
531
|
+
]
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
Example:
|
|
535
|
+
>>> costs = await tracker.get_agent_costs(agent_id="backend-001")
|
|
536
|
+
>>> print(f"Agent total: ${costs['total_cost_usd']:.2f}")
|
|
537
|
+
"""
|
|
538
|
+
# Get all usage records for agent
|
|
539
|
+
usage_records = self.db.get_token_usage(agent_id=agent_id)
|
|
540
|
+
|
|
541
|
+
# Initialize result
|
|
542
|
+
result = {
|
|
543
|
+
"agent_id": agent_id,
|
|
544
|
+
"total_cost_usd": 0.0,
|
|
545
|
+
"total_tokens": 0,
|
|
546
|
+
"total_calls": len(usage_records),
|
|
547
|
+
"by_call_type": [],
|
|
548
|
+
"by_project": [],
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if not usage_records:
|
|
552
|
+
return result
|
|
553
|
+
|
|
554
|
+
# Aggregate by call type and project
|
|
555
|
+
call_type_stats: Dict[str, Dict[str, Any]] = {}
|
|
556
|
+
project_stats: Dict[int, Dict[str, Any]] = {}
|
|
557
|
+
|
|
558
|
+
for record in usage_records:
|
|
559
|
+
cost = record["estimated_cost_usd"]
|
|
560
|
+
tokens = record["input_tokens"] + record["output_tokens"]
|
|
561
|
+
call_type = record["call_type"]
|
|
562
|
+
project_id = record["project_id"]
|
|
563
|
+
|
|
564
|
+
# Update totals
|
|
565
|
+
result["total_cost_usd"] += cost
|
|
566
|
+
result["total_tokens"] += tokens
|
|
567
|
+
|
|
568
|
+
# Update call type stats
|
|
569
|
+
if call_type not in call_type_stats:
|
|
570
|
+
call_type_stats[call_type] = {
|
|
571
|
+
"call_type": call_type,
|
|
572
|
+
"cost_usd": 0.0,
|
|
573
|
+
"call_count": 0,
|
|
574
|
+
}
|
|
575
|
+
call_type_stats[call_type]["cost_usd"] += cost
|
|
576
|
+
call_type_stats[call_type]["call_count"] += 1
|
|
577
|
+
|
|
578
|
+
# Update project stats
|
|
579
|
+
if project_id not in project_stats:
|
|
580
|
+
project_stats[project_id] = {"project_id": project_id, "cost_usd": 0.0}
|
|
581
|
+
project_stats[project_id]["cost_usd"] += cost
|
|
582
|
+
|
|
583
|
+
# Convert to lists and round costs
|
|
584
|
+
result["total_cost_usd"] = round(result["total_cost_usd"], 6) # type: ignore[call-overload]
|
|
585
|
+
result["by_call_type"] = [
|
|
586
|
+
{**stats, "cost_usd": round(stats["cost_usd"], 6)}
|
|
587
|
+
for stats in call_type_stats.values()
|
|
588
|
+
]
|
|
589
|
+
result["by_project"] = [
|
|
590
|
+
{**stats, "cost_usd": round(stats["cost_usd"], 6)}
|
|
591
|
+
for stats in project_stats.values()
|
|
592
|
+
]
|
|
593
|
+
|
|
594
|
+
return result
|
|
595
|
+
|
|
596
|
+
async def get_token_usage_stats(
|
|
597
|
+
self,
|
|
598
|
+
project_id: int,
|
|
599
|
+
start_date: Optional[datetime] = None,
|
|
600
|
+
end_date: Optional[datetime] = None,
|
|
601
|
+
) -> Dict[str, Any]:
|
|
602
|
+
"""Get token usage statistics for a date range.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
project_id: Project ID to get stats for
|
|
606
|
+
start_date: Start of date range (inclusive, optional)
|
|
607
|
+
end_date: End of date range (inclusive, optional)
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
Dictionary with usage statistics:
|
|
611
|
+
{
|
|
612
|
+
"project_id": int,
|
|
613
|
+
"total_cost_usd": float,
|
|
614
|
+
"total_tokens": int,
|
|
615
|
+
"total_calls": int,
|
|
616
|
+
"date_range": {
|
|
617
|
+
"start": str (ISO format),
|
|
618
|
+
"end": str (ISO format)
|
|
619
|
+
},
|
|
620
|
+
"by_day": [
|
|
621
|
+
{"date": str, "cost_usd": float, "tokens": int, "calls": int},
|
|
622
|
+
...
|
|
623
|
+
]
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
Example:
|
|
627
|
+
>>> from datetime import datetime, timedelta
|
|
628
|
+
>>> start = datetime.now() - timedelta(days=7)
|
|
629
|
+
>>> stats = await tracker.get_token_usage_stats(
|
|
630
|
+
... project_id=1,
|
|
631
|
+
... start_date=start
|
|
632
|
+
... )
|
|
633
|
+
>>> print(f"Last 7 days: ${stats['total_cost_usd']:.2f}")
|
|
634
|
+
"""
|
|
635
|
+
# Get usage records with date filtering
|
|
636
|
+
usage_records = self.db.get_token_usage(
|
|
637
|
+
project_id=project_id, start_date=start_date, end_date=end_date
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# Initialize result
|
|
641
|
+
result = {
|
|
642
|
+
"project_id": project_id,
|
|
643
|
+
"total_cost_usd": 0.0,
|
|
644
|
+
"total_tokens": 0,
|
|
645
|
+
"total_calls": len(usage_records),
|
|
646
|
+
"date_range": {
|
|
647
|
+
"start": start_date.isoformat() if start_date else None,
|
|
648
|
+
"end": end_date.isoformat() if end_date else None,
|
|
649
|
+
},
|
|
650
|
+
"by_day": [],
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if not usage_records:
|
|
654
|
+
return result
|
|
655
|
+
|
|
656
|
+
# Aggregate totals
|
|
657
|
+
for record in usage_records:
|
|
658
|
+
result["total_cost_usd"] += record["estimated_cost_usd"]
|
|
659
|
+
result["total_tokens"] += record["input_tokens"] + record["output_tokens"]
|
|
660
|
+
|
|
661
|
+
# Round cost
|
|
662
|
+
result["total_cost_usd"] = round(result["total_cost_usd"], 6) # type: ignore[call-overload]
|
|
663
|
+
|
|
664
|
+
# TODO: Implement by_day aggregation (future enhancement)
|
|
665
|
+
# This would group usage by date for timeline visualization
|
|
666
|
+
|
|
667
|
+
return result
|
|
668
|
+
|
|
669
|
+
async def get_token_usage_timeseries(
|
|
670
|
+
self,
|
|
671
|
+
project_id: int,
|
|
672
|
+
start_date: datetime,
|
|
673
|
+
end_date: datetime,
|
|
674
|
+
interval: str = "day",
|
|
675
|
+
) -> list[dict[str, Any]]:
|
|
676
|
+
"""Get token usage aggregated by time intervals for charting.
|
|
677
|
+
|
|
678
|
+
Groups token usage records into time buckets (hour, day, or week) for
|
|
679
|
+
visualization in time series charts. Each bucket contains aggregated
|
|
680
|
+
token counts and costs.
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
project_id: Project ID to get time series for
|
|
684
|
+
start_date: Start of date range (inclusive)
|
|
685
|
+
end_date: End of date range (inclusive)
|
|
686
|
+
interval: Time interval for grouping ('hour', 'day', 'week')
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
List of time series data points, each containing:
|
|
690
|
+
{
|
|
691
|
+
"timestamp": str (ISO 8601 format),
|
|
692
|
+
"input_tokens": int,
|
|
693
|
+
"output_tokens": int,
|
|
694
|
+
"total_tokens": int,
|
|
695
|
+
"cost_usd": float
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
Raises:
|
|
699
|
+
ValueError: If interval is not one of 'hour', 'day', 'week'
|
|
700
|
+
|
|
701
|
+
Example:
|
|
702
|
+
>>> from datetime import datetime, timedelta
|
|
703
|
+
>>> start = datetime.now() - timedelta(days=7)
|
|
704
|
+
>>> end = datetime.now()
|
|
705
|
+
>>> series = await tracker.get_token_usage_timeseries(
|
|
706
|
+
... project_id=1,
|
|
707
|
+
... start_date=start,
|
|
708
|
+
... end_date=end,
|
|
709
|
+
... interval='day'
|
|
710
|
+
... )
|
|
711
|
+
>>> for point in series:
|
|
712
|
+
... print(f"{point['timestamp']}: {point['total_tokens']} tokens")
|
|
713
|
+
"""
|
|
714
|
+
valid_intervals = ("hour", "day", "week")
|
|
715
|
+
if interval not in valid_intervals:
|
|
716
|
+
raise ValueError(
|
|
717
|
+
f"Invalid interval '{interval}'. Must be one of: {', '.join(valid_intervals)}"
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
# Get usage records with date filtering
|
|
721
|
+
usage_records = self.db.get_token_usage(
|
|
722
|
+
project_id=project_id, start_date=start_date, end_date=end_date
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
if not usage_records:
|
|
726
|
+
return []
|
|
727
|
+
|
|
728
|
+
# Group records by time bucket
|
|
729
|
+
buckets: dict[str, dict[str, Any]] = {}
|
|
730
|
+
|
|
731
|
+
for record in usage_records:
|
|
732
|
+
# Parse timestamp - handle string, naive datetime, and aware datetime
|
|
733
|
+
timestamp = record["timestamp"]
|
|
734
|
+
if isinstance(timestamp, str):
|
|
735
|
+
# Handle both ISO 8601 and simple date formats
|
|
736
|
+
timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
737
|
+
elif timestamp.tzinfo is None:
|
|
738
|
+
# Assume UTC for naive datetimes from database
|
|
739
|
+
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
|
740
|
+
|
|
741
|
+
# Calculate bucket key based on interval
|
|
742
|
+
bucket_key = self._get_bucket_key(timestamp, interval)
|
|
743
|
+
|
|
744
|
+
# Initialize bucket if not exists
|
|
745
|
+
if bucket_key not in buckets:
|
|
746
|
+
buckets[bucket_key] = {
|
|
747
|
+
"timestamp": bucket_key,
|
|
748
|
+
"input_tokens": 0,
|
|
749
|
+
"output_tokens": 0,
|
|
750
|
+
"total_tokens": 0,
|
|
751
|
+
"cost_usd": 0.0,
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
# Aggregate values
|
|
755
|
+
buckets[bucket_key]["input_tokens"] += record["input_tokens"]
|
|
756
|
+
buckets[bucket_key]["output_tokens"] += record["output_tokens"]
|
|
757
|
+
buckets[bucket_key]["total_tokens"] += (
|
|
758
|
+
record["input_tokens"] + record["output_tokens"]
|
|
759
|
+
)
|
|
760
|
+
buckets[bucket_key]["cost_usd"] += record["estimated_cost_usd"]
|
|
761
|
+
|
|
762
|
+
# Round costs and sort by timestamp
|
|
763
|
+
result = []
|
|
764
|
+
for bucket in buckets.values():
|
|
765
|
+
bucket["cost_usd"] = round(bucket["cost_usd"], 6)
|
|
766
|
+
result.append(bucket)
|
|
767
|
+
|
|
768
|
+
# Sort by timestamp
|
|
769
|
+
result.sort(key=lambda x: x["timestamp"])
|
|
770
|
+
|
|
771
|
+
return result
|
|
772
|
+
|
|
773
|
+
def _get_bucket_key(self, timestamp: datetime, interval: str) -> str:
|
|
774
|
+
"""Get the bucket key for a timestamp based on the interval.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
timestamp: Datetime to get bucket key for
|
|
778
|
+
interval: Time interval ('hour', 'day', 'week')
|
|
779
|
+
|
|
780
|
+
Returns:
|
|
781
|
+
ISO 8601 formatted string representing the bucket start time
|
|
782
|
+
"""
|
|
783
|
+
if interval == "hour":
|
|
784
|
+
# Truncate to start of hour
|
|
785
|
+
bucket_start = timestamp.replace(minute=0, second=0, microsecond=0)
|
|
786
|
+
elif interval == "day":
|
|
787
|
+
# Truncate to start of day
|
|
788
|
+
bucket_start = timestamp.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
789
|
+
elif interval == "week":
|
|
790
|
+
# Truncate to start of ISO week (Monday)
|
|
791
|
+
# Get the weekday (0=Monday, 6=Sunday)
|
|
792
|
+
days_since_monday = timestamp.weekday()
|
|
793
|
+
bucket_start = timestamp.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
794
|
+
bucket_start = bucket_start - timedelta(days=days_since_monday)
|
|
795
|
+
else:
|
|
796
|
+
# This should never be reached due to validation in get_token_usage_timeseries
|
|
797
|
+
raise ValueError(f"Invalid interval: {interval}")
|
|
798
|
+
|
|
799
|
+
# Return ISO format with Z suffix for UTC
|
|
800
|
+
return bucket_start.strftime("%Y-%m-%dT%H:%M:%SZ")
|