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,322 @@
|
|
|
1
|
+
"""Cost analytics API router for CodeFRAME v2 (issue #557).
|
|
2
|
+
|
|
3
|
+
Aggregates the workspace's `token_usage` table into a daily-bucket summary
|
|
4
|
+
for the /costs page in the web UI. Hosts a single endpoint:
|
|
5
|
+
|
|
6
|
+
GET /api/v2/costs/summary?days=30
|
|
7
|
+
|
|
8
|
+
Returns an empty-state payload (all zeros, zero-filled daily series) when
|
|
9
|
+
no spend data exists or the table isn't present — never 404.
|
|
10
|
+
|
|
11
|
+
The handler opens the workspace SQLite database directly to avoid the
|
|
12
|
+
pre-existing schema conflict between `codeframe/core/workspace.py` and
|
|
13
|
+
`codeframe/platform_store/schema_manager.py` — wiring `TokenRepository`
|
|
14
|
+
to a raw connection skips `Database.initialize()` entirely.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import sqlite3
|
|
19
|
+
from datetime import datetime, timedelta, timezone
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from fastapi import APIRouter, Depends, Query, Request
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
from codeframe.core import tasks as tasks_module
|
|
26
|
+
from codeframe.core.workspace import Workspace
|
|
27
|
+
from codeframe.lib.rate_limiter import rate_limit_standard
|
|
28
|
+
from codeframe.platform_store.repositories.token_repository import TokenRepository
|
|
29
|
+
from codeframe.ui.dependencies import get_v2_workspace
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
router = APIRouter(prefix="/api/v2/costs", tags=["metrics"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DailyCostPoint(BaseModel):
|
|
37
|
+
"""One day of aggregated spend."""
|
|
38
|
+
|
|
39
|
+
date: str # ISO format YYYY-MM-DD
|
|
40
|
+
cost_usd: float
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CostSummaryResponse(BaseModel):
|
|
44
|
+
"""Aggregated spend over the requested window."""
|
|
45
|
+
|
|
46
|
+
total_spend_usd: float
|
|
47
|
+
total_tasks: int
|
|
48
|
+
avg_cost_per_task: float
|
|
49
|
+
daily: List[DailyCostPoint]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _empty_summary(days: int) -> Dict:
|
|
53
|
+
"""Build a zero-state response with `days` daily buckets."""
|
|
54
|
+
end_date = datetime.now(timezone.utc).date()
|
|
55
|
+
start_date = end_date - timedelta(days=days - 1)
|
|
56
|
+
daily = [
|
|
57
|
+
{"date": (start_date + timedelta(days=i)).isoformat(), "cost_usd": 0.0}
|
|
58
|
+
for i in range(days)
|
|
59
|
+
]
|
|
60
|
+
return {
|
|
61
|
+
"total_spend_usd": 0.0,
|
|
62
|
+
"total_tasks": 0,
|
|
63
|
+
"avg_cost_per_task": 0.0,
|
|
64
|
+
"daily": daily,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _query_costs(db_path: str, days: int) -> Dict:
|
|
69
|
+
"""Query the workspace DB via TokenRepository on a raw connection.
|
|
70
|
+
|
|
71
|
+
Returns an empty summary if the DB can't be opened or the table is missing,
|
|
72
|
+
rather than raising — keeps the endpoint safe for fresh workspaces.
|
|
73
|
+
|
|
74
|
+
TODO(schema-conflict): we open the connection directly rather than through
|
|
75
|
+
`Database(...).initialize()` because the v2 workspace schema in
|
|
76
|
+
`codeframe/core/workspace.py` and the global schema in
|
|
77
|
+
`persistence/schema_manager.py` define `blockers` incompatibly, and
|
|
78
|
+
`Database.initialize()` therefore crashes on existing workspace DBs.
|
|
79
|
+
Remove this workaround once the two schemas converge.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
conn = sqlite3.connect(db_path)
|
|
83
|
+
conn.row_factory = sqlite3.Row
|
|
84
|
+
except sqlite3.Error as e:
|
|
85
|
+
logger.warning("costs: failed to open %s: %s", db_path, e)
|
|
86
|
+
return _empty_summary(days)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
try:
|
|
90
|
+
cursor = conn.cursor()
|
|
91
|
+
cursor.execute(
|
|
92
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage'"
|
|
93
|
+
)
|
|
94
|
+
if cursor.fetchone() is None:
|
|
95
|
+
return _empty_summary(days)
|
|
96
|
+
|
|
97
|
+
repo = TokenRepository(sync_conn=conn)
|
|
98
|
+
return repo.get_costs_summary(days)
|
|
99
|
+
except sqlite3.Error as e:
|
|
100
|
+
# Locked DB, corrupted schema, etc. — fall back to empty state
|
|
101
|
+
# rather than 500'ing the dashboard.
|
|
102
|
+
logger.warning("costs: query failed on %s: %s", db_path, e)
|
|
103
|
+
return _empty_summary(days)
|
|
104
|
+
finally:
|
|
105
|
+
conn.close()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@router.get("/summary", response_model=CostSummaryResponse)
|
|
109
|
+
@rate_limit_standard()
|
|
110
|
+
async def get_costs_summary(
|
|
111
|
+
request: Request,
|
|
112
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
113
|
+
days: int = Query(30, ge=7, le=90, description="Window size in days (7-90)"),
|
|
114
|
+
):
|
|
115
|
+
"""Return total spend, task count, average cost, and a daily series.
|
|
116
|
+
|
|
117
|
+
Reads from the workspace's `token_usage` table. Returns zero-filled
|
|
118
|
+
daily buckets so the client can render a chart without conditionals.
|
|
119
|
+
If the table doesn't exist (no agent has run in this workspace yet),
|
|
120
|
+
returns an empty-state response rather than an error.
|
|
121
|
+
"""
|
|
122
|
+
summary = _query_costs(str(workspace.db_path), days)
|
|
123
|
+
return CostSummaryResponse(
|
|
124
|
+
total_spend_usd=summary["total_spend_usd"],
|
|
125
|
+
total_tasks=summary["total_tasks"],
|
|
126
|
+
avg_cost_per_task=summary["avg_cost_per_task"],
|
|
127
|
+
daily=[
|
|
128
|
+
DailyCostPoint(date=d["date"], cost_usd=d["cost_usd"])
|
|
129
|
+
for d in summary["daily"]
|
|
130
|
+
],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Per-task and per-agent breakdowns (Issue #558)
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TaskCostEntry(BaseModel):
|
|
140
|
+
"""One task's aggregated cost with the most-used agent."""
|
|
141
|
+
|
|
142
|
+
task_id: str
|
|
143
|
+
task_title: str
|
|
144
|
+
agent_id: str
|
|
145
|
+
input_tokens: int
|
|
146
|
+
output_tokens: int
|
|
147
|
+
total_cost_usd: float
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TaskCostsResponse(BaseModel):
|
|
151
|
+
"""Top-N tasks by cost over the requested window."""
|
|
152
|
+
|
|
153
|
+
tasks: List[TaskCostEntry]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class AgentCostEntry(BaseModel):
|
|
157
|
+
"""One agent's aggregated cost over the window."""
|
|
158
|
+
|
|
159
|
+
agent_id: str
|
|
160
|
+
input_tokens: int
|
|
161
|
+
output_tokens: int
|
|
162
|
+
total_cost_usd: float
|
|
163
|
+
call_count: int
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class AgentCostsResponse(BaseModel):
|
|
167
|
+
"""Per-agent breakdown plus overall token totals."""
|
|
168
|
+
|
|
169
|
+
by_agent: List[AgentCostEntry]
|
|
170
|
+
total_input_tokens: int
|
|
171
|
+
total_output_tokens: int
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _placeholder_task_title(task_id: str) -> str:
|
|
175
|
+
"""Title to display when a task referenced by token_usage no longer exists."""
|
|
176
|
+
short = str(task_id)[:8] if task_id else "unknown"
|
|
177
|
+
return f"Unknown task ({short})"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _open_workspace_conn(db_path: str) -> Optional[sqlite3.Connection]:
|
|
181
|
+
"""Open the workspace DB or return None if it cannot be read.
|
|
182
|
+
|
|
183
|
+
Mirrors _query_costs's tolerance for fresh/locked workspaces: callers
|
|
184
|
+
fall back to an empty response rather than 500'ing the dashboard.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
conn = sqlite3.connect(db_path)
|
|
188
|
+
conn.row_factory = sqlite3.Row
|
|
189
|
+
return conn
|
|
190
|
+
except sqlite3.Error as e:
|
|
191
|
+
logger.warning("costs: failed to open %s: %s", db_path, e)
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _token_usage_exists(conn: sqlite3.Connection) -> bool:
|
|
196
|
+
cursor = conn.cursor()
|
|
197
|
+
cursor.execute(
|
|
198
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage'"
|
|
199
|
+
)
|
|
200
|
+
return cursor.fetchone() is not None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _query_top_tasks(
|
|
204
|
+
db_path: str, workspace: Workspace, days: int, limit: int,
|
|
205
|
+
) -> List[Dict[str, Any]]:
|
|
206
|
+
"""Aggregate per-task cost and join titles via workspace.tasks.
|
|
207
|
+
|
|
208
|
+
Returns a list of dicts ready for serialization into ``TaskCostEntry``.
|
|
209
|
+
"""
|
|
210
|
+
conn = _open_workspace_conn(db_path)
|
|
211
|
+
if conn is None:
|
|
212
|
+
return []
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
if not _token_usage_exists(conn):
|
|
216
|
+
return []
|
|
217
|
+
try:
|
|
218
|
+
repo = TokenRepository(sync_conn=conn)
|
|
219
|
+
rows = repo.get_top_tasks_by_cost(days=days, limit=limit)
|
|
220
|
+
except sqlite3.Error as e:
|
|
221
|
+
logger.warning("costs/tasks: query failed on %s: %s", db_path, e)
|
|
222
|
+
return []
|
|
223
|
+
finally:
|
|
224
|
+
conn.close()
|
|
225
|
+
|
|
226
|
+
entries: List[Dict[str, Any]] = []
|
|
227
|
+
for row in rows:
|
|
228
|
+
raw_id = row["task_id"]
|
|
229
|
+
task_id_str = str(raw_id) if raw_id is not None else ""
|
|
230
|
+
title = _placeholder_task_title(task_id_str)
|
|
231
|
+
try:
|
|
232
|
+
task = tasks_module.get(workspace, task_id_str)
|
|
233
|
+
if task is not None:
|
|
234
|
+
title = task.title
|
|
235
|
+
except Exception:
|
|
236
|
+
# Lookup failures are non-fatal — keep the placeholder title.
|
|
237
|
+
logger.debug("costs/tasks: task lookup failed for %s", task_id_str, exc_info=True)
|
|
238
|
+
|
|
239
|
+
entries.append({
|
|
240
|
+
"task_id": task_id_str,
|
|
241
|
+
"task_title": title,
|
|
242
|
+
"agent_id": row["agent_id"],
|
|
243
|
+
"input_tokens": row["input_tokens"],
|
|
244
|
+
"output_tokens": row["output_tokens"],
|
|
245
|
+
"total_cost_usd": row["total_cost_usd"],
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
return entries
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _query_costs_by_agent(db_path: str, days: int) -> Dict[str, Any]:
|
|
252
|
+
"""Aggregate per-agent cost over the window."""
|
|
253
|
+
empty = {"by_agent": [], "total_input_tokens": 0, "total_output_tokens": 0}
|
|
254
|
+
|
|
255
|
+
conn = _open_workspace_conn(db_path)
|
|
256
|
+
if conn is None:
|
|
257
|
+
return empty
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
if not _token_usage_exists(conn):
|
|
261
|
+
return empty
|
|
262
|
+
try:
|
|
263
|
+
repo = TokenRepository(sync_conn=conn)
|
|
264
|
+
return repo.get_costs_by_agent(days=days)
|
|
265
|
+
except sqlite3.Error as e:
|
|
266
|
+
logger.warning("costs/by-agent: query failed on %s: %s", db_path, e)
|
|
267
|
+
return empty
|
|
268
|
+
finally:
|
|
269
|
+
conn.close()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@router.get("/tasks", response_model=TaskCostsResponse)
|
|
273
|
+
@rate_limit_standard()
|
|
274
|
+
async def get_costs_by_task(
|
|
275
|
+
request: Request,
|
|
276
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
277
|
+
days: int = Query(30, ge=1, le=365, description="Window size in days (1-365)"),
|
|
278
|
+
limit: int = Query(
|
|
279
|
+
10,
|
|
280
|
+
ge=1,
|
|
281
|
+
le=1000,
|
|
282
|
+
description=(
|
|
283
|
+
"Max number of tasks to return. Default 10 matches the analytics view; "
|
|
284
|
+
"raise it (e.g. to 1000) when populating a per-task badge map for the "
|
|
285
|
+
"full task board."
|
|
286
|
+
),
|
|
287
|
+
),
|
|
288
|
+
):
|
|
289
|
+
"""Return the top ``limit`` tasks by total cost over the requested window.
|
|
290
|
+
|
|
291
|
+
Token usage rows are grouped by ``task_id``; the resulting list is sorted
|
|
292
|
+
by total cost (descending). Rows whose ``task_id`` is NULL are excluded —
|
|
293
|
+
only task-attributable spend counts here.
|
|
294
|
+
|
|
295
|
+
If the workspace has no token usage data yet (or the table doesn't exist),
|
|
296
|
+
returns ``{"tasks": []}`` rather than an error.
|
|
297
|
+
"""
|
|
298
|
+
entries = _query_top_tasks(str(workspace.db_path), workspace, days, limit=limit)
|
|
299
|
+
return TaskCostsResponse(
|
|
300
|
+
tasks=[TaskCostEntry(**e) for e in entries],
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@router.get("/by-agent", response_model=AgentCostsResponse)
|
|
305
|
+
@rate_limit_standard()
|
|
306
|
+
async def get_costs_by_agent_endpoint(
|
|
307
|
+
request: Request,
|
|
308
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
309
|
+
days: int = Query(30, ge=1, le=365, description="Window size in days (1-365)"),
|
|
310
|
+
):
|
|
311
|
+
"""Return per-agent cost breakdown and overall input/output token totals.
|
|
312
|
+
|
|
313
|
+
Token usage rows are grouped by ``agent_id`` and sorted by total cost
|
|
314
|
+
(descending). Rows with NULL ``task_id`` still count toward the agent's
|
|
315
|
+
totals (a non-task call still represents spend).
|
|
316
|
+
"""
|
|
317
|
+
summary = _query_costs_by_agent(str(workspace.db_path), days)
|
|
318
|
+
return AgentCostsResponse(
|
|
319
|
+
by_agent=[AgentCostEntry(**a) for a in summary["by_agent"]],
|
|
320
|
+
total_input_tokens=summary["total_input_tokens"],
|
|
321
|
+
total_output_tokens=summary["total_output_tokens"],
|
|
322
|
+
)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""V2 Diagnose router - delegates to core/diagnostic_agent module.
|
|
2
|
+
|
|
3
|
+
This module provides v2-style API endpoints for task diagnosis.
|
|
4
|
+
Diagnosis analyzes failed runs to identify root causes and recommendations.
|
|
5
|
+
|
|
6
|
+
Routes:
|
|
7
|
+
POST /api/v2/tasks/{id}/diagnose - Diagnose a failed task
|
|
8
|
+
GET /api/v2/tasks/{id}/diagnose - Get existing diagnostic report
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from codeframe.core.workspace import Workspace
|
|
18
|
+
from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard
|
|
19
|
+
from codeframe.core import tasks, runtime
|
|
20
|
+
from codeframe.core.diagnostics import (
|
|
21
|
+
DiagnosticReport,
|
|
22
|
+
get_latest_diagnostic_report,
|
|
23
|
+
)
|
|
24
|
+
from codeframe.core.diagnostic_agent import DiagnosticAgent
|
|
25
|
+
from codeframe.ui.dependencies import get_v2_workspace
|
|
26
|
+
from codeframe.ui.response_models import api_error, ErrorCodes
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
router = APIRouter(prefix="/api/v2/tasks", tags=["diagnose-v2"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ============================================================================
|
|
34
|
+
# Request/Response Models
|
|
35
|
+
# ============================================================================
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RecommendationResponse(BaseModel):
|
|
39
|
+
"""Response for a diagnostic recommendation."""
|
|
40
|
+
|
|
41
|
+
title: str
|
|
42
|
+
description: str
|
|
43
|
+
action: str # RemediationAction value
|
|
44
|
+
priority: int
|
|
45
|
+
command: Optional[str]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DiagnosticReportResponse(BaseModel):
|
|
49
|
+
"""Response for a diagnostic report."""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
task_id: str
|
|
53
|
+
run_id: str
|
|
54
|
+
failure_category: str
|
|
55
|
+
severity: str
|
|
56
|
+
root_cause: str
|
|
57
|
+
log_summary: str
|
|
58
|
+
error_messages: list[str]
|
|
59
|
+
recommendations: list[RecommendationResponse]
|
|
60
|
+
has_blocker: bool
|
|
61
|
+
analyzed_at: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DiagnoseRequest(BaseModel):
|
|
65
|
+
"""Request for running diagnosis."""
|
|
66
|
+
|
|
67
|
+
force: bool = Field(False, description="Force re-analysis even if report exists")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ============================================================================
|
|
71
|
+
# Helper Functions
|
|
72
|
+
# ============================================================================
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _report_to_response(report: DiagnosticReport) -> DiagnosticReportResponse:
|
|
76
|
+
"""Convert a DiagnosticReport to a DiagnosticReportResponse."""
|
|
77
|
+
return DiagnosticReportResponse(
|
|
78
|
+
id=report.id,
|
|
79
|
+
task_id=report.task_id,
|
|
80
|
+
run_id=report.run_id,
|
|
81
|
+
failure_category=report.failure_category.value,
|
|
82
|
+
severity=report.severity.value,
|
|
83
|
+
root_cause=report.root_cause,
|
|
84
|
+
log_summary=report.log_summary,
|
|
85
|
+
error_messages=report.error_messages,
|
|
86
|
+
recommendations=[
|
|
87
|
+
RecommendationResponse(
|
|
88
|
+
title=rec.title,
|
|
89
|
+
description=rec.description,
|
|
90
|
+
action=rec.action.value,
|
|
91
|
+
priority=rec.priority,
|
|
92
|
+
command=rec.command,
|
|
93
|
+
)
|
|
94
|
+
for rec in report.recommendations
|
|
95
|
+
],
|
|
96
|
+
has_blocker=report.has_blocker,
|
|
97
|
+
analyzed_at=report.analyzed_at.isoformat(),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ============================================================================
|
|
102
|
+
# Endpoints
|
|
103
|
+
# ============================================================================
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.post("/{task_id}/diagnose", response_model=DiagnosticReportResponse)
|
|
107
|
+
@rate_limit_ai()
|
|
108
|
+
async def diagnose_task(
|
|
109
|
+
request: Request,
|
|
110
|
+
task_id: str,
|
|
111
|
+
body: DiagnoseRequest = None,
|
|
112
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
113
|
+
) -> DiagnosticReportResponse:
|
|
114
|
+
"""Diagnose a failed task and generate recommendations.
|
|
115
|
+
|
|
116
|
+
Analyzes run logs to identify the root cause of failure and
|
|
117
|
+
provides actionable recommendations to fix the issue.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
task_id: Task ID to diagnose
|
|
121
|
+
request: Diagnosis options
|
|
122
|
+
workspace: v2 Workspace
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Diagnostic report with analysis and recommendations
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
HTTPException:
|
|
129
|
+
- 404: Task not found or no failed run
|
|
130
|
+
- 400: Task has no failed runs
|
|
131
|
+
"""
|
|
132
|
+
force = body.force if body else False
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
# Find task
|
|
136
|
+
task = tasks.get(workspace, task_id)
|
|
137
|
+
if not task:
|
|
138
|
+
raise HTTPException(
|
|
139
|
+
status_code=404,
|
|
140
|
+
detail=api_error("Task not found", ErrorCodes.NOT_FOUND, f"No task with id {task_id}"),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Find the most recent failed run
|
|
144
|
+
runs = runtime.list_runs(workspace, task_id=task.id)
|
|
145
|
+
failed_runs = [r for r in runs if r.status == runtime.RunStatus.FAILED]
|
|
146
|
+
|
|
147
|
+
if not failed_runs:
|
|
148
|
+
raise HTTPException(
|
|
149
|
+
status_code=400,
|
|
150
|
+
detail=api_error(
|
|
151
|
+
"No failed runs",
|
|
152
|
+
ErrorCodes.INVALID_STATE,
|
|
153
|
+
f"Task '{task.title}' has no failed runs to diagnose",
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
latest_run = failed_runs[0] # Most recent failed run
|
|
158
|
+
|
|
159
|
+
# Check for existing report
|
|
160
|
+
existing_report = get_latest_diagnostic_report(workspace, run_id=latest_run.id)
|
|
161
|
+
|
|
162
|
+
if existing_report and not force:
|
|
163
|
+
return _report_to_response(existing_report)
|
|
164
|
+
|
|
165
|
+
# Run diagnostic analysis
|
|
166
|
+
agent = DiagnosticAgent(workspace)
|
|
167
|
+
report = agent.analyze(task.id, latest_run.id)
|
|
168
|
+
|
|
169
|
+
return _report_to_response(report)
|
|
170
|
+
|
|
171
|
+
except HTTPException:
|
|
172
|
+
raise
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.error(f"Failed to diagnose task {task_id}: {e}", exc_info=True)
|
|
175
|
+
raise HTTPException(
|
|
176
|
+
status_code=500,
|
|
177
|
+
detail=api_error("Diagnosis failed", ErrorCodes.EXECUTION_FAILED, str(e)),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@router.get("/{task_id}/diagnose", response_model=DiagnosticReportResponse)
|
|
182
|
+
@rate_limit_standard()
|
|
183
|
+
async def get_diagnostic_report(
|
|
184
|
+
request: Request,
|
|
185
|
+
task_id: str,
|
|
186
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
187
|
+
) -> DiagnosticReportResponse:
|
|
188
|
+
"""Get the latest diagnostic report for a task.
|
|
189
|
+
|
|
190
|
+
Returns the most recent diagnostic report without running new analysis.
|
|
191
|
+
Use POST to run a new analysis.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
task_id: Task ID to get report for
|
|
195
|
+
workspace: v2 Workspace
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Latest diagnostic report
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
HTTPException:
|
|
202
|
+
- 404: Task not found or no diagnostic report exists
|
|
203
|
+
"""
|
|
204
|
+
# Find task
|
|
205
|
+
task = tasks.get(workspace, task_id)
|
|
206
|
+
if not task:
|
|
207
|
+
raise HTTPException(
|
|
208
|
+
status_code=404,
|
|
209
|
+
detail=api_error("Task not found", ErrorCodes.NOT_FOUND, f"No task with id {task_id}"),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Get latest report for this task
|
|
213
|
+
report = get_latest_diagnostic_report(workspace, task_id=task.id)
|
|
214
|
+
|
|
215
|
+
if not report:
|
|
216
|
+
raise HTTPException(
|
|
217
|
+
status_code=404,
|
|
218
|
+
detail=api_error(
|
|
219
|
+
"No diagnostic report",
|
|
220
|
+
ErrorCodes.NOT_FOUND,
|
|
221
|
+
f"No diagnostic report exists for task '{task.title}'. Run POST /diagnose first.",
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return _report_to_response(report)
|