Costs
++ Total AI spend across this workspace. +
++ {formatCurrency(data.total_spend_usd)} +
++ {data.total_tasks.toLocaleString('en-US')} +
++ {formatCurrency(data.avg_cost_per_task)} +
+diff --git a/codeframe/persistence/repositories/token_repository.py b/codeframe/persistence/repositories/token_repository.py index 67111ec0..b7c38923 100644 --- a/codeframe/persistence/repositories/token_repository.py +++ b/codeframe/persistence/repositories/token_repository.py @@ -3,7 +3,7 @@ Extracted from monolithic Database class for better maintainability. """ -from datetime import datetime +from datetime import datetime, timedelta, timezone from typing import List, Optional, Dict, Any, TYPE_CHECKING import logging @@ -274,6 +274,83 @@ def get_workspace_token_usage( cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] + def get_costs_summary(self, days: int) -> Dict[str, Any]: + """Aggregate token_usage costs into daily buckets for analytics. + + Args: + days: Number of trailing days to include in the summary. + + Returns: + Dictionary with keys: + total_spend_usd: float — sum of estimated_cost_usd in window + total_tasks: int — distinct task_id count (excludes NULL) + avg_cost_per_task: float — total_spend_usd / total_tasks (0 if no tasks) + daily: list of {"date": "YYYY-MM-DD", "cost_usd": float} + — one entry per day in the window, oldest first, + zero-filled for days with no spend. + """ + if days <= 0: + raise ValueError("days must be a positive integer") + + now_utc = datetime.now(timezone.utc) + # Inclusive window starting at midnight UTC, `days` calendar days back. + # Use a space-separated, offset-free format so lexicographic comparison + # works against both `CURRENT_TIMESTAMP` defaults ("YYYY-MM-DD HH:MM:SS") + # and Python `.isoformat()` outputs ("YYYY-MM-DDTHH:MM:SS+00:00"). + end_date = now_utc.date() + start_date = end_date - timedelta(days=days - 1) + start_iso = start_date.strftime("%Y-%m-%d %H:%M:%S") + # Exclusive upper bound = midnight after today, so the daily chart and + # the KPI cards always cover the same set of rows even if some records + # are future-dated (clock skew, bad seed data). + end_iso = (end_date + timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S") + + cursor = self.conn.cursor() + + # Totals over the window. total_spend includes NULL-task records so it + # matches the chart; total_tasks only counts records linked to a task. + cursor.execute( + """ + SELECT + COALESCE(SUM(estimated_cost_usd), 0.0) AS total_spend, + COUNT(DISTINCT CASE WHEN task_id IS NOT NULL THEN task_id END) AS task_count + FROM token_usage + WHERE timestamp >= ? AND timestamp < ? + """, + (start_iso, end_iso), + ) + totals = cursor.fetchone() + total_spend = float(totals["total_spend"] or 0.0) + total_tasks = int(totals["task_count"] or 0) + avg_cost = (total_spend / total_tasks) if total_tasks > 0 else 0.0 + + # Daily aggregation — group by calendar date in UTC + cursor.execute( + """ + SELECT + DATE(timestamp) AS day, + COALESCE(SUM(estimated_cost_usd), 0.0) AS cost + FROM token_usage + WHERE timestamp >= ? AND timestamp < ? + GROUP BY DATE(timestamp) + """, + (start_iso, end_iso), + ) + by_day: Dict[str, float] = {row["day"]: float(row["cost"] or 0.0) for row in cursor.fetchall()} + + daily: List[Dict[str, Any]] = [] + for offset in range(days): + d = start_date + timedelta(days=offset) + iso = d.isoformat() + daily.append({"date": iso, "cost_usd": by_day.get(iso, 0.0)}) + + return { + "total_spend_usd": total_spend, + "total_tasks": total_tasks, + "avg_cost_per_task": avg_cost, + "daily": daily, + } + def get_project_costs_aggregate(self, project_id: int) -> Dict[str, Any]: """Get aggregated cost statistics for a project. diff --git a/codeframe/ui/routers/costs_v2.py b/codeframe/ui/routers/costs_v2.py new file mode 100644 index 00000000..c516606c --- /dev/null +++ b/codeframe/ui/routers/costs_v2.py @@ -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) + + 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) + 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"] + ], + ) diff --git a/codeframe/ui/server.py b/codeframe/ui/server.py index e6287277..014fde93 100644 --- a/codeframe/ui/server.py +++ b/codeframe/ui/server.py @@ -22,6 +22,7 @@ batches_v2, blockers_v2, checkpoints_v2, + costs_v2, diagnose_v2, discovery_v2, environment_v2, @@ -483,6 +484,7 @@ async def test_broadcast(message: dict, project_id: int = None): app.include_router(batches_v2.router) # /api/v2/batches app.include_router(blockers_v2.router) # /api/v2/blockers app.include_router(checkpoints_v2.router) # /api/v2/checkpoints +app.include_router(costs_v2.router) # /api/v2/costs app.include_router(diagnose_v2.router) # /api/v2/tasks/{id}/diagnose app.include_router(discovery_v2.router) # /api/v2/discovery app.include_router(environment_v2.router) # /api/v2/env diff --git a/tests/persistence/test_token_repository_costs.py b/tests/persistence/test_token_repository_costs.py new file mode 100644 index 00000000..df7d4893 --- /dev/null +++ b/tests/persistence/test_token_repository_costs.py @@ -0,0 +1,200 @@ +"""Tests for TokenRepository.get_costs_summary (Issue #557). + +The method aggregates token_usage rows into daily buckets for the +cost analytics page. Returns total spend, total tasks, average cost +per task, and a daily series filled with zeros where no data exists. +""" + +import pytest +from datetime import datetime, timedelta, timezone + +from codeframe.core.models import CallType, TokenUsage +from codeframe.persistence.database import Database + +pytestmark = pytest.mark.v2 + + +@pytest.fixture +def db(): + database = Database(":memory:") + database.initialize() + cursor = database.conn.cursor() + cursor.execute( + "INSERT INTO projects (name, description, workspace_path, status) VALUES (?, ?, ?, ?)", + ("test-project", "Test project", "/tmp/test", "active"), + ) + database.conn.commit() + return database + + +def _create_task(db, project_id=1, title="Task"): + cursor = db.conn.cursor() + cursor.execute( + "INSERT INTO tasks (project_id, title, description, status) VALUES (?, ?, ?, ?)", + (project_id, title, "Test", "in_progress"), + ) + db.conn.commit() + return cursor.lastrowid + + +def _save(db, task_id=None, cost=0.01, timestamp=None, project_id=1): + if timestamp is None: + timestamp = datetime.now(timezone.utc) + usage = TokenUsage( + task_id=task_id, + agent_id="agent-001", + project_id=project_id, + model_name="claude-sonnet-4-5", + input_tokens=100, + output_tokens=50, + estimated_cost_usd=cost, + actual_cost_usd=None, + call_type=CallType.TASK_EXECUTION, + timestamp=timestamp, + ) + return db.save_token_usage(usage) + + +class TestGetCostsSummaryEmpty: + def test_empty_table_returns_zeros(self, db): + summary = db.token_usage.get_costs_summary(days=30) + + assert summary["total_spend_usd"] == 0.0 + assert summary["total_tasks"] == 0 + assert summary["avg_cost_per_task"] == 0.0 + # daily should have one entry per day in the range + assert len(summary["daily"]) == 30 + assert all(d["cost_usd"] == 0.0 for d in summary["daily"]) + + def test_default_days_is_30(self, db): + summary = db.token_usage.get_costs_summary(days=30) + assert len(summary["daily"]) == 30 + + +class TestGetCostsSummaryWithData: + def test_aggregates_total_spend(self, db): + t1 = _create_task(db) + t2 = _create_task(db) + now = datetime.now(timezone.utc) + _save(db, task_id=t1, cost=0.50, timestamp=now) + _save(db, task_id=t1, cost=0.25, timestamp=now) + _save(db, task_id=t2, cost=0.30, timestamp=now) + + summary = db.token_usage.get_costs_summary(days=30) + + assert summary["total_spend_usd"] == pytest.approx(1.05) + assert summary["total_tasks"] == 2 # distinct task_ids + assert summary["avg_cost_per_task"] == pytest.approx(1.05 / 2) + + def test_excludes_null_task_ids_from_count(self, db): + t1 = _create_task(db) + now = datetime.now(timezone.utc) + _save(db, task_id=t1, cost=0.10, timestamp=now) + _save(db, task_id=None, cost=0.10, timestamp=now) # standalone call + + summary = db.token_usage.get_costs_summary(days=30) + + assert summary["total_spend_usd"] == pytest.approx(0.20) + assert summary["total_tasks"] == 1 + + def test_daily_buckets_filled_with_zeros(self, db): + t1 = _create_task(db) + # Two records on different days within the range + now = datetime.now(timezone.utc) + _save(db, task_id=t1, cost=0.10, timestamp=now) + _save(db, task_id=t1, cost=0.20, timestamp=now - timedelta(days=3)) + + summary = db.token_usage.get_costs_summary(days=7) + + assert len(summary["daily"]) == 7 + # All entries have date keys and cost_usd keys + for entry in summary["daily"]: + assert "date" in entry + assert "cost_usd" in entry + # Sum of daily matches total + assert sum(d["cost_usd"] for d in summary["daily"]) == pytest.approx(0.30) + + def test_excludes_data_outside_window(self, db): + t1 = _create_task(db) + now = datetime.now(timezone.utc) + _save(db, task_id=t1, cost=0.10, timestamp=now) + # 100 days ago — outside 30-day window + _save(db, task_id=t1, cost=99.0, timestamp=now - timedelta(days=100)) + + summary = db.token_usage.get_costs_summary(days=30) + + assert summary["total_spend_usd"] == pytest.approx(0.10) + + def test_excludes_future_dated_rows(self, db): + """A row with a timestamp past today must not inflate the KPI cards. + + Without an upper bound the daily chart (which is built from a fixed + list of dates within the window) would exclude future rows while the + SUM() KPIs would include them, making the two views disagree. + """ + t1 = _create_task(db) + now = datetime.now(timezone.utc) + _save(db, task_id=t1, cost=0.10, timestamp=now) + _save(db, task_id=t1, cost=42.0, timestamp=now + timedelta(days=2)) + + summary = db.token_usage.get_costs_summary(days=7) + + assert summary["total_spend_usd"] == pytest.approx(0.10) + # And the daily series sum agrees with the KPI total + assert sum(d["cost_usd"] for d in summary["daily"]) == pytest.approx(0.10) + + def test_daily_dates_are_ordered_oldest_to_newest(self, db): + summary = db.token_usage.get_costs_summary(days=7) + dates = [d["date"] for d in summary["daily"]] + assert dates == sorted(dates) + + def test_avg_cost_per_task_zero_when_no_tasks(self, db): + # Record exists but has NULL task_id + now = datetime.now(timezone.utc) + _save(db, task_id=None, cost=0.50, timestamp=now) + + summary = db.token_usage.get_costs_summary(days=30) + + assert summary["total_spend_usd"] == pytest.approx(0.50) + assert summary["total_tasks"] == 0 + assert summary["avg_cost_per_task"] == 0.0 + + +class TestGetCostsSummaryTimestampFormats: + """Records inserted via different timestamp formats must all be picked up. + + SQLite's `CURRENT_TIMESTAMP` produces space-separated values + ("YYYY-MM-DD HH:MM:SS"), Python `.isoformat()` produces T-separated + values with an offset suffix ("YYYY-MM-DDTHH:MM:SS+00:00"). The query + must include both. + """ + + def test_includes_records_with_space_separated_timestamps(self, db): + """A record inserted with SQLite's default timestamp format must be counted.""" + tid = _create_task(db) + # Insert raw with a space-separated timestamp (the schema default format). + # This simulates DEFAULT CURRENT_TIMESTAMP behavior. + now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + cursor = db.conn.cursor() + cursor.execute( + """ + INSERT INTO token_usage (task_id, agent_id, project_id, model_name, + input_tokens, output_tokens, estimated_cost_usd, call_type, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (tid, "agent-001", 1, "claude-sonnet-4-5", + 100, 50, 0.42, "task_execution", now_str), + ) + db.conn.commit() + + summary = db.token_usage.get_costs_summary(days=7) + + assert summary["total_spend_usd"] == pytest.approx(0.42) + assert summary["total_tasks"] == 1 + + +class TestGetCostsSummaryRangeValidation: + @pytest.mark.parametrize("days", [7, 30, 90]) + def test_valid_ranges(self, db, days): + summary = db.token_usage.get_costs_summary(days=days) + assert len(summary["daily"]) == days diff --git a/tests/ui/test_costs_v2.py b/tests/ui/test_costs_v2.py new file mode 100644 index 00000000..e7876261 --- /dev/null +++ b/tests/ui/test_costs_v2.py @@ -0,0 +1,161 @@ +"""Tests for cost analytics endpoints (issue #557). + +Covers: +- GET /api/v2/costs/summary returns zero-state when no data +- GET /api/v2/costs/summary aggregates token_usage into daily buckets +- days query param is bounded to [7, 90] +- Default days is 30 +""" + +import shutil +import sqlite3 +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +pytestmark = pytest.mark.v2 + + +def _ensure_token_usage_table(db_path: Path) -> None: + """Create token_usage on the workspace DB without invoking SchemaManager. + + The router opens the workspace DB directly and tolerates the table + being absent. Tests that exercise real data need to create the table + inline to mirror what an agent run would produce. + """ + conn = sqlite3.connect(str(db_path)) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS token_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER, + agent_id TEXT NOT NULL, + project_id INTEGER NOT NULL, + model_name TEXT NOT NULL, + input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + estimated_cost_usd REAL NOT NULL, + actual_cost_usd REAL, + call_type TEXT, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + session_id TEXT DEFAULT NULL + ) + """ + ) + conn.commit() + finally: + conn.close() + + +@pytest.fixture +def test_workspace(): + temp_dir = Path(tempfile.mkdtemp()) + workspace_path = temp_dir / "test_workspace" + workspace_path.mkdir(parents=True, exist_ok=True) + + from codeframe.core.workspace import create_or_load_workspace + + workspace = create_or_load_workspace(workspace_path) + + yield workspace + + shutil.rmtree(temp_dir, ignore_errors=True) + + +@pytest.fixture +def test_client(test_workspace): + from codeframe.ui.dependencies import get_v2_workspace + from codeframe.ui.routers import costs_v2 + + app = FastAPI() + app.include_router(costs_v2.router) + + def get_test_workspace(): + return test_workspace + + app.dependency_overrides[get_v2_workspace] = get_test_workspace + client = TestClient(app) + client.workspace = test_workspace + return client + + +def _record_usage(workspace, *, task_id=1, cost=0.10, when=None): + _ensure_token_usage_table(workspace.db_path) + timestamp = (when or datetime.now(timezone.utc)).isoformat() + conn = sqlite3.connect(str(workspace.db_path)) + try: + conn.execute( + """ + INSERT INTO token_usage ( + task_id, agent_id, project_id, model_name, + input_tokens, output_tokens, estimated_cost_usd, + call_type, timestamp + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (task_id, "agent-001", 1, "claude-sonnet-4-5", + 100, 50, cost, "task_execution", timestamp), + ) + conn.commit() + finally: + conn.close() + + +class TestCostsSummaryEmpty: + def test_returns_zero_state(self, test_client): + response = test_client.get("/api/v2/costs/summary") + assert response.status_code == 200 + body = response.json() + assert body["total_spend_usd"] == 0.0 + assert body["total_tasks"] == 0 + assert body["avg_cost_per_task"] == 0.0 + assert isinstance(body["daily"], list) + assert len(body["daily"]) == 30 + + +class TestCostsSummaryWithData: + def test_aggregates_token_usage(self, test_client): + _record_usage(test_client.workspace, task_id=1, cost=0.50) + _record_usage(test_client.workspace, task_id=1, cost=0.25) + + response = test_client.get("/api/v2/costs/summary?days=30") + assert response.status_code == 200 + body = response.json() + assert body["total_spend_usd"] == pytest.approx(0.75) + assert body["total_tasks"] == 1 + assert body["avg_cost_per_task"] == pytest.approx(0.75) + + def test_daily_series_length_matches_days(self, test_client): + _record_usage(test_client.workspace, task_id=1, cost=0.10) + response = test_client.get("/api/v2/costs/summary?days=7") + assert response.status_code == 200 + body = response.json() + assert len(body["daily"]) == 7 + for entry in body["daily"]: + assert "date" in entry + assert "cost_usd" in entry + + +class TestDaysValidation: + def test_below_minimum_rejected(self, test_client): + response = test_client.get("/api/v2/costs/summary?days=3") + assert response.status_code == 422 + + def test_above_maximum_rejected(self, test_client): + response = test_client.get("/api/v2/costs/summary?days=365") + assert response.status_code == 422 + + def test_default_is_30(self, test_client): + response = test_client.get("/api/v2/costs/summary") + assert response.status_code == 200 + assert len(response.json()["daily"]) == 30 + + @pytest.mark.parametrize("days", [7, 30, 90]) + def test_valid_ranges_accepted(self, test_client, days): + response = test_client.get(f"/api/v2/costs/summary?days={days}") + assert response.status_code == 200 + assert len(response.json()["daily"]) == days diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js index 56533835..d7e97eec 100644 --- a/web-ui/__mocks__/@hugeicons/react.js +++ b/web-ui/__mocks__/@hugeicons/react.js @@ -71,4 +71,8 @@ module.exports = { UserCircle02Icon: createIconMock('UserCircle02Icon'), // PRHistoryPanel ArrowUpRight01Icon: createIconMock('ArrowUpRight01Icon'), + // Costs page + MoneyBag02Icon: createIconMock('MoneyBag02Icon'), + Analytics01Icon: createIconMock('Analytics01Icon'), + ChartLineData01Icon: createIconMock('ChartLineData01Icon'), }; diff --git a/web-ui/src/__tests__/components/costs/CostsPage.test.tsx b/web-ui/src/__tests__/components/costs/CostsPage.test.tsx new file mode 100644 index 00000000..1336a4af --- /dev/null +++ b/web-ui/src/__tests__/components/costs/CostsPage.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import useSWR from 'swr'; +import CostsPage from '@/app/costs/page'; +import * as storage from '@/lib/workspace-storage'; +import type { CostSummaryResponse } from '@/types'; + +jest.mock('swr'); +jest.mock('@/lib/workspace-storage', () => ({ + getSelectedWorkspacePath: jest.fn(), + setSelectedWorkspacePath: jest.fn(), +})); +jest.mock('@/lib/api', () => ({ + costsApi: { + getSummary: jest.fn(), + }, + workspaceApi: { + checkExists: jest.fn(), + init: jest.fn(), + }, +})); +jest.mock('@/components/workspace/WorkspaceSelector', () => ({ + WorkspaceSelector: () =>
, +})); +jest.mock('@/components/costs/SpendBarChart', () => ({ + SpendBarChart: ({ daily, days }: { daily: unknown[]; days: number }) => ( + + ), +})); + +const mockUseSWR = useSWR as jest.MockedFunction+ Total AI spend across this workspace. +
++ {formatCurrency(data.total_spend_usd)} +
++ {data.total_tasks.toLocaleString('en-US')} +
++ {formatCurrency(data.avg_cost_per_task)} +
+