1use std::path::{Path, PathBuf};
10use std::sync::OnceLock;
11
12use anyhow::{Context, Result};
13use rusqlite::{Connection, OptionalExtension, params};
14
15pub type RateLookup = fn(provider: &str, model: Option<&str>) -> (f64, f64, f64, f64);
17
18static RATE_LOOKUP: OnceLock<RateLookup> = OnceLock::new();
21
22pub fn register_rate_lookup(f: RateLookup) {
25 let _ = RATE_LOOKUP.set(f);
26}
27
28const ZERO_RATE_CHECK_DAY_KEY: &str = "zero_rate_check_day";
29
30#[derive(Debug, Clone)]
32pub struct Record {
33 pub provider: String,
34 pub model: Option<String>,
35 pub tokenizer: String,
36 pub exact: bool,
37 pub input_before: i64,
38 pub input_after: i64,
39 pub output_before: Option<i64>,
40 pub output_after: Option<i64>,
41 pub compress_micros: Option<i64>,
43 pub cache_read_tokens: Option<i64>,
47 pub fresh_input_tokens: Option<i64>,
52 pub cache_write_tokens: Option<i64>,
55 pub output_shaped: Option<bool>,
59 pub frozen_input_tokens: Option<i64>,
63 pub outcome: Option<String>,
70}
71
72#[derive(Debug, Clone, Default)]
77pub struct BreakdownTurn {
78 pub session_id: String,
79 pub cc_session_id: Option<String>,
83 pub agent: String,
84 pub project: Option<String>,
85 pub session_name: Option<String>,
86 pub provider: String,
87 pub model: Option<String>,
88 pub sub_provider: Option<String>,
94 pub window: i64,
96 pub fresh_input: i64,
97 pub cache_read: i64,
98 pub cache_write: i64,
99 pub output_tok: i64,
100 pub input_rate: f64,
102 pub output_rate: f64,
103 pub cache_read_rate: f64,
104 pub cache_write_rate: f64,
105 pub bill_micros: i64,
107 pub input_before: i64,
110 pub input_after: i64,
111}
112
113#[derive(Debug, Clone, Default)]
115pub struct BreakdownBlock {
116 pub zone: String,
117 pub section: String,
118 pub bucket: String,
119 pub group_label: String,
121 pub label: String,
123 pub mcp_server: Option<String>,
124 pub tool_name: Option<String>,
125 pub role: Option<String>,
126 pub msg_index: Option<i64>,
127 pub raw_tokens: i64,
129 pub fresh_tok: f64,
131 pub cache_read_tok: f64,
132 pub cache_write_tok: f64,
133 pub output_tok: f64,
134}
135
136#[derive(Debug, Clone)]
138pub struct ProviderRow {
139 pub provider: String,
140 pub events: i64,
141 pub input_before: i64,
142 pub input_after: i64,
143 pub exact: bool,
144 pub output_before: i64,
145 pub output_after: i64,
146 pub output_events: i64,
147}
148
149#[derive(Debug, Clone)]
151pub struct ModelRow {
152 pub provider: String,
153 pub model: Option<String>,
154 pub events: i64,
155 pub input_before: i64,
156 pub input_after: i64,
157 pub output_after: i64,
158 pub cache_read: i64,
160 pub cache_write: i64,
162 pub fresh_input_est: i64,
165 pub output_after_shaped: i64,
168 pub frozen_input_tokens: i64,
173 pub metered_input_before: i64,
174 pub metered_input_after: i64,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum Period {
180 Day,
181 Week,
182 Month,
183}
184
185impl Period {
186 pub(crate) fn sql_bucket(self) -> &'static str {
189 match self {
190 Period::Day => "substr(ts, 1, 10)", Period::Month => "substr(ts, 1, 7)", Period::Week => "strftime('%Y-W%W', ts)", }
194 }
195
196 pub fn label(self) -> &'static str {
197 match self {
198 Period::Day => "daily",
199 Period::Week => "weekly",
200 Period::Month => "monthly",
201 }
202 }
203}
204
205#[derive(Debug, Clone)]
207pub struct PeriodRow {
208 pub bucket: String,
209 pub events: i64,
210 pub input_before: i64,
211 pub input_after: i64,
212 pub output_before: i64,
213 pub output_after: i64,
214}
215
216#[derive(Debug, Clone, Default)]
218pub struct Summary {
219 pub events: i64,
220 pub input_before: i64,
221 pub input_after: i64,
222 pub any_approximate: bool,
223 pub by_provider: Vec<ProviderRow>,
224 pub output_before: i64,
225 pub output_after: i64,
226 pub output_events: i64,
227 pub avg_compress_micros: Option<f64>,
229 pub cache_read_tokens: i64,
231 pub last_ts: Option<String>,
234 pub frozen_input_tokens: i64,
239 pub metered_input_before: i64,
240 pub metered_input_after: i64,
241}
242
243impl Summary {
244 pub fn saved(&self) -> i64 {
245 self.input_before - self.input_after
246 }
247
248 pub fn saved_pct(&self) -> f64 {
250 if self.input_before <= 0 {
251 0.0
252 } else {
253 (self.saved() as f64 / self.input_before as f64) * 100.0
254 }
255 }
256
257 pub fn output_saved(&self) -> i64 {
258 self.output_before - self.output_after
259 }
260
261 pub fn output_saved_pct(&self) -> f64 {
263 if self.output_before <= 0 {
264 0.0
265 } else {
266 (self.output_saved() as f64 / self.output_before as f64) * 100.0
267 }
268 }
269}
270
271pub const DEFAULT_MAX_ROWS: i64 = 100_000;
274
275pub const DEFAULT_MAX_BREAKDOWN_TURNS: i64 = 50_000;
286
287pub struct Tracker {
288 conn: Connection,
289}
290
291impl Tracker {
292 pub fn open() -> Result<Self> {
295 let path = default_db_path()?;
296 Self::open_at(&path)
297 }
298
299 pub fn open_at(path: &Path) -> Result<Self> {
300 if let Some(parent) = path.parent() {
301 std::fs::create_dir_all(parent)
302 .with_context(|| format!("failed to create {}", parent.display()))?;
303 }
304 let conn = Connection::open(path)
305 .with_context(|| format!("failed to open ledger at {}", path.display()))?;
306 let tracker = Self { conn };
307 tracker.migrate()?;
308 let _ = tracker.prune_default();
311 let _ = tracker.prune_breakdown(Self::breakdown_turns_cap());
315 let _ = tracker.maybe_reprice_unpriced_daily();
317 Ok(tracker)
318 }
319
320 pub fn open_reader() -> Result<Self> {
326 let path = default_db_path()?;
327 Self::open_reader_at(&path)
328 }
329
330 pub fn open_reader_at(path: &Path) -> Result<Self> {
332 if let Some(parent) = path.parent() {
333 std::fs::create_dir_all(parent)
334 .with_context(|| format!("failed to create {}", parent.display()))?;
335 }
336 let conn = Connection::open(path)
337 .with_context(|| format!("failed to open ledger at {}", path.display()))?;
338 let tracker = Self { conn };
339 tracker.migrate()?;
340 let _ = tracker.maybe_reprice_unpriced_daily();
342 let _ = tracker.conn.pragma_update(None, "temp_store", "MEMORY");
344 let _ = tracker.conn.pragma_update(None, "cache_size", -8000_i64);
345 let _ = tracker
346 .conn
347 .pragma_update(None, "mmap_size", 268_435_456_i64);
348 let _ = tracker.conn.pragma_update(None, "query_only", "ON");
350 Ok(tracker)
351 }
352
353 pub fn into_connection(self) -> Connection {
356 self.conn
357 }
358
359 pub fn connection(&self) -> &Connection {
363 &self.conn
364 }
365
366 pub fn open_in_memory() -> Result<Self> {
368 let conn = Connection::open_in_memory().context("failed to open in-memory ledger")?;
369 let tracker = Self { conn };
370 tracker.migrate()?;
371 Ok(tracker)
372 }
373
374 fn migrate(&self) -> Result<()> {
375 let _ = self.conn.pragma_update(None, "journal_mode", "WAL");
382 let _ = self.conn.pragma_update(None, "busy_timeout", 2000);
383 self.conn
384 .execute_batch(
385 "CREATE TABLE IF NOT EXISTS compressions (
386 id INTEGER PRIMARY KEY,
387 ts TEXT NOT NULL,
388 provider TEXT NOT NULL,
389 model TEXT,
390 tokenizer TEXT NOT NULL,
391 exact INTEGER NOT NULL,
392 input_before INTEGER NOT NULL,
393 input_after INTEGER NOT NULL,
394 output_before INTEGER,
395 output_after INTEGER,
396 compress_micros INTEGER,
397 cache_read_tokens INTEGER,
398 fresh_input_tokens INTEGER,
399 cache_write_tokens INTEGER,
400 output_shaped INTEGER,
401 frozen_input_tokens INTEGER,
402 outcome TEXT
403 );
404 CREATE TABLE IF NOT EXISTS breakdown_turns (
405 id INTEGER PRIMARY KEY,
406 ts TEXT NOT NULL,
407 session_id TEXT NOT NULL,
408 cc_session_id TEXT,
409 agent TEXT NOT NULL,
410 project TEXT,
411 session_name TEXT,
412 provider TEXT NOT NULL,
413 model TEXT,
414 sub_provider TEXT,
415 window INTEGER NOT NULL,
416 fresh_input INTEGER NOT NULL,
417 cache_read INTEGER NOT NULL,
418 cache_write INTEGER NOT NULL,
419 output_tok INTEGER NOT NULL,
420 input_rate REAL NOT NULL,
421 output_rate REAL NOT NULL,
422 cache_read_rate REAL NOT NULL,
423 cache_write_rate REAL NOT NULL,
424 bill_micros INTEGER NOT NULL,
425 input_before INTEGER,
426 input_after INTEGER
427 );
428 CREATE TABLE IF NOT EXISTS breakdown_blocks (
429 id INTEGER PRIMARY KEY,
430 turn_id INTEGER NOT NULL REFERENCES breakdown_turns(id),
431 zone TEXT NOT NULL,
432 section TEXT NOT NULL,
433 bucket TEXT NOT NULL,
434 group_label TEXT NOT NULL,
435 label TEXT NOT NULL,
436 mcp_server TEXT,
437 tool_name TEXT,
438 role TEXT,
439 msg_index INTEGER,
440 raw_tokens INTEGER NOT NULL,
441 fresh_tok REAL NOT NULL,
442 cache_read_tok REAL NOT NULL,
443 cache_write_tok REAL NOT NULL,
444 output_tok REAL NOT NULL
445 );
446 CREATE INDEX IF NOT EXISTS idx_breakdown_turns_session ON breakdown_turns(session_id);
447 CREATE INDEX IF NOT EXISTS idx_breakdown_turns_agent ON breakdown_turns(agent);
448 CREATE INDEX IF NOT EXISTS idx_breakdown_blocks_turn ON breakdown_blocks(turn_id);
449 -- Serves sessions()'s GROUP BY + per-session ROW_NUMBER ordering and latest_turn().
450 CREATE INDEX IF NOT EXISTS idx_breakdown_turns_group ON breakdown_turns(session_id, agent, project, id);
451 -- Money range queries (today / week) and prune-friendly scans.
452 CREATE INDEX IF NOT EXISTS idx_breakdown_turns_ts ON breakdown_turns(ts);
453 -- Lets the day-scoped aggregate (by_model_today) range-seek instead of scanning.
454 CREATE INDEX IF NOT EXISTS idx_compressions_ts ON compressions(ts);",
455 )
456 .context("failed to migrate ledger schema")?;
457 for col in [
462 "compress_micros",
463 "cache_read_tokens",
464 "fresh_input_tokens",
465 "cache_write_tokens",
466 "output_shaped",
467 "frozen_input_tokens",
468 ] {
469 if let Err(e) = self.conn.execute(
470 &format!("ALTER TABLE compressions ADD COLUMN {col} INTEGER"),
471 [],
472 ) && !is_duplicate_column(&e)
473 {
474 return Err(e).with_context(|| format!("failed to add ledger column {col}"));
475 }
476 }
477 for col in ["input_before", "input_after"] {
479 if let Err(e) = self.conn.execute(
480 &format!("ALTER TABLE breakdown_turns ADD COLUMN {col} INTEGER"),
481 [],
482 ) && !is_duplicate_column(&e)
483 {
484 return Err(e).with_context(|| format!("failed to add breakdown column {col}"));
485 }
486 }
487 for col in ["cc_session_id", "sub_provider"] {
489 if let Err(e) = self.conn.execute(
490 &format!("ALTER TABLE breakdown_turns ADD COLUMN {col} TEXT"),
491 [],
492 ) && !is_duplicate_column(&e)
493 {
494 return Err(e).with_context(|| format!("failed to add breakdown column {col}"));
495 }
496 }
497 for col in ["outcome"] {
498 if let Err(e) = self.conn.execute(
499 &format!("ALTER TABLE compressions ADD COLUMN {col} TEXT"),
500 [],
501 ) && !is_duplicate_column(&e)
502 {
503 return Err(e).with_context(|| format!("failed to add ledger column {col}"));
504 }
505 }
506 self.conn
508 .execute_batch(
509 "CREATE TABLE IF NOT EXISTS ledger_meta (
510 key TEXT PRIMARY KEY NOT NULL,
511 value TEXT NOT NULL
512 );",
513 )
514 .context("failed to create ledger_meta")?;
515 Ok(())
516 }
517
518 pub fn maybe_reprice_unpriced_daily(&self) -> Result<()> {
523 let Some(lookup) = RATE_LOOKUP.get() else {
524 return Ok(());
525 };
526 if !self.zero_rate_check_due_today()? {
527 return Ok(());
528 }
529 self.mark_zero_rate_check_today()?;
531 for (provider, model) in self.unpriced_models()? {
532 let (input, output, cache_read, cache_write) = lookup(&provider, Some(&model));
533 if input == 0.0 && output == 0.0 {
534 continue;
535 }
536 let _ = self.reprice_zero_rate_turns(
537 &provider,
538 &model,
539 input,
540 output,
541 cache_read,
542 cache_write,
543 );
544 }
545 Ok(())
546 }
547
548 pub fn unpriced_models(&self) -> Result<Vec<(String, String)>> {
550 let mut stmt = self
551 .conn
552 .prepare(
553 "SELECT DISTINCT provider, model
554 FROM breakdown_turns
555 WHERE model IS NOT NULL
556 AND input_rate = 0
557 AND output_rate = 0
558 AND cache_read_rate = 0
559 AND cache_write_rate = 0
560 AND bill_micros = 0",
561 )
562 .context("failed to prepare unpriced_models query")?;
563 let rows = stmt
564 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
565 .context("failed to query unpriced models")?;
566 rows.collect::<rusqlite::Result<Vec<_>>>()
567 .context("failed to read unpriced models")
568 }
569
570 pub fn reprice_zero_rate_turns(
573 &self,
574 provider: &str,
575 model: &str,
576 input_rate: f64,
577 output_rate: f64,
578 cache_read_rate: f64,
579 cache_write_rate: f64,
580 ) -> Result<u64> {
581 let n = self
583 .conn
584 .execute(
585 "UPDATE breakdown_turns
586 SET input_rate = ?1,
587 output_rate = ?2,
588 cache_read_rate = ?3,
589 cache_write_rate = ?4,
590 bill_micros = CAST(ROUND(
591 fresh_input * ?1
592 + cache_read * ?3
593 + cache_write * ?4
594 + output_tok * ?2
595 ) AS INTEGER)
596 WHERE provider = ?5
597 AND model = ?6
598 AND input_rate = 0
599 AND output_rate = 0
600 AND cache_read_rate = 0
601 AND cache_write_rate = 0
602 AND bill_micros = 0",
603 params![
604 input_rate,
605 output_rate,
606 cache_read_rate,
607 cache_write_rate,
608 provider,
609 model,
610 ],
611 )
612 .with_context(|| format!("failed to reprice zero-rate turns for {provider}/{model}"))?;
613 Ok(n as u64)
614 }
615
616 fn today_utc() -> String {
617 chrono::Utc::now().format("%Y-%m-%d").to_string()
618 }
619
620 fn zero_rate_check_due_today(&self) -> Result<bool> {
621 let today = Self::today_utc();
622 let last: Option<String> = self
623 .conn
624 .query_row(
625 "SELECT value FROM ledger_meta WHERE key = ?1",
626 params![ZERO_RATE_CHECK_DAY_KEY],
627 |r| r.get(0),
628 )
629 .optional()
630 .context("failed to read zero_rate_check_day")?;
631 Ok(last.as_deref() != Some(today.as_str()))
632 }
633
634 fn mark_zero_rate_check_today(&self) -> Result<()> {
635 self.conn
636 .execute(
637 "INSERT INTO ledger_meta (key, value) VALUES (?1, ?2)
638 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
639 params![ZERO_RATE_CHECK_DAY_KEY, Self::today_utc()],
640 )
641 .context("failed to mark zero_rate_check_day")?;
642 Ok(())
643 }
644
645 pub fn prune(&self, max_rows: i64, max_age_days: Option<i64>) -> Result<u64> {
650 let mut deleted: u64 = 0;
651 if let Some(days) = max_age_days.filter(|d| *d > 0) {
654 let delta = chrono::TimeDelta::try_days(days).unwrap_or_else(chrono::TimeDelta::zero);
655 let cutoff = (chrono::Utc::now() - delta).to_rfc3339();
656 deleted += self
657 .conn
658 .execute("DELETE FROM compressions WHERE ts < ?1", params![cutoff])
659 .context("failed to age-prune ledger")? as u64;
660 }
661 let n: i64 = self
663 .conn
664 .query_row("SELECT COUNT(*) FROM compressions", [], |row| row.get(0))
665 .context("failed to count ledger rows")?;
666 if n > max_rows {
667 deleted += self
668 .conn
669 .execute(
670 "DELETE FROM compressions WHERE id <= (SELECT MAX(id) - ?1 FROM compressions)",
671 params![max_rows],
672 )
673 .context("failed to cap-prune ledger")? as u64;
674 }
675 Ok(deleted)
676 }
677
678 pub fn prune_default(&self) -> Result<u64> {
683 self.prune(
684 llmtrim_core::config::max_rows().unwrap_or(DEFAULT_MAX_ROWS),
685 llmtrim_core::config::retention_days(),
686 )
687 }
688
689 pub fn breakdown_turns_cap() -> i64 {
692 llmtrim_core::config::max_breakdown_turns().unwrap_or(DEFAULT_MAX_BREAKDOWN_TURNS)
693 }
694
695 pub fn record(&self, r: &Record) -> Result<()> {
696 let ts = chrono::Utc::now().to_rfc3339();
697 self.conn
698 .execute(
699 "INSERT INTO compressions
700 (ts, provider, model, tokenizer, exact, input_before, input_after,
701 output_before, output_after, compress_micros, cache_read_tokens,
702 fresh_input_tokens, cache_write_tokens, output_shaped, frozen_input_tokens,
703 outcome)
704 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
705 params![
706 ts,
707 r.provider,
708 r.model,
709 r.tokenizer,
710 i64::from(r.exact),
711 r.input_before,
712 r.input_after,
713 r.output_before,
714 r.output_after,
715 r.compress_micros,
716 r.cache_read_tokens,
717 r.fresh_input_tokens,
718 r.cache_write_tokens,
719 r.output_shaped.map(i64::from),
720 r.frozen_input_tokens,
721 r.outcome,
722 ],
723 )
724 .context("failed to record compression")?;
725 Ok(())
726 }
727
728 pub fn record_breakdown(&self, turn: &BreakdownTurn, blocks: &[BreakdownBlock]) -> Result<i64> {
732 let ts = chrono::Utc::now().to_rfc3339();
733 self.conn
734 .execute(
735 "INSERT INTO breakdown_turns
736 (ts, session_id, cc_session_id, agent, project, session_name, provider, model,
737 sub_provider, window, fresh_input, cache_read, cache_write, output_tok,
738 input_rate, output_rate, cache_read_rate, cache_write_rate, bill_micros,
739 input_before, input_after)
740 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21)",
741 params![
742 ts,
743 turn.session_id,
744 turn.cc_session_id,
745 turn.agent,
746 turn.project,
747 turn.session_name,
748 turn.provider,
749 turn.model,
750 turn.sub_provider,
751 turn.window,
752 turn.fresh_input,
753 turn.cache_read,
754 turn.cache_write,
755 turn.output_tok,
756 turn.input_rate,
757 turn.output_rate,
758 turn.cache_read_rate,
759 turn.cache_write_rate,
760 turn.bill_micros,
761 turn.input_before,
762 turn.input_after,
763 ],
764 )
765 .context("failed to record breakdown turn")?;
766 let turn_id = self.conn.last_insert_rowid();
767 let mut stmt = self
768 .conn
769 .prepare_cached(
770 "INSERT INTO breakdown_blocks
771 (turn_id, zone, section, bucket, group_label, label, mcp_server, tool_name,
772 role, msg_index, raw_tokens, fresh_tok, cache_read_tok, cache_write_tok, output_tok)
773 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15)",
774 )
775 .context("failed to prepare breakdown block insert")?;
776 for b in blocks {
777 stmt.execute(params![
778 turn_id,
779 b.zone,
780 b.section,
781 b.bucket,
782 b.group_label,
783 b.label,
784 b.mcp_server,
785 b.tool_name,
786 b.role,
787 b.msg_index,
788 b.raw_tokens,
789 b.fresh_tok,
790 b.cache_read_tok,
791 b.cache_write_tok,
792 b.output_tok,
793 ])
794 .context("failed to record breakdown block")?;
795 }
796 Ok(turn_id)
797 }
798
799 pub fn prune_breakdown(&self, max_turns: i64) -> Result<u64> {
802 let n: i64 = self
803 .conn
804 .query_row("SELECT COUNT(*) FROM breakdown_turns", [], |row| row.get(0))
805 .context("failed to count breakdown turns")?;
806 if n <= max_turns {
807 return Ok(0);
808 }
809 self.conn
810 .execute(
811 "DELETE FROM breakdown_blocks WHERE turn_id <= \
812 (SELECT MAX(id) - ?1 FROM breakdown_turns)",
813 params![max_turns],
814 )
815 .context("failed to cap-prune breakdown blocks")?;
816 let deleted = self
817 .conn
818 .execute(
819 "DELETE FROM breakdown_turns WHERE id <= (SELECT MAX(id) - ?1 FROM breakdown_turns)",
820 params![max_turns],
821 )
822 .context("failed to cap-prune breakdown turns")? as u64;
823 Ok(deleted)
824 }
825
826 #[cfg(test)]
829 pub fn record_breakdown_premeter(&self, agent: &str, bill_micros: i64) -> Result<i64> {
830 let ts = chrono::Utc::now().to_rfc3339();
831 self.conn
832 .execute(
833 "INSERT INTO breakdown_turns
834 (ts, session_id, agent, provider, window,
835 fresh_input, cache_read, cache_write, output_tok,
836 input_rate, output_rate, cache_read_rate, cache_write_rate,
837 bill_micros, input_before, input_after)
838 VALUES (?1, ?2, ?3, 'anthropic', 200000,
839 0, 0, 0, 0, 3.0, 15.0, 0.3, 3.75,
840 ?4, NULL, NULL)",
841 params![ts, format!("premeter-{agent}"), agent, bill_micros],
842 )
843 .context("failed to record pre-meter turn")?;
844 Ok(self.conn.last_insert_rowid())
845 }
846
847 #[cfg(test)]
849 pub fn record_breakdown_with_ts(&self, turn: &BreakdownTurn, ts: &str) -> Result<i64> {
850 self.conn
851 .execute(
852 "INSERT INTO breakdown_turns
853 (ts, session_id, cc_session_id, agent, project, session_name, provider, model,
854 sub_provider, window, fresh_input, cache_read, cache_write, output_tok,
855 input_rate, output_rate, cache_read_rate, cache_write_rate, bill_micros,
856 input_before, input_after)
857 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21)",
858 params![
859 ts,
860 turn.session_id,
861 turn.cc_session_id,
862 turn.agent,
863 turn.project,
864 turn.session_name,
865 turn.provider,
866 turn.model,
867 turn.sub_provider,
868 turn.window,
869 turn.fresh_input,
870 turn.cache_read,
871 turn.cache_write,
872 turn.output_tok,
873 turn.input_rate,
874 turn.output_rate,
875 turn.cache_read_rate,
876 turn.cache_write_rate,
877 turn.bill_micros,
878 turn.input_before,
879 turn.input_after,
880 ],
881 )
882 .context("failed to record breakdown turn with ts")?;
883 Ok(self.conn.last_insert_rowid())
884 }
885
886 #[cfg(test)]
889 fn record_with_ts(&self, r: &Record, ts: &str) -> Result<()> {
890 self.conn
891 .execute(
892 "INSERT INTO compressions
893 (ts, provider, model, tokenizer, exact, input_before, input_after,
894 output_before, output_after, compress_micros, cache_read_tokens,
895 fresh_input_tokens, cache_write_tokens, output_shaped, frozen_input_tokens,
896 outcome)
897 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
898 params![
899 ts,
900 r.provider,
901 r.model,
902 r.tokenizer,
903 i64::from(r.exact),
904 r.input_before,
905 r.input_after,
906 r.output_before,
907 r.output_after,
908 r.compress_micros,
909 r.cache_read_tokens,
910 r.fresh_input_tokens,
911 r.cache_write_tokens,
912 r.output_shaped.map(i64::from),
913 r.frozen_input_tokens,
914 r.outcome,
915 ],
916 )
917 .context("failed to record compression (test)")?;
918 Ok(())
919 }
920
921 pub fn summary(&self) -> Result<Summary> {
922 let (events, input_before, input_after, approx, output_before, output_after, output_events): (
923 i64, i64, i64, i64, i64, i64, i64,
924 ) = self
925 .conn
926 .query_row(
927 "SELECT COUNT(*),
928 COALESCE(SUM(input_before), 0),
929 COALESCE(SUM(input_after), 0),
930 COALESCE(SUM(CASE WHEN exact = 0 THEN 1 ELSE 0 END), 0),
931 COALESCE(SUM(output_before), 0),
932 COALESCE(SUM(output_after), 0),
933 COALESCE(SUM(CASE WHEN output_after IS NOT NULL THEN 1 ELSE 0 END), 0)
934 FROM compressions",
935 [],
936 |row| {
937 Ok((
938 row.get(0)?,
939 row.get(1)?,
940 row.get(2)?,
941 row.get(3)?,
942 row.get(4)?,
943 row.get(5)?,
944 row.get(6)?,
945 ))
946 },
947 )
948 .context("failed to summarize ledger")?;
949
950 let mut stmt = self
951 .conn
952 .prepare(
953 "SELECT provider, COUNT(*),
954 COALESCE(SUM(input_before), 0),
955 COALESCE(SUM(input_after), 0),
956 MIN(exact),
957 COALESCE(SUM(output_before), 0),
958 COALESCE(SUM(output_after), 0),
959 COALESCE(SUM(CASE WHEN output_after IS NOT NULL THEN 1 ELSE 0 END), 0)
960 FROM compressions GROUP BY provider ORDER BY provider",
961 )
962 .context("failed to prepare provider summary")?;
963 let rows = stmt
964 .query_map([], |row| {
965 Ok(ProviderRow {
966 provider: row.get(0)?,
967 events: row.get(1)?,
968 input_before: row.get(2)?,
969 input_after: row.get(3)?,
970 exact: row.get::<_, i64>(4)? != 0,
971 output_before: row.get(5)?,
972 output_after: row.get(6)?,
973 output_events: row.get(7)?,
974 })
975 })
976 .context("failed to query provider summary")?;
977 let by_provider = rows
978 .collect::<rusqlite::Result<Vec<_>>>()
979 .context("failed to read provider summary")?;
980
981 let (avg_compress_micros, cache_read_tokens, last_ts): (Option<f64>, i64, Option<String>) =
985 self.conn
986 .query_row(
987 "SELECT AVG(compress_micros), COALESCE(SUM(cache_read_tokens), 0), MAX(ts)
988 FROM compressions",
989 [],
990 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
991 )
992 .context("failed to summarize latency + cache")?;
993
994 let (frozen_input_tokens, metered_input_before, metered_input_after): (i64, i64, i64) =
997 self.conn
998 .query_row(
999 "SELECT COALESCE(SUM(frozen_input_tokens), 0),
1000 COALESCE(SUM(CASE WHEN frozen_input_tokens IS NOT NULL
1001 THEN input_before ELSE 0 END), 0),
1002 COALESCE(SUM(CASE WHEN frozen_input_tokens IS NOT NULL
1003 THEN input_after ELSE 0 END), 0)
1004 FROM compressions",
1005 [],
1006 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1007 )
1008 .context("failed to summarize frozen-zone meter")?;
1009
1010 Ok(Summary {
1011 events,
1012 input_before,
1013 input_after,
1014 any_approximate: approx > 0,
1015 by_provider,
1016 output_before,
1017 output_after,
1018 output_events,
1019 avg_compress_micros,
1020 cache_read_tokens,
1021 last_ts,
1022 frozen_input_tokens,
1023 metered_input_before,
1024 metered_input_after,
1025 })
1026 }
1027
1028 pub fn by_model(&self) -> Result<Vec<ModelRow>> {
1033 self.by_model_where("")
1034 }
1035
1036 pub fn by_model_today(&self) -> Result<Vec<ModelRow>> {
1040 self.by_model_where("WHERE ts >= date('now') || 'T00:00:00+00:00'")
1045 }
1046
1047 fn by_model_where(&self, where_clause: &str) -> Result<Vec<ModelRow>> {
1050 let mut stmt = self
1051 .conn
1052 .prepare(&format!(
1053 "SELECT provider, model, COUNT(*),
1054 COALESCE(SUM(input_before), 0),
1055 COALESCE(SUM(input_after), 0),
1056 COALESCE(SUM(output_after), 0),
1057 COALESCE(SUM(cache_read_tokens), 0),
1058 COALESCE(SUM(cache_write_tokens), 0),
1059 COALESCE(SUM(COALESCE(fresh_input_tokens,
1060 MAX(input_after - COALESCE(cache_read_tokens, 0), 0))), 0),
1061 COALESCE(SUM(CASE WHEN output_shaped = 1
1062 THEN COALESCE(output_after, 0) ELSE 0 END), 0),
1063 COALESCE(SUM(frozen_input_tokens), 0),
1064 COALESCE(SUM(CASE WHEN frozen_input_tokens IS NOT NULL
1065 THEN input_before ELSE 0 END), 0),
1066 COALESCE(SUM(CASE WHEN frozen_input_tokens IS NOT NULL
1067 THEN input_after ELSE 0 END), 0)
1068 FROM compressions {where_clause} GROUP BY provider, model ORDER BY provider, model",
1069 ))
1070 .context("failed to prepare model summary")?;
1071 let rows = stmt
1072 .query_map([], |row| {
1073 Ok(ModelRow {
1074 provider: row.get(0)?,
1075 model: row.get(1)?,
1076 events: row.get(2)?,
1077 input_before: row.get(3)?,
1078 input_after: row.get(4)?,
1079 output_after: row.get(5)?,
1080 cache_read: row.get(6)?,
1081 cache_write: row.get(7)?,
1082 fresh_input_est: row.get(8)?,
1083 output_after_shaped: row.get(9)?,
1084 frozen_input_tokens: row.get(10)?,
1085 metered_input_before: row.get(11)?,
1086 metered_input_after: row.get(12)?,
1087 })
1088 })
1089 .context("failed to query model summary")?;
1090 rows.collect::<rusqlite::Result<Vec<_>>>()
1091 .context("failed to read model summary")
1092 }
1093
1094 pub fn by_period(&self, period: Period) -> Result<Vec<PeriodRow>> {
1096 let sql = format!(
1097 "SELECT {} AS bucket, COUNT(*),
1098 COALESCE(SUM(input_before), 0),
1099 COALESCE(SUM(input_after), 0),
1100 COALESCE(SUM(output_before), 0),
1101 COALESCE(SUM(output_after), 0)
1102 FROM compressions GROUP BY bucket ORDER BY bucket",
1103 period.sql_bucket()
1104 );
1105 let mut stmt = self
1106 .conn
1107 .prepare(&sql)
1108 .context("failed to prepare period summary")?;
1109 let rows = stmt
1110 .query_map([], |row| {
1111 Ok(PeriodRow {
1112 bucket: row.get(0)?,
1113 events: row.get(1)?,
1114 input_before: row.get(2)?,
1115 input_after: row.get(3)?,
1116 output_before: row.get(4)?,
1117 output_after: row.get(5)?,
1118 })
1119 })
1120 .context("failed to query period summary")?;
1121 rows.collect::<rusqlite::Result<Vec<_>>>()
1122 .context("failed to read period summary")
1123 }
1124}
1125
1126fn is_duplicate_column(e: &rusqlite::Error) -> bool {
1130 e.to_string().to_lowercase().contains("duplicate column")
1131}
1132
1133pub fn db_path() -> Result<PathBuf> {
1136 default_db_path()
1137}
1138
1139fn default_db_path() -> Result<PathBuf> {
1140 if let Some(p) = llmtrim_core::config::RuntimeConfig::get().db_path.clone() {
1141 return Ok(p);
1142 }
1143 let base = if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
1144 PathBuf::from(xdg)
1145 } else {
1146 let home = std::env::var("HOME")
1147 .or_else(|_| std::env::var("USERPROFILE"))
1148 .context("set HOME (or USERPROFILE), or LLMTRIM_DB_PATH")?;
1149 PathBuf::from(home).join(".local/share")
1150 };
1151 Ok(base.join("llmtrim").join("tracking.db"))
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156 use super::*;
1157
1158 fn rec(provider: &str, exact: bool, before: i64, after: i64) -> Record {
1159 Record {
1160 provider: provider.to_string(),
1161 model: Some("m".to_string()),
1162 tokenizer: "t".to_string(),
1163 exact,
1164 input_before: before,
1165 input_after: after,
1166 output_before: None,
1167 output_after: None,
1168 compress_micros: None,
1169 cache_read_tokens: None,
1170 fresh_input_tokens: None,
1171 cache_write_tokens: None,
1172 output_shaped: None,
1173 frozen_input_tokens: None,
1174 outcome: None,
1175 }
1176 }
1177
1178 fn breakdown_turn(session: &str) -> BreakdownTurn {
1179 BreakdownTurn {
1180 session_id: session.to_string(),
1181 cc_session_id: None,
1182 agent: "claude-code".to_string(),
1183 project: Some("/proj".to_string()),
1184 session_name: None,
1185 provider: "anthropic".to_string(),
1186 model: Some("claude-sonnet-4".to_string()),
1187 sub_provider: None,
1188 window: 200_000,
1189 fresh_input: 1000,
1190 cache_read: 500,
1191 cache_write: 100,
1192 output_tok: 200,
1193 input_rate: 3.0,
1194 output_rate: 15.0,
1195 cache_read_rate: 0.3,
1196 cache_write_rate: 3.75,
1197 bill_micros: 6_750,
1198 input_before: 2000,
1199 input_after: 1600,
1200 }
1201 }
1202
1203 fn breakdown_block(label: &str, raw: i64) -> BreakdownBlock {
1204 BreakdownBlock {
1205 zone: "input".to_string(),
1206 section: "static".to_string(),
1207 bucket: "system".to_string(),
1208 group_label: "Static".to_string(),
1209 label: label.to_string(),
1210 mcp_server: None,
1211 tool_name: None,
1212 role: None,
1213 msg_index: None,
1214 raw_tokens: raw,
1215 fresh_tok: raw as f64,
1216 cache_read_tok: 0.0,
1217 cache_write_tok: 0.0,
1218 output_tok: 0.0,
1219 }
1220 }
1221
1222 #[test]
1223 fn record_breakdown_round_trips() {
1224 let t = Tracker::open_in_memory().unwrap();
1225 let id = t
1226 .record_breakdown(
1227 &breakdown_turn("sess-a"),
1228 &[
1229 breakdown_block("System prompt", 800),
1230 breakdown_block("MCP tools", 200),
1231 ],
1232 )
1233 .expect("record breakdown");
1234 assert!(id > 0);
1235 let turns: i64 = t
1236 .conn
1237 .query_row("SELECT COUNT(*) FROM breakdown_turns", [], |r| r.get(0))
1238 .unwrap();
1239 let blocks: i64 = t
1240 .conn
1241 .query_row(
1242 "SELECT COUNT(*) FROM breakdown_blocks WHERE turn_id = ?1",
1243 [id],
1244 |r| r.get(0),
1245 )
1246 .unwrap();
1247 assert_eq!((turns, blocks), (1, 2));
1248 }
1249
1250 #[test]
1251 fn prune_breakdown_caps_turns_and_orphan_blocks() {
1252 let t = Tracker::open_in_memory().unwrap();
1253 for i in 0..5 {
1254 t.record_breakdown(
1255 &breakdown_turn(&format!("s{i}")),
1256 &[breakdown_block("System prompt", 10)],
1257 )
1258 .unwrap();
1259 }
1260 let deleted = t.prune_breakdown(2).unwrap();
1261 assert_eq!(deleted, 3);
1262 let turns: i64 = t
1263 .conn
1264 .query_row("SELECT COUNT(*) FROM breakdown_turns", [], |r| r.get(0))
1265 .unwrap();
1266 let blocks: i64 = t
1267 .conn
1268 .query_row("SELECT COUNT(*) FROM breakdown_blocks", [], |r| r.get(0))
1269 .unwrap();
1270 assert_eq!((turns, blocks), (2, 2));
1271 }
1272
1273 #[test]
1274 fn record_and_summarize() {
1275 let t = Tracker::open_in_memory().unwrap();
1276 t.record(&rec("openai", true, 100, 60)).unwrap();
1277 t.record(&rec("openai", true, 50, 40)).unwrap();
1278 t.record(&rec("anthropic", false, 200, 150)).unwrap();
1279
1280 let s = t.summary().unwrap();
1281 assert_eq!(s.events, 3);
1282 assert_eq!(s.input_before, 350);
1283 assert_eq!(s.input_after, 250);
1284 assert_eq!(s.saved(), 100);
1285 assert!((s.saved_pct() - 28.57).abs() < 0.1);
1286 assert!(s.any_approximate, "an anthropic (approx) row exists");
1287 assert_eq!(s.by_provider.len(), 2);
1288
1289 let oa = s
1290 .by_provider
1291 .iter()
1292 .find(|r| r.provider == "openai")
1293 .unwrap();
1294 assert_eq!(oa.events, 2);
1295 assert!(oa.exact);
1296 let an = s
1297 .by_provider
1298 .iter()
1299 .find(|r| r.provider == "anthropic")
1300 .unwrap();
1301 assert!(!an.exact);
1302 }
1303
1304 #[test]
1305 fn empty_ledger_summary_is_zero() {
1306 let t = Tracker::open_in_memory().unwrap();
1307 let s = t.summary().unwrap();
1308 assert_eq!(s.events, 0);
1309 assert_eq!(s.saved(), 0);
1310 assert_eq!(s.saved_pct(), 0.0);
1311 assert!(!s.any_approximate);
1312 assert_eq!(s.output_before, 0);
1313 assert_eq!(s.output_after, 0);
1314 assert_eq!(s.output_events, 0);
1315 }
1316
1317 #[test]
1318 fn frozen_meter_sums_metered_rows_only() {
1319 let t = Tracker::open_in_memory().unwrap();
1320 t.record(&rec("anthropic", true, 1000, 900)).unwrap();
1322 let mut r = rec("anthropic", true, 1000, 900);
1324 r.frozen_input_tokens = Some(600);
1325 t.record(&r).unwrap();
1326
1327 let s = t.summary().unwrap();
1328 assert_eq!(s.frozen_input_tokens, 600);
1329 assert_eq!(s.metered_input_before, 1000, "metered rows only");
1330 assert_eq!(s.metered_input_after, 900);
1331 assert_eq!(s.input_before, 2000);
1333
1334 let models = t.by_model().unwrap();
1336 let m = models
1337 .iter()
1338 .find(|m| m.provider == "anthropic")
1339 .expect("anthropic row");
1340 assert_eq!(m.frozen_input_tokens, 600);
1341 assert_eq!(m.metered_input_before, 1000, "metered rows only");
1342 assert_eq!(m.metered_input_after, 900);
1343 }
1344
1345 #[test]
1346 fn output_tokens_round_trip_and_aggregate() {
1347 let t = Tracker::open_in_memory().unwrap();
1348
1349 t.record(&Record {
1351 provider: "openai".to_string(),
1352 model: Some("gpt-4o".to_string()),
1353 tokenizer: "tiktoken".to_string(),
1354 exact: true,
1355 input_before: 100,
1356 input_after: 60,
1357 output_before: None,
1358 output_after: Some(42),
1359 compress_micros: Some(300),
1360 cache_read_tokens: Some(50),
1361 fresh_input_tokens: Some(80),
1362 cache_write_tokens: Some(12),
1363 output_shaped: Some(true),
1364 frozen_input_tokens: None,
1365 outcome: None,
1366 })
1367 .unwrap();
1368
1369 t.record(&Record {
1371 provider: "openai".to_string(),
1372 model: Some("gpt-4o".to_string()),
1373 tokenizer: "tiktoken".to_string(),
1374 exact: true,
1375 input_before: 80,
1376 input_after: 50,
1377 output_before: None,
1378 output_after: Some(17),
1379 compress_micros: Some(500),
1380 cache_read_tokens: Some(70),
1381 fresh_input_tokens: None,
1382 cache_write_tokens: None,
1383 output_shaped: Some(false),
1384 frozen_input_tokens: None,
1385 outcome: None,
1386 })
1387 .unwrap();
1388
1389 t.record(&rec("openai", true, 50, 30)).unwrap();
1391
1392 let s = t.summary().unwrap();
1393
1394 assert_eq!(s.events, 3);
1396
1397 assert_eq!(s.output_events, 2);
1399
1400 assert_eq!(s.output_after, 59);
1402
1403 assert_eq!(s.output_before, 0);
1405
1406 assert_eq!(s.avg_compress_micros, Some(400.0));
1408 assert_eq!(s.cache_read_tokens, 120);
1410
1411 let oa = s
1413 .by_provider
1414 .iter()
1415 .find(|r| r.provider == "openai")
1416 .unwrap();
1417 assert_eq!(oa.output_events, 2);
1418 assert_eq!(oa.output_after, 59);
1419 assert_eq!(oa.output_before, 0);
1420
1421 let models = t.by_model().unwrap();
1425 let gpt = models
1426 .iter()
1427 .find(|m| m.model.as_deref() == Some("gpt-4o"))
1428 .unwrap();
1429 assert_eq!(gpt.cache_read, 120);
1430 assert_eq!(gpt.cache_write, 12);
1431 assert_eq!(gpt.fresh_input_est, 80, "row1 usage + row2 fallback of 0");
1432 assert_eq!(gpt.output_after_shaped, 42);
1433
1434 assert_eq!(s.frozen_input_tokens, 0);
1437 assert_eq!(s.metered_input_before, 0);
1438 let m = models
1439 .iter()
1440 .find(|m| m.model.as_deref() == Some("m"))
1441 .unwrap();
1442 assert_eq!(
1443 m.fresh_input_est, 30,
1444 "no usage, no cache → fallback to input_after"
1445 );
1446 assert_eq!(m.output_after_shaped, 0, "shaped unknown → not credited");
1447 }
1448
1449 #[test]
1450 fn prune_caps_to_most_recent_rows() {
1451 let t = Tracker::open_in_memory().unwrap();
1452 for _ in 0..10 {
1453 t.record(&rec("openai", true, 10, 5)).unwrap();
1454 }
1455 let deleted = t.prune(4, None).unwrap();
1456 assert_eq!(deleted, 6, "10 rows capped to 4 → 6 deleted");
1457 assert_eq!(t.summary().unwrap().events, 4);
1458 }
1459
1460 #[test]
1461 fn prune_drops_rows_older_than_max_age() {
1462 let t = Tracker::open_in_memory().unwrap();
1463 for _ in 0..3 {
1465 t.record_with_ts(&rec("openai", true, 10, 5), "2000-01-01T00:00:00+00:00")
1466 .unwrap();
1467 }
1468 t.record(&rec("openai", true, 10, 5)).unwrap();
1469 t.record(&rec("openai", true, 10, 5)).unwrap();
1470
1471 let deleted = t.prune(DEFAULT_MAX_ROWS, Some(30)).unwrap();
1472 assert_eq!(deleted, 3, "only the three >30d-old rows are dropped");
1473 assert_eq!(t.summary().unwrap().events, 2);
1474 }
1475
1476 #[test]
1477 fn prune_without_age_keeps_old_rows_within_cap() {
1478 let t = Tracker::open_in_memory().unwrap();
1479 t.record_with_ts(&rec("openai", true, 10, 5), "2000-01-01T00:00:00+00:00")
1480 .unwrap();
1481 let deleted = t.prune(DEFAULT_MAX_ROWS, None).unwrap();
1483 assert_eq!(deleted, 0);
1484 assert_eq!(t.summary().unwrap().events, 1);
1485 }
1486
1487 #[test]
1488 fn file_ledger_enables_wal_and_busy_timeout() {
1489 let dir = std::env::temp_dir().join(format!("llmtrim_wal_{}", std::process::id()));
1492 std::fs::create_dir_all(&dir).unwrap();
1493 let path = dir.join("t.db");
1494 let t = Tracker::open_at(&path).expect("open file ledger");
1495 let mode: String = t
1496 .conn
1497 .query_row("PRAGMA journal_mode", [], |r| r.get(0))
1498 .unwrap();
1499 assert_eq!(mode.to_lowercase(), "wal", "WAL journal mode is set");
1500 let timeout: i64 = t
1501 .conn
1502 .query_row("PRAGMA busy_timeout", [], |r| r.get(0))
1503 .unwrap();
1504 assert!(timeout >= 2000, "busy_timeout set (got {timeout})");
1505 let _ = std::fs::remove_dir_all(&dir);
1506 }
1507
1508 #[test]
1509 fn migrate_is_idempotent_on_existing_columns() {
1510 let t = Tracker::open_in_memory().unwrap();
1513 t.migrate()
1514 .expect("second migrate swallows duplicate-column ALTERs");
1515 t.record(&rec("openai", true, 10, 5)).unwrap();
1516 assert_eq!(t.summary().unwrap().events, 1);
1517 }
1518
1519 #[test]
1520 fn transport_reset_outcome_round_trips() {
1521 let t = Tracker::open_in_memory().unwrap();
1524 let mut r = rec("anthropic", true, 100, 80);
1525 r.outcome = Some("transport_reset".into());
1526 t.record(&r).unwrap();
1527 let outcome: Option<String> = t
1528 .conn
1529 .query_row(
1530 "SELECT outcome FROM compressions WHERE provider = 'anthropic'",
1531 [],
1532 |row| row.get(0),
1533 )
1534 .unwrap();
1535 assert_eq!(outcome.as_deref(), Some("transport_reset"));
1536 t.migrate().expect("outcome ALTER is idempotent");
1538 t.record(&rec("openai", true, 10, 5)).unwrap();
1539 assert_eq!(t.summary().unwrap().events, 2);
1540 }
1541
1542 #[test]
1543 fn duplicate_column_classifier_matches_only_that_error() {
1544 let t = Tracker::open_in_memory().unwrap();
1547 let dup = t
1548 .conn
1549 .execute("ALTER TABLE compressions ADD COLUMN model TEXT", [])
1550 .expect_err("model already exists");
1551 assert!(is_duplicate_column(&dup), "duplicate column recognized");
1552 let other = t
1553 .conn
1554 .execute("ALTER TABLE compressions ADD COLUMN", [])
1555 .expect_err("malformed ALTER");
1556 assert!(
1557 !is_duplicate_column(&other),
1558 "non-duplicate error not swallowed"
1559 );
1560 }
1561
1562 fn test_rate_lookup(provider: &str, model: Option<&str>) -> (f64, f64, f64, f64) {
1563 match (provider, model) {
1564 ("anthropic", Some("claude-opus-5")) => (5.0, 25.0, 0.5, 6.25),
1565 _ => (0.0, 0.0, 0.0, 0.0),
1566 }
1567 }
1568
1569 fn seed_zero_rate(t: &Tracker, session: &str, model: &str) {
1570 let mut turn = breakdown_turn(session);
1571 turn.model = Some(model.into());
1572 turn.input_rate = 0.0;
1573 turn.output_rate = 0.0;
1574 turn.cache_read_rate = 0.0;
1575 turn.cache_write_rate = 0.0;
1576 turn.bill_micros = 0;
1577 turn.fresh_input = 1_000;
1578 turn.cache_read = 2_000;
1579 turn.cache_write = 100;
1580 turn.output_tok = 50;
1581 t.record_breakdown(&turn, &[]).unwrap();
1582 }
1583
1584 fn rates_row(t: &Tracker, session: &str) -> (f64, f64, f64, f64, i64) {
1585 t.conn
1586 .query_row(
1587 "SELECT input_rate, output_rate, cache_read_rate, cache_write_rate, bill_micros
1588 FROM breakdown_turns WHERE session_id = ?1",
1589 params![session],
1590 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
1591 )
1592 .unwrap()
1593 }
1594
1595 #[test]
1596 fn reprice_zero_rate_turns_is_narrowly_guarded() {
1597 let t = Tracker::open_in_memory().unwrap();
1599 seed_zero_rate(&t, "sess-unpriced", "claude-opus-5");
1600
1601 let mut priced = breakdown_turn("sess-priced");
1602 priced.model = Some("claude-opus-5".into());
1603 priced.input_rate = 5.0;
1604 priced.output_rate = 25.0;
1605 priced.cache_read_rate = 0.5;
1606 priced.cache_write_rate = 6.25;
1607 priced.bill_micros = 999;
1608 t.record_breakdown(&priced, &[]).unwrap();
1609
1610 seed_zero_rate(&t, "sess-other", "some-future-model");
1611
1612 let n = t
1613 .reprice_zero_rate_turns("anthropic", "claude-opus-5", 5.0, 25.0, 0.5, 6.25)
1614 .unwrap();
1615 assert_eq!(n, 1);
1616 assert_eq!(
1618 rates_row(&t, "sess-unpriced"),
1619 (5.0, 25.0, 0.5, 6.25, 7_875)
1620 );
1621 assert_eq!(rates_row(&t, "sess-priced"), (5.0, 25.0, 0.5, 6.25, 999));
1622 assert_eq!(rates_row(&t, "sess-other"), (0.0, 0.0, 0.0, 0.0, 0));
1623 }
1624
1625 #[test]
1626 fn daily_reprice_uses_lookup_and_skips_unknown_models() {
1627 register_rate_lookup(test_rate_lookup);
1629 let t = Tracker::open_in_memory().unwrap();
1630 let _ = t.conn.execute("DELETE FROM ledger_meta", []);
1633
1634 seed_zero_rate(&t, "sess-opus", "claude-opus-5");
1635 seed_zero_rate(&t, "sess-unknown", "not-in-price-table");
1636
1637 t.maybe_reprice_unpriced_daily().unwrap();
1638 assert_eq!(rates_row(&t, "sess-opus"), (5.0, 25.0, 0.5, 6.25, 7_875));
1639 assert_eq!(rates_row(&t, "sess-unknown"), (0.0, 0.0, 0.0, 0.0, 0));
1640
1641 seed_zero_rate(&t, "sess-late", "claude-opus-5");
1643 t.maybe_reprice_unpriced_daily().unwrap();
1644 assert_eq!(rates_row(&t, "sess-late"), (0.0, 0.0, 0.0, 0.0, 0));
1645
1646 t.conn
1648 .execute(
1649 "UPDATE ledger_meta SET value = '1970-01-01' WHERE key = ?1",
1650 params![ZERO_RATE_CHECK_DAY_KEY],
1651 )
1652 .unwrap();
1653 t.maybe_reprice_unpriced_daily().unwrap();
1654 assert_eq!(rates_row(&t, "sess-late"), (5.0, 25.0, 0.5, 6.25, 7_875));
1655 }
1656}