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) ? `