Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 55 additions & 6 deletions dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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:
Expand All @@ -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 ────────────────────────────
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));

Expand All @@ -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);

Expand Down Expand Up @@ -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)
? `<td class="cost">${fmtCost(cost)}</td>`
: `<td class="cost-na">n/a</td>`;
Expand Down Expand Up @@ -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);
Expand Down
74 changes: 74 additions & 0 deletions tests/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()