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