1use anyhow::{Context, Result};
10use rusqlite::{Connection, params};
11
12#[derive(Debug, Clone)]
15pub struct SessionRow {
16 pub session_id: String,
17 pub cc_session_id: Option<String>,
18 pub agent: String,
19 pub project: Option<String>,
20 pub session_name: Option<String>,
21 pub turns: i64,
22 pub tokens: i64,
24 pub cache_hit: f64,
26 pub last_cache_hit: Option<f64>,
31 pub last_input_tokens: i64,
35 pub bill_micros: i64,
37 pub saved_micros: i64,
40 pub input_before: i64,
42 pub input_after: i64,
43 pub last_ts: String,
45 pub last_sub_provider: Option<String>,
49 pub last_model: Option<String>,
52}
53
54impl SessionRow {
55 pub fn bill_usd(&self) -> f64 {
56 self.bill_micros as f64 / 1_000_000.0
57 }
58
59 pub fn saved_pct(&self) -> f64 {
62 self.trim_pct()
63 }
64
65 pub fn trim_pct(&self) -> f64 {
67 if self.input_before > 0 {
68 (self.input_before - self.input_after).max(0) as f64 / self.input_before as f64 * 100.0
69 } else {
70 0.0
71 }
72 }
73
74 pub fn saved_usd(&self) -> f64 {
75 self.saved_micros as f64 / 1_000_000.0
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct OccupancyRow {
82 pub group_label: String,
83 pub label: String,
84 pub mcp_server: Option<String>,
85 pub tool_name: Option<String>,
86 pub tokens: i64,
87}
88
89#[derive(Debug, Clone)]
92pub struct CostRow {
93 pub group_label: String,
94 pub label: String,
95 pub mcp_server: Option<String>,
96 pub tool_name: Option<String>,
97 pub usd: f64,
99 pub read_usd: f64,
101 pub write_usd: f64,
103 pub new_usd: f64,
105}
106
107pub struct BreakdownDb {
108 pub(crate) conn: Connection,
109}
110
111impl BreakdownDb {
112 pub fn open() -> Result<Self> {
118 Ok(Self {
119 conn: crate::tracking::Tracker::open_reader()?.into_connection(),
120 })
121 }
122
123 pub fn from_connection(conn: Connection) -> Self {
125 Self { conn }
126 }
127
128 pub fn sessions(&self) -> Result<Vec<SessionRow>> {
130 let saved_expr = crate::money::saved_micros_sql("");
132 let sql = format!(
133 "WITH ranked AS (
139 SELECT session_id, cc_session_id, agent, project, session_name, ts, id,
140 fresh_input, cache_read, cache_write, output_tok,
141 bill_micros, input_before, input_after, sub_provider, model,
142 input_rate, cache_read_rate, cache_write_rate,
143 ({saved_expr}) AS saved_micros,
144 ROW_NUMBER() OVER (
145 PARTITION BY session_id, cc_session_id, agent, project
146 ORDER BY (session_name IS NOT NULL) DESC, id DESC
147 ) AS rn,
148 ROW_NUMBER() OVER (
149 PARTITION BY session_id, cc_session_id, agent, project
150 ORDER BY id DESC
151 ) AS recent
152 FROM breakdown_turns
153 )
154 SELECT session_id, agent, project,
155 MAX(CASE WHEN rn = 1 THEN session_name END),
156 COUNT(*),
157 COALESCE(SUM(fresh_input + cache_read + cache_write + output_tok), 0),
158 COALESCE(SUM(cache_read), 0),
159 COALESCE(SUM(fresh_input + cache_read + cache_write), 0),
160 COALESCE(SUM(bill_micros), 0),
161 COALESCE(SUM(saved_micros), 0),
162 COALESCE(SUM(input_before), 0),
163 COALESCE(SUM(input_after), 0),
164 MAX(ts),
165 cc_session_id,
166 MAX(CASE WHEN recent = 1 THEN sub_provider END),
167 MAX(CASE WHEN recent = 1 THEN model END),
168 COALESCE(MAX(CASE WHEN recent = 1 THEN cache_read END), 0),
169 COALESCE(
170 MAX(CASE WHEN recent = 1 THEN fresh_input + cache_read + cache_write END),
171 0
172 )
173 FROM ranked
174 GROUP BY session_id, cc_session_id, agent, project
175 ORDER BY MAX(ts) DESC"
176 );
177 let mut stmt = self
178 .conn
179 .prepare(&sql)
180 .context("failed to prepare sessions query")?;
181 let rows = stmt
182 .query_map([], |r| {
183 let cache_read: i64 = r.get(6)?;
184 let total_in: i64 = r.get(7)?;
185 let last_read: i64 = r.get(16)?;
186 let last_total_in: i64 = r.get(17)?;
187 Ok(SessionRow {
188 session_id: r.get(0)?,
189 agent: r.get(1)?,
190 project: r.get(2)?,
191 session_name: r.get(3)?,
192 turns: r.get(4)?,
193 tokens: r.get(5)?,
194 cache_hit: if total_in > 0 {
195 cache_read as f64 / total_in as f64
196 } else {
197 0.0
198 },
199 last_cache_hit: (last_total_in > 0)
200 .then(|| last_read as f64 / last_total_in as f64),
201 last_input_tokens: last_total_in,
202 bill_micros: r.get(8)?,
203 saved_micros: r.get(9)?,
204 input_before: r.get(10)?,
205 input_after: r.get(11)?,
206 last_ts: r.get(12)?,
207 cc_session_id: r.get(13)?,
208 last_sub_provider: r.get(14)?,
209 last_model: r.get(15)?,
210 })
211 })
212 .context("failed to query sessions")?;
213 rows.collect::<rusqlite::Result<Vec<_>>>()
214 .context("failed to read sessions")
215 }
216
217 pub fn latest_turn(&self, session_id: &str) -> Result<Option<(i64, i64)>> {
219 let mut stmt = self
220 .conn
221 .prepare(
222 "SELECT id, window FROM breakdown_turns
223 WHERE session_id = ?1 ORDER BY id DESC LIMIT 1",
224 )
225 .context("failed to prepare latest-turn query")?;
226 let row = stmt
227 .query_row(params![session_id], |r| Ok((r.get(0)?, r.get(1)?)))
228 .ok();
229 Ok(row)
230 }
231
232 pub fn occupancy(&self, turn_id: i64) -> Result<Vec<OccupancyRow>> {
234 let mut stmt = self
235 .conn
236 .prepare(
237 "SELECT group_label, label, mcp_server, tool_name, SUM(raw_tokens)
238 FROM breakdown_blocks
239 WHERE turn_id = ?1 AND zone = 'input'
240 GROUP BY group_label, label, mcp_server, tool_name
241 ORDER BY SUM(raw_tokens) DESC",
242 )
243 .context("failed to prepare occupancy query")?;
244 let rows = stmt
245 .query_map(params![turn_id], |r| {
246 Ok(OccupancyRow {
247 group_label: r.get(0)?,
248 label: r.get(1)?,
249 mcp_server: r.get(2)?,
250 tool_name: r.get(3)?,
251 tokens: r.get(4)?,
252 })
253 })
254 .context("failed to query occupancy")?;
255 rows.collect::<rusqlite::Result<Vec<_>>>()
256 .context("failed to read occupancy")
257 }
258
259 pub fn cost(&self, session_id: &str) -> Result<Vec<CostRow>> {
262 self.cost_grouped(Some(session_id))
263 }
264
265 pub fn all_sources(&self) -> Result<Vec<CostRow>> {
268 self.cost_grouped(None)
269 }
270
271 fn cost_grouped(&self, session: Option<&str>) -> Result<Vec<CostRow>> {
273 let where_clause = if session.is_some() {
274 "WHERE t.session_id = ?1"
275 } else {
276 ""
277 };
278 let sql = format!(
279 "SELECT b.group_label, b.label, b.mcp_server, b.tool_name,
280 SUM(b.cache_read_tok * t.cache_read_rate) / 1e6 AS read_usd,
281 SUM(b.cache_write_tok * t.cache_write_rate) / 1e6 AS write_usd,
282 SUM(b.fresh_tok * t.input_rate + b.output_tok * t.output_rate) / 1e6 AS new_usd
283 FROM breakdown_blocks b JOIN breakdown_turns t ON b.turn_id = t.id
284 {where_clause}
285 GROUP BY b.group_label, b.label, b.mcp_server, b.tool_name
286 ORDER BY (read_usd + write_usd + new_usd) DESC"
287 );
288 let mut stmt = self
289 .conn
290 .prepare(&sql)
291 .context("failed to prepare cost query")?;
292 let map = |r: &rusqlite::Row| {
293 let read_usd: f64 = r.get(4)?;
294 let write_usd: f64 = r.get(5)?;
295 let new_usd: f64 = r.get(6)?;
296 Ok(CostRow {
297 group_label: r.get(0)?,
298 label: r.get(1)?,
299 mcp_server: r.get(2)?,
300 tool_name: r.get(3)?,
301 usd: read_usd + write_usd + new_usd,
302 read_usd,
303 write_usd,
304 new_usd,
305 })
306 };
307 let rows = match session {
308 Some(id) => stmt.query_map(params![id], map),
309 None => stmt.query_map([], map),
310 }
311 .context("failed to query cost")?;
312 rows.collect::<rusqlite::Result<Vec<_>>>()
313 .context("failed to read cost")
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::tracking::{BreakdownBlock, BreakdownTurn, Tracker};
321
322 fn turn(session: &str) -> BreakdownTurn {
323 BreakdownTurn {
324 session_id: session.to_string(),
325 cc_session_id: None,
326 agent: "claude-code".to_string(),
327 project: Some("/proj".to_string()),
328 session_name: Some("my session".to_string()),
329 provider: "anthropic".to_string(),
330 model: Some("claude-sonnet-4".to_string()),
331 sub_provider: None,
332 window: 200_000,
333 fresh_input: 50,
334 cache_read: 120,
335 cache_write: 30,
336 output_tok: 40,
337 input_rate: 3.0,
338 output_rate: 15.0,
339 cache_read_rate: 0.3,
340 cache_write_rate: 3.75,
341 bill_micros: 1_000,
342 input_before: 300,
343 input_after: 200,
344 }
345 }
346
347 fn block(group: &str, label: &str, mcp: Option<&str>, fresh: f64, read: f64) -> BreakdownBlock {
348 BreakdownBlock {
349 zone: "input".to_string(),
350 section: "static".to_string(),
351 bucket: "schema".to_string(),
352 group_label: group.to_string(),
353 label: label.to_string(),
354 mcp_server: mcp.map(str::to_string),
355 tool_name: None,
356 role: None,
357 msg_index: None,
358 raw_tokens: (fresh + read) as i64,
359 fresh_tok: fresh,
360 cache_read_tok: read,
361 cache_write_tok: 0.0,
362 output_tok: 0.0,
363 }
364 }
365
366 fn seeded_db() -> BreakdownDb {
369 let tracker = Tracker::open_in_memory().unwrap();
370 tracker
371 .record_breakdown(
372 &turn("sess-a"),
373 &[
374 block("Static", "System prompt", None, 50.0, 100.0),
375 block("Static", "MCP tools", Some("github"), 0.0, 20.0),
376 ],
377 )
378 .unwrap();
379 tracker
380 .record_breakdown(
381 &turn("sess-a"),
382 &[block("Static", "System prompt", None, 50.0, 100.0)],
383 )
384 .unwrap();
385 BreakdownDb::from_connection(tracker.into_connection())
386 }
387
388 #[test]
389 fn sessions_round_trip_the_cc_session_id() {
390 let tracker = Tracker::open_in_memory().unwrap();
393 let mut t = turn("sess-a");
394 t.cc_session_id = Some("968ad7ea-6e4a-430e-a708-4eda80c8b858".to_string());
395 tracker
396 .record_breakdown(&t, &[block("Static", "System prompt", None, 50.0, 100.0)])
397 .unwrap();
398 let db = BreakdownDb::from_connection(tracker.into_connection());
399 let rows = db.sessions().unwrap();
400 assert_eq!(rows.len(), 1);
401 assert_eq!(
402 rows[0].cc_session_id.as_deref(),
403 Some("968ad7ea-6e4a-430e-a708-4eda80c8b858")
404 );
405 }
406
407 #[test]
408 fn sessions_keep_claude_code_windows_separate() {
409 let tracker = Tracker::open_in_memory().unwrap();
410 let mut first = turn("shared-prompt-hash");
411 first.cc_session_id = Some("window-a".to_string());
412 first.fresh_input = 12_000;
413 first.cache_read = 30_000;
414 first.cache_write = 0;
415 tracker
416 .record_breakdown(
417 &first,
418 &[block("Static", "System prompt", None, 12_000.0, 30_000.0)],
419 )
420 .unwrap();
421
422 let mut second = turn("shared-prompt-hash");
423 second.cc_session_id = Some("window-b".to_string());
424 second.fresh_input = 3_000;
425 second.cache_read = 240_000;
426 second.cache_write = 0;
427 tracker
428 .record_breakdown(
429 &second,
430 &[block("Static", "System prompt", None, 3_000.0, 240_000.0)],
431 )
432 .unwrap();
433
434 let db = BreakdownDb::from_connection(tracker.into_connection());
435 let rows = db.sessions().unwrap();
436 assert_eq!(rows.len(), 2);
437 let tokens_for = |id: &str| {
438 rows.iter()
439 .find(|row| row.cc_session_id.as_deref() == Some(id))
440 .map(|row| row.last_input_tokens)
441 };
442 assert_eq!(tokens_for("window-a"), Some(42_000));
443 assert_eq!(tokens_for("window-b"), Some(243_000));
444 }
445
446 #[test]
447 fn a_ledger_written_before_sub_provider_existed_still_queries() {
448 let path = std::env::temp_dir().join(format!(
451 "llmtrim-legacy-ledger-{}-{}.db",
452 std::process::id(),
453 std::time::SystemTime::now()
454 .duration_since(std::time::UNIX_EPOCH)
455 .unwrap()
456 .as_nanos()
457 ));
458 let legacy = Connection::open(&path).unwrap();
459 legacy
460 .execute_batch(
461 "CREATE TABLE breakdown_turns (
462 id INTEGER PRIMARY KEY, ts TEXT NOT NULL, session_id TEXT NOT NULL,
463 agent TEXT NOT NULL, project TEXT, session_name TEXT,
464 provider TEXT NOT NULL, model TEXT, window INTEGER NOT NULL,
465 fresh_input INTEGER NOT NULL, cache_read INTEGER NOT NULL,
466 cache_write INTEGER NOT NULL, output_tok INTEGER NOT NULL,
467 input_rate REAL NOT NULL, output_rate REAL NOT NULL,
468 cache_read_rate REAL NOT NULL, cache_write_rate REAL NOT NULL,
469 bill_micros INTEGER NOT NULL
470 );
471 INSERT INTO breakdown_turns
472 (ts, session_id, agent, provider, model, window, fresh_input, cache_read,
473 cache_write, output_tok, input_rate, output_rate, cache_read_rate,
474 cache_write_rate, bill_micros)
475 VALUES ('2026-07-01T00:00:00+00:00', 'sess-old', 'claude-code', 'anthropic',
476 'claude-sonnet-4', 200000, 10, 0, 0, 5, 3.0, 15.0, 0.3, 3.75, 100);",
477 )
478 .unwrap();
479 drop(legacy);
480
481 let tracker = Tracker::open_reader_at(&path).unwrap();
482 let db = BreakdownDb::from_connection(tracker.into_connection());
483 let rows = db.sessions().unwrap();
484 assert_eq!(rows.len(), 1);
485 assert_eq!(rows[0].last_sub_provider, None);
487 assert_eq!(rows[0].last_model.as_deref(), Some("claude-sonnet-4"));
488
489 let _ = std::fs::remove_file(&path);
490 }
491
492 #[test]
493 fn sessions_report_the_backend_that_served_the_newest_turn() {
494 let tracker = Tracker::open_in_memory().unwrap();
499 tracker
500 .record_breakdown(
501 &turn("sess-a"),
502 &[block("Static", "System prompt", None, 50.0, 100.0)],
503 )
504 .unwrap();
505 let mut fired = turn("sess-a");
506 fired.sub_provider = Some("codex".to_string());
507 fired.model = Some("gpt-5.6-terra".to_string());
508 tracker
509 .record_breakdown(
510 &fired,
511 &[block("Static", "System prompt", None, 50.0, 100.0)],
512 )
513 .unwrap();
514
515 let db = BreakdownDb::from_connection(tracker.into_connection());
516 let rows = db.sessions().unwrap();
517 assert_eq!(rows.len(), 1);
518 assert_eq!(rows[0].last_sub_provider.as_deref(), Some("codex"));
519 assert_eq!(rows[0].last_model.as_deref(), Some("gpt-5.6-terra"));
520 }
521
522 #[test]
523 fn sessions_report_no_backend_when_anthropic_served_the_newest_turn() {
524 let tracker = Tracker::open_in_memory().unwrap();
527 let mut fired = turn("sess-a");
528 fired.sub_provider = Some("codex".to_string());
529 fired.model = Some("gpt-5.6-terra".to_string());
530 tracker
531 .record_breakdown(
532 &fired,
533 &[block("Static", "System prompt", None, 50.0, 100.0)],
534 )
535 .unwrap();
536 tracker
537 .record_breakdown(
538 &turn("sess-a"),
539 &[block("Static", "System prompt", None, 50.0, 100.0)],
540 )
541 .unwrap();
542
543 let db = BreakdownDb::from_connection(tracker.into_connection());
544 let rows = db.sessions().unwrap();
545 assert_eq!(rows[0].last_sub_provider, None);
546 assert_eq!(rows[0].last_model.as_deref(), Some("claude-sonnet-4"));
547 }
548
549 #[test]
550 fn sessions_aggregate_turns_and_cache_hit() {
551 let db = seeded_db();
552 let rows = db.sessions().unwrap();
553 assert_eq!(rows.len(), 1);
554 let s = &rows[0];
555 assert_eq!(s.session_id, "sess-a");
556 assert_eq!(s.turns, 2);
557 assert_eq!(s.session_name.as_deref(), Some("my session"));
558 assert!((s.cache_hit - 0.6).abs() < 1e-6);
560 }
561
562 #[test]
563 fn occupancy_uses_latest_turn_only() {
564 let db = seeded_db();
565 let (turn_id, window) = db.latest_turn("sess-a").unwrap().expect("a turn");
566 assert_eq!(window, 200_000);
567 let occ = db.occupancy(turn_id).unwrap();
568 assert_eq!(occ.len(), 1);
570 assert_eq!(occ[0].label, "System prompt");
571 assert_eq!(occ[0].tokens, 150);
572 }
573
574 #[test]
575 fn all_sources_aggregates_across_sessions() {
576 let db = seeded_db();
577 let all = db.all_sources().unwrap();
578 let sys = all.iter().find(|r| r.label == "System prompt").unwrap();
580 assert!(sys.usd > 0.0);
581 assert!(
583 all.iter()
584 .any(|r| r.mcp_server.as_deref() == Some("github"))
585 );
586 }
587
588 #[test]
589 fn cost_prices_from_frozen_rates_and_splits_mcp() {
590 let db = seeded_db();
591 let rows = db.cost("sess-a").unwrap();
592 let mcp = rows
593 .iter()
594 .find(|r| r.mcp_server.as_deref() == Some("github"))
595 .unwrap();
596 assert!((mcp.read_usd - 20.0 * 0.3 / 1e6).abs() < 1e-12);
598 assert!(mcp.new_usd.abs() < 1e-12);
599 }
600
601 #[test]
604 fn sessions_snapshot_anchor() {
605 let db = seeded_db();
606 let rows = db.sessions().unwrap();
607 let out = rows
609 .iter()
610 .map(|r| {
611 format!(
612 "session={} agent={} turns={} bill_micros={}",
613 r.session_id, r.agent, r.turns, r.bill_micros
614 )
615 })
616 .collect::<Vec<_>>()
617 .join("\n");
618 insta::assert_snapshot!(out);
619 }
620}