-
Notifications
You must be signed in to change notification settings - Fork 5
feat(costs): cost analytics page (#557) #590
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a8e7aa1
be3e895
594368a
6d0ece8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| """Cost analytics API router for CodeFRAME v2 (issue #557). | ||
|
|
||
| Aggregates the workspace's `token_usage` table into a daily-bucket summary | ||
| for the /costs page in the web UI. Hosts a single endpoint: | ||
|
|
||
| GET /api/v2/costs/summary?days=30 | ||
|
|
||
| Returns an empty-state payload (all zeros, zero-filled daily series) when | ||
| no spend data exists or the table isn't present — never 404. | ||
|
|
||
| The handler opens the workspace SQLite database directly to avoid the | ||
| pre-existing schema conflict between `codeframe/core/workspace.py` and | ||
| `codeframe/persistence/schema_manager.py` — wiring `TokenRepository` | ||
| to a raw connection skips `Database.initialize()` entirely. | ||
| """ | ||
|
|
||
| import logging | ||
| import sqlite3 | ||
| from datetime import datetime, timedelta, timezone | ||
| from typing import Dict, List | ||
|
|
||
| from fastapi import APIRouter, Depends, Query, Request | ||
| from pydantic import BaseModel | ||
|
|
||
| from codeframe.core.workspace import Workspace | ||
| from codeframe.lib.rate_limiter import rate_limit_standard | ||
| from codeframe.persistence.repositories.token_repository import TokenRepository | ||
| from codeframe.ui.dependencies import get_v2_workspace | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| router = APIRouter(prefix="/api/v2/costs", tags=["metrics"]) | ||
|
|
||
|
|
||
| class DailyCostPoint(BaseModel): | ||
| """One day of aggregated spend.""" | ||
|
|
||
| date: str # ISO format YYYY-MM-DD | ||
| cost_usd: float | ||
|
|
||
|
|
||
| class CostSummaryResponse(BaseModel): | ||
| """Aggregated spend over the requested window.""" | ||
|
|
||
| total_spend_usd: float | ||
| total_tasks: int | ||
| avg_cost_per_task: float | ||
| daily: List[DailyCostPoint] | ||
|
|
||
|
|
||
| def _empty_summary(days: int) -> Dict: | ||
| """Build a zero-state response with `days` daily buckets.""" | ||
| end_date = datetime.now(timezone.utc).date() | ||
| start_date = end_date - timedelta(days=days - 1) | ||
| daily = [ | ||
| {"date": (start_date + timedelta(days=i)).isoformat(), "cost_usd": 0.0} | ||
| for i in range(days) | ||
| ] | ||
| return { | ||
| "total_spend_usd": 0.0, | ||
| "total_tasks": 0, | ||
| "avg_cost_per_task": 0.0, | ||
| "daily": daily, | ||
| } | ||
|
|
||
|
|
||
| def _query_costs(db_path: str, days: int) -> Dict: | ||
| """Query the workspace DB via TokenRepository on a raw connection. | ||
|
|
||
| Returns an empty summary if the DB can't be opened or the table is missing, | ||
| rather than raising — keeps the endpoint safe for fresh workspaces. | ||
|
|
||
| TODO(schema-conflict): we open the connection directly rather than through | ||
| `Database(...).initialize()` because the v2 workspace schema in | ||
| `codeframe/core/workspace.py` and the global schema in | ||
| `persistence/schema_manager.py` define `blockers` incompatibly, and | ||
| `Database.initialize()` therefore crashes on existing workspace DBs. | ||
| Remove this workaround once the two schemas converge. | ||
| """ | ||
| try: | ||
| conn = sqlite3.connect(db_path) | ||
| conn.row_factory = sqlite3.Row | ||
| except sqlite3.Error as e: | ||
| logger.warning("costs: failed to open %s: %s", db_path, e) | ||
| return _empty_summary(days) | ||
|
Comment on lines
+80
to
+85
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid creating a database file on a read-only GET.
🤖 Prompt for AI Agents |
||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| try: | ||
| try: | ||
| cursor = conn.cursor() | ||
| cursor.execute( | ||
| "SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage'" | ||
| ) | ||
| if cursor.fetchone() is None: | ||
| return _empty_summary(days) | ||
|
|
||
| repo = TokenRepository(sync_conn=conn) | ||
| return repo.get_costs_summary(days) | ||
| except sqlite3.Error as e: | ||
| # Locked DB, corrupted schema, etc. — fall back to empty state | ||
| # rather than 500'ing the dashboard. | ||
| logger.warning("costs: query failed on %s: %s", db_path, e) | ||
| return _empty_summary(days) | ||
| finally: | ||
| conn.close() | ||
|
|
||
|
|
||
| @router.get("/summary", response_model=CostSummaryResponse) | ||
| @rate_limit_standard() | ||
| async def get_costs_summary( | ||
| request: Request, | ||
| workspace: Workspace = Depends(get_v2_workspace), | ||
| days: int = Query(30, ge=7, le=90, description="Window size in days (7-90)"), | ||
| ): | ||
| """Return total spend, task count, average cost, and a daily series. | ||
|
|
||
| Reads from the workspace's `token_usage` table. Returns zero-filled | ||
| daily buckets so the client can render a chart without conditionals. | ||
| If the table doesn't exist (no agent has run in this workspace yet), | ||
| returns an empty-state response rather than an error. | ||
| """ | ||
| summary = _query_costs(str(workspace.db_path), days) | ||
|
Comment on lines
+109
to
+121
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -type f -name "costs_v2.py" | head -20Repository: frankbria/codeframe Length of output: 98 🏁 Script executed: cat -n codeframe/ui/routers/costs_v2.py | head -120Repository: frankbria/codeframe Length of output: 4611 🏁 Script executed: # Check the _query_costs function definition
rg "_query_costs" -B 3 -A 15Repository: frankbria/codeframe Length of output: 2233 🏁 Script executed: find . -type f -name "token_repository.py" -o -name "*repository.py" | grep -E "token_repository|repositories"Repository: frankbria/codeframe Length of output: 1208 🏁 Script executed: # Check TokenRepository.get_costs_summary to see if it's blocking
rg "get_costs_summary" -B 2 -A 20Repository: frankbria/codeframe Length of output: 16330 🏁 Script executed: # Check how other async routers handle blocking operations
rg "async def" codeframe/ui/routers/ -A 10 | head -100Repository: frankbria/codeframe Length of output: 6740 🏁 Script executed: # Search for threadpool/to_thread usage in the codebase to see the pattern
rg "to_thread|run_in_threadpool|ThreadPoolExecutor" -B 2 -A 2Repository: frankbria/codeframe Length of output: 14276 🏁 Script executed: # Check TokenRepository.get_costs_summary implementation
cat -n codeframe/persistence/repositories/token_repository.py | grep -A 60 "def get_costs_summary"Repository: frankbria/codeframe Length of output: 3096 🏁 Script executed: # Check if there's any pattern preference - look at which is more common
rg "async def.*\(.*Depends|Query" codeframe/ui/routers/*.py | wc -l
rg "def .*\(.*Depends|Query" codeframe/ui/routers/*.py | wc -lRepository: frankbria/codeframe Length of output: 69 Do not run synchronous SQLite work on the event loop. This async handler calls The codebase has established a consistent pattern for this — see 🤖 Prompt for AI Agents |
||
| return CostSummaryResponse( | ||
| total_spend_usd=summary["total_spend_usd"], | ||
| total_tasks=summary["total_tasks"], | ||
| avg_cost_per_task=summary["avg_cost_per_task"], | ||
| daily=[ | ||
| DailyCostPoint(date=d["date"], cost_usd=d["cost_usd"]) | ||
| for d in summary["daily"] | ||
| ], | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.