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.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. 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")