diff --git a/dashboard.py b/dashboard.py index 067ac300..fdf962ca 100644 --- a/dashboard.py +++ b/dashboard.py @@ -24,6 +24,39 @@ SURFACE = "web" +def _session_model_breakdowns(conn): + """Return ``{session_id: [{model, input, output, cache_read, cache_creation}]}`` + — each session's tokens split by the model that actually produced them. + + Cost must be computed per model and summed: the ``sessions`` table stores a + single primary-model label (opus > sonnet > haiku), so pricing a session's + summed tokens by that one label overcharges every session that touched more + than one model — a subagent on a cheaper model, or a mid-session /model + switch. The client attaches this to each session as ``by_model``. + """ + 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() + + out = {} + for r in rows: + out.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, + }) + return out + + def get_dashboard_data(db_path=DB_PATH): if not db_path.exists(): return {"error": "Database not found. Run: python cli.py scan"} @@ -113,6 +146,8 @@ def get_dashboard_data(db_path=DB_PATH): ORDER BY last_timestamp DESC """).fetchall() + session_by_model = _session_model_breakdowns(conn) + sessions_all = [] for r in session_rows: try: @@ -137,6 +172,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_by_model.get(r["session_id"], []), }) # ── Subagent breakdown by type, by day & model ──────────────────────────── @@ -782,6 +818,19 @@ def get_dashboard_data(db_path=DB_PATH): ); } +// Total $ cost of a session, summed across the models its turns actually used +// (s.by_model from the server). Pricing a session by its single `model` label x +// summed tokens overcharges any multi-model session — a subagent on a cheaper +// model, or a mid-session /model switch. Falls back to the single-model path +// when no breakdown is present (older payloads, or non-session rows). +function sessionCost(s) { + const bm = s && s.by_model; + if (Array.isArray(bm) && bm.length) { + return bm.reduce((t, m) => t + 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); +} + // ── Formatting ───────────────────────────────────────────────────────────── function fmt(n) { if (n >= 1e9) return (n/1e9).toFixed(2)+'B'; @@ -1149,8 +1198,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 +1272,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 +1288,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,7 +1702,7 @@ 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 cost = sessionCost(s); const costCell = isBillable(s.model) ? `${fmtCost(cost)}` : `n/a`; @@ -1865,7 +1914,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 2f3b5a16..1203b5b1 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -494,5 +494,79 @@ def test_prices_match(self): ) +class TestMixedModelSessionCost(unittest.TestCase): + """A session's tokens must be priced by the model that produced them. + + The `sessions` table stores one primary-model label (opus > sonnet > haiku), + so pricing summed session tokens by that label overcharges any session that + also ran cheaper models (subagents, or a mid-session /model switch). + """ + + 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-mixed", "project_name": "user/proj", + "first_timestamp": "2026-04-08T09:00:00Z", + "last_timestamp": "2026-04-08T10:00:00Z", + "git_branch": "main", "model": "claude-opus-4-1", # primary label + "total_input_tokens": 300, "total_output_tokens": 3000, + "total_cache_read": 30000, "total_cache_creation": 3000, + "turn_count": 2, + }]) + insert_turns(conn, [ + {"session_id": "sess-mixed", "timestamp": "2026-04-08T09:10:00Z", + "model": "claude-opus-4-1", "input_tokens": 100, "output_tokens": 1000, + "cache_read_tokens": 10000, "cache_creation_tokens": 1000, + "tool_name": None, "cwd": "/tmp"}, + {"session_id": "sess-mixed", "timestamp": "2026-04-08T09:20:00Z", + "model": "claude-haiku-4-5", "input_tokens": 200, "output_tokens": 2000, + "cache_read_tokens": 20000, "cache_creation_tokens": 2000, + "tool_name": None, "cwd": "/tmp"}, + ]) + conn.commit() + conn.close() + + def tearDown(self): + os.unlink(self.db_path) + + def test_session_carries_per_model_breakdown(self): + data = get_dashboard_data(self.db_path) + session = next(s for s in data["sessions_all"] if s["session_id"] == "sess-mixed") + by_model = {m["model"]: m for m in session["by_model"]} + self.assertEqual(set(by_model), {"claude-opus-4-1", "claude-haiku-4-5"}, + "by_model must split the session's tokens by producing model") + self.assertEqual(by_model["claude-haiku-4-5"]["output"], 2000) + self.assertEqual(by_model["claude-opus-4-1"]["cache_read"], 10000) + # The split must be lossless against the session rollup. + for field in ("input", "output", "cache_read", "cache_creation"): + self.assertEqual(sum(m[field] for m in session["by_model"]), session[field], + f"by_model {field} must sum to the session total") + + def test_per_model_pricing_is_cheaper_than_the_primary_label(self): + """The bug this guards: haiku turns billed at opus rates.""" + import cli + data = get_dashboard_data(self.db_path) + session = next(s for s in data["sessions_all"] if s["session_id"] == "sess-mixed") + per_model = sum(cli.calc_cost(m["model"], m["input"], m["output"], + m["cache_read"], m["cache_creation"]) + for m in session["by_model"]) + single_label = cli.calc_cost(session["model"], session["input"], session["output"], + session["cache_read"], session["cache_creation"]) + self.assertLess(per_model, single_label) + + def test_js_prices_sessions_per_model(self): + """The cost math runs in JS; keep the session sites off the single-label path.""" + # assertTrue, not assertIn: assertIn dumps the whole template on failure. + fallback = " return calcCost(s.model, s.input, s.output, s.cache_read, s.cache_creation);" + self.assertTrue("function sessionCost(s)" in HTML_TEMPLATE, + "sessionCost missing from dashboard JS") + self.assertFalse("calcCost(s.model," in HTML_TEMPLATE.replace(fallback, ""), + "a session is still priced by its single primary-model label") + + if __name__ == "__main__": unittest.main()