diff --git a/dashboard.py b/dashboard.py index 067ac30..881bdb8 100644 --- a/dashboard.py +++ b/dashboard.py @@ -113,6 +113,31 @@ def get_dashboard_data(db_path=DB_PATH): ORDER BY last_timestamp DESC """).fetchall() + # Per-session, per-model token breakdown — pricing a session's total tokens + # at its single primary model overbills sub-agent turns run on a cheaper + # model. This lets the client price each model's share separately. + breakdown_rows = conn.execute(""" + SELECT + session_id, + COALESCE(NULLIF(model, ''), 'unknown') as model, + SUM(input_tokens) as input, + SUM(output_tokens) as output, + SUM(cache_read_tokens) as cache_read, + SUM(cache_creation_tokens) as cache_creation + FROM turns + GROUP BY session_id, COALESCE(NULLIF(model, ''), 'unknown') + """).fetchall() + + session_breakdown = {} + for r in breakdown_rows: + session_breakdown.setdefault(r["session_id"], []).append({ + "model": r["model"], + "input": r["input"] or 0, + "output": r["output"] or 0, + "cache_read": r["cache_read"] or 0, + "cache_creation": r["cache_creation"] or 0, + }) + sessions_all = [] for r in session_rows: try: @@ -137,6 +162,7 @@ def get_dashboard_data(db_path=DB_PATH): "output": r["total_output_tokens"] or 0, "cache_read": r["total_cache_read"] or 0, "cache_creation": r["total_cache_creation"] or 0, + "by_model": session_breakdown.get(r["session_id"], []), }) # ── Subagent breakdown by type, by day & model ──────────────────────────── @@ -782,6 +808,27 @@ def get_dashboard_data(db_path=DB_PATH): ); } +// Prices a session's tokens per turn model (via its by_model breakdown) +// instead of its single primary model, so sub-agent turns on cheaper models +// aren't billed at the session's main-model rate. Falls back to calcCost on +// the session totals for old DBs / an empty breakdown. +function sessionCost(s) { + if (s.by_model && s.by_model.length) { + return s.by_model.reduce((sum, m) => sum + calcCost(m.model, m.input, m.output, m.cache_read, m.cache_creation), 0); + } + return calcCost(s.model, s.input, s.output, s.cache_read, s.cache_creation); +} + +// A session can be billable even when its primary model isn't (e.g. a +// non-billable main model that dispatched a billable sub-agent), so this +// checks the whole breakdown rather than just s.model. +function sessionIsBillable(s) { + if (s.by_model && s.by_model.length) { + return s.by_model.some(m => isBillable(m.model)); + } + return isBillable(s.model); +} + // ── Formatting ───────────────────────────────────────────────────────────── function fmt(n) { if (n >= 1e9) return (n/1e9).toFixed(2)+'B'; @@ -1149,8 +1196,8 @@ def get_dashboard_data(db_path=DB_PATH): return [...sessions].sort((a, b) => { let av, bv; if (sessionSortCol === 'cost') { - av = calcCost(a.model, a.input, a.output, a.cache_read, a.cache_creation); - bv = calcCost(b.model, b.input, b.output, b.cache_read, b.cache_creation); + av = sessionCost(a); + bv = sessionCost(b); } else if (sessionSortCol === 'duration_min') { av = parseFloat(a.duration_min) || 0; bv = parseFloat(b.duration_min) || 0; @@ -1223,7 +1270,7 @@ def get_dashboard_data(db_path=DB_PATH): p.cache_creation += s.cache_creation; p.turns += s.turns; p.sessions++; - p.cost += calcCost(s.model, s.input, s.output, s.cache_read, s.cache_creation); + p.cost += sessionCost(s); } const byProject = Object.values(projMap).sort((a, b) => (b.input + b.output) - (a.input + a.output)); @@ -1239,7 +1286,7 @@ def get_dashboard_data(db_path=DB_PATH): pb.cache_creation += s.cache_creation; pb.turns += s.turns; pb.sessions++; - pb.cost += calcCost(s.model, s.input, s.output, s.cache_read, s.cache_creation); + pb.cost += sessionCost(s); } const byProjectBranch = Object.values(projBranchMap).sort((a, b) => b.cost - a.cost); @@ -1653,8 +1700,8 @@ def get_dashboard_data(db_path=DB_PATH): function renderSessionsTable(sessions) { const shown = sessions.slice(0, shownCount(sessionsLimit, sessions.length)); document.getElementById('sessions-body').innerHTML = shown.map(s => { - const cost = calcCost(s.model, s.input, s.output, s.cache_read, s.cache_creation); - const costCell = isBillable(s.model) + const cost = sessionCost(s); + const costCell = sessionIsBillable(s) ? `${fmtCost(cost)}` : `n/a`; const titleCell = s.topic @@ -1865,7 +1912,7 @@ def get_dashboard_data(db_path=DB_PATH): function exportSessionsCSV() { const header = ['Session', 'Project', 'Title', 'Last Active', 'Duration (min)', 'Model', 'Turns', 'Input', 'Output', 'Cache Read', 'Cache Creation', 'Est. Cost']; const rows = lastFilteredSessions.map(s => { - const cost = calcCost(s.model, s.input, s.output, s.cache_read, s.cache_creation); + const cost = sessionCost(s); return [s.session_id, s.project, s.topic, s.last, s.duration_min, s.model, s.turns, s.input, s.output, s.cache_read, s.cache_creation, cost.toFixed(4)]; }); downloadCSV('sessions', header, rows); diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 2f3b5a1..b4f0629 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -494,5 +494,86 @@ def test_prices_match(self): ) +class TestSessionByModelBreakdown(unittest.TestCase): + """Regression (#160): a session's turns can span multiple models (e.g. a + sub-agent dispatch on a cheaper model). sessions_all must carry a + per-model token breakdown so the dashboard prices each model's share + separately instead of billing the whole session at its primary model.""" + + def setUp(self): + self.tmpfile = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + self.tmpfile.close() + self.db_path = Path(self.tmpfile.name) + conn = get_db(self.db_path) + init_db(conn) + upsert_sessions(conn, [{ + "session_id": "sess-multi", "project_name": "user/myproject", + "first_timestamp": "2026-04-08T09:00:00Z", + "last_timestamp": "2026-04-08T10:00:00Z", + "git_branch": "main", "model": "claude-fable-5", + "total_input_tokens": 6000, "total_output_tokens": 2500, + "total_cache_read": 300, "total_cache_creation": 100, + "turn_count": 3, + }]) + insert_turns(conn, [ + { + "session_id": "sess-multi", "timestamp": "2026-04-08T09:00:00Z", + "model": "claude-fable-5", "input_tokens": 3000, + "output_tokens": 1500, "cache_read_tokens": 200, + "cache_creation_tokens": 50, "tool_name": None, "cwd": "/tmp", + "message_id": "msg-multi-1", + }, + { + "session_id": "sess-multi", "timestamp": "2026-04-08T09:15:00Z", + "model": "claude-fable-5", "input_tokens": 2000, + "output_tokens": 500, "cache_read_tokens": 50, + "cache_creation_tokens": 20, "tool_name": None, "cwd": "/tmp", + "message_id": "msg-multi-2", + }, + { + "session_id": "sess-multi", "timestamp": "2026-04-08T09:30:00Z", + "model": "claude-sonnet-4-6", "input_tokens": 1000, + "output_tokens": 500, "cache_read_tokens": 50, + "cache_creation_tokens": 30, "tool_name": None, "cwd": "/tmp", + "message_id": "msg-multi-3", "is_subagent": 1, + }, + ]) + conn.commit() + conn.close() + + def tearDown(self): + os.unlink(self.db_path) + + def test_by_model_has_both_models(self): + data = get_dashboard_data(db_path=self.db_path) + session = data["sessions_all"][0] + self.assertIn("by_model", session) + models = {m["model"] for m in session["by_model"]} + self.assertEqual(models, {"claude-fable-5", "claude-sonnet-4-6"}) + + def test_by_model_per_model_sums(self): + data = get_dashboard_data(db_path=self.db_path) + session = data["sessions_all"][0] + by_model = {m["model"]: m for m in session["by_model"]} + fable = by_model["claude-fable-5"] + self.assertEqual(fable["input"], 5000) + self.assertEqual(fable["output"], 2000) + self.assertEqual(fable["cache_read"], 250) + self.assertEqual(fable["cache_creation"], 70) + sonnet = by_model["claude-sonnet-4-6"] + self.assertEqual(sonnet["input"], 1000) + self.assertEqual(sonnet["output"], 500) + self.assertEqual(sonnet["cache_read"], 50) + self.assertEqual(sonnet["cache_creation"], 30) + + def test_by_model_totals_match_session_totals(self): + data = get_dashboard_data(db_path=self.db_path) + session = data["sessions_all"][0] + self.assertEqual(sum(m["input"] for m in session["by_model"]), session["input"]) + self.assertEqual(sum(m["output"] for m in session["by_model"]), session["output"]) + self.assertEqual(sum(m["cache_read"] for m in session["by_model"]), session["cache_read"]) + self.assertEqual(sum(m["cache_creation"] for m in session["by_model"]), session["cache_creation"]) + + if __name__ == "__main__": unittest.main()