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: 54 additions & 7 deletions dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 ────────────────────────────
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));

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

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