Skip to main content

llmtrim_ledger/
tracking.rs

1//! SQLite savings ledger.
2//!
3//! A rusqlite ledger at `~/.local/share/<tool>/tracking.db` that records
4//! **real-tokenizer** counts and carries nullable
5//! output-token columns for the round-trip cost model once the proxy phase can
6//! measure them. Recording is best-effort at the CLI layer: a ledger failure must
7//! never block the user's compressed output.
8
9use std::path::{Path, PathBuf};
10use std::sync::OnceLock;
11
12use anyhow::{Context, Result};
13use rusqlite::{Connection, OptionalExtension, params};
14
15/// `(input, output, cache_read, cache_write)` USD per 1M tokens. All zeros = still unpriced.
16pub type RateLookup = fn(provider: &str, model: Option<&str>) -> (f64, f64, f64, f64);
17
18/// Optional pricing hook set by the CLI (`rates_for`). When unset (tray, tests, bare
19/// ledger), daily zero-rate reprice is a no-op — the data layer stays price-blind.
20static RATE_LOOKUP: OnceLock<RateLookup> = OnceLock::new();
21
22/// Register the process-wide rate lookup used by [`Tracker::maybe_reprice_unpriced_daily`].
23/// First registration wins; later calls are ignored. Call once from CLI startup.
24pub 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/// One compression event.
31#[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    /// Microseconds spent compressing this request (proxy overhead); `None` for CLI paths.
42    pub compress_micros: Option<i64>,
43    /// Provider-reported cached input tokens reused on this request (Anthropic
44    /// `cache_read_input_tokens`), billed at ~10% of input price — a real bill discount the
45    /// token % can't show. `None` when the provider reports none / on CLI paths.
46    pub cache_read_tokens: Option<i64>,
47    /// Provider-reported *uncached* input tokens billed at the full rate (Anthropic
48    /// `input_tokens`; OpenAI `prompt_tokens − cached_tokens`). With `cache_read_tokens`
49    /// and `cache_write_tokens` this reconstructs the real input bill. `None` when the
50    /// response carried no usage / on CLI paths.
51    pub fresh_input_tokens: Option<i64>,
52    /// Provider-reported cache-write tokens (Anthropic `cache_creation_input_tokens`,
53    /// billed at 1.25×). `None` for providers without a write surcharge / no usage.
54    pub cache_write_tokens: Option<i64>,
55    /// Whether the request that was actually forwarded carried the output-shaping
56    /// instruction (Stage F ran and the compressed body was kept). `None` on rows recorded
57    /// before this column existed.
58    pub output_shaped: Option<bool>,
59    /// Tokens in the frozen (cache-controlled) prefix the stages skipped by cache-zone
60    /// discipline. `input_before − frozen` is the compressible surface — the honest
61    /// denominator for the "saved on new content" figure. `None` on pre-meter rows.
62    pub frozen_input_tokens: Option<i64>,
63    /// Explicit non-success outcome for a turn that never produced measurable output, e.g.
64    /// `transport_reset` when the upstream keep-alive died before any response,
65    /// `empty_stream` when a 2xx body closed with only synthetic zero usage / no content,
66    /// `incomplete_stream` when content was emitted but positive usage never arrived
67    /// (client abort or truncated stream). `None` on ordinary rows (including pre-column
68    /// ledgers) so old readers stay valid.
69    pub outcome: Option<String>,
70}
71
72/// One attributed proxy turn for the breakdown view: identity + provider-reported usage +
73/// the pricing snapshot frozen at the time of the request. Per-source dollars are
74/// derived at render from the blocks' token splits times these frozen rates, so a
75/// historical session always shows what it actually cost even after price tables move.
76#[derive(Debug, Clone, Default)]
77pub struct BreakdownTurn {
78    pub session_id: String,
79    /// Claude Code's own session id (`x-claude-code-session-id` header), when present.
80    /// Distinct from `session_id` (a hash of the system prompt) — this lets the status
81    /// line match a session exactly. `None` for non-Claude-Code traffic or older rows.
82    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    /// The `sub` backend that actually served this turn (`codex`/`kimi`), when one did. `None`
89    /// for a turn Anthropic served — including a fallback-mode turn where Anthropic answered and
90    /// the chain never fired. `provider` above stays the *wire* provider (`anthropic`, the shape
91    /// we parse and reply in), so this is the only record of who really answered; `model` already
92    /// carries the upstream model id on a rerouted turn.
93    pub sub_provider: Option<String>,
94    /// Context window of the model, for occupancy %.
95    pub window: i64,
96    pub fresh_input: i64,
97    pub cache_read: i64,
98    pub cache_write: i64,
99    pub output_tok: i64,
100    /// Frozen rates, USD per 1M tokens.
101    pub input_rate: f64,
102    pub output_rate: f64,
103    pub cache_read_rate: f64,
104    pub cache_write_rate: f64,
105    /// Frozen total bill of this turn in micro-USD (integer, drift-free for sums).
106    pub bill_micros: i64,
107    /// Input tokens before / after llmtrim's compression — the per-turn savings, summed per
108    /// session to show "how much we trimmed" alongside the spend.
109    pub input_before: i64,
110    pub input_after: i64,
111}
112
113/// One attributed source block of a turn, with the provider usage reconciled onto it.
114#[derive(Debug, Clone, Default)]
115pub struct BreakdownBlock {
116    pub zone: String,
117    pub section: String,
118    pub bucket: String,
119    /// Source group: `Static` / `Messages` / `Output`.
120    pub group_label: String,
121    /// Source category label within the group (e.g. "System prompt", "MCP tools").
122    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    /// Attributed block token count (pre-reconcile measure).
128    pub raw_tokens: i64,
129    /// Provider usage reconciled onto this block (sum across blocks == turn usage).
130    pub fresh_tok: f64,
131    pub cache_read_tok: f64,
132    pub cache_write_tok: f64,
133    pub output_tok: f64,
134}
135
136/// Per-provider aggregate row.
137#[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/// Per-(provider, model) aggregate row — used to price savings with a per-model rate.
150#[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    /// Provider-reported cache-read tokens (discounted prefix), summed.
159    pub cache_read: i64,
160    /// Provider-reported cache-write tokens (surcharged), summed.
161    pub cache_write: i64,
162    /// Full-rate input tokens, summed. Rows without usage fall back to
163    /// `max(input_after − cache_read, 0)` so pre-usage ledgers still price sanely.
164    pub fresh_input_est: i64,
165    /// Output tokens from requests where the output-shaping instruction was actually
166    /// forwarded — the only output the A/B bench factor may be projected onto.
167    pub output_after_shaped: i64,
168    /// Frozen-zone meter sums over this model's metered rows (frozen recorded): total
169    /// frozen-prefix tokens and the input before/after of those same rows, so
170    /// `(metered_before − frozen) → (metered_after − frozen)` is the measured saving on
171    /// the model's compressible (new-content) surface. All zero on pre-meter rows.
172    pub frozen_input_tokens: i64,
173    pub metered_input_before: i64,
174    pub metered_input_after: i64,
175}
176
177/// Time-series bucket granularity for `by_period`.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum Period {
180    Day,
181    Week,
182    Month,
183}
184
185impl Period {
186    /// SQL expression that buckets the rfc3339 `ts` column (string-sliced, no date parse
187    /// for day/month; `strftime` only for ISO week).
188    pub(crate) fn sql_bucket(self) -> &'static str {
189        match self {
190            Period::Day => "substr(ts, 1, 10)",       // YYYY-MM-DD
191            Period::Month => "substr(ts, 1, 7)",      // YYYY-MM
192            Period::Week => "strftime('%Y-W%W', ts)", // YYYY-Www
193        }
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/// One time bucket of savings, for `--daily/--weekly/--monthly` reports.
206#[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/// Aggregate savings for the dashboard.
217#[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    /// Mean compression overhead (µs) across recorded requests; `None` if none recorded it.
228    pub avg_compress_micros: Option<f64>,
229    /// Total cached input tokens reused (Anthropic prompt-cache hits) — the discounted prefix.
230    pub cache_read_tokens: i64,
231    /// rfc3339 UTC timestamp of the most recent recorded request (`None` on an empty
232    /// ledger) — the end-to-end "traffic actually flows" signal for `status`.
233    pub last_ts: Option<String>,
234    /// Frozen-zone meter sums, restricted to rows that recorded the meter (post-feature):
235    /// total frozen-prefix tokens, and the input before/after of those same rows — so
236    /// `(metered_before − frozen) → (metered_after − frozen)` is the measured saving on
237    /// the compressible (new-content) surface. All zero on pre-meter ledgers.
238    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    /// Percentage of input tokens saved (0.0 when no data).
249    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    /// Percentage of output tokens saved (0.0 when no counterfactual data).
262    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
271/// Default ledger row cap — the most-recent N compression events are retained. Each row is
272/// metadata only (~100 bytes), so this bounds the file to roughly 10-15 MB.
273pub const DEFAULT_MAX_ROWS: i64 = 100_000;
274
275/// Retention cap for the per-source breakdown, in *turns*. A turn fans out into one
276/// `breakdown_blocks` row per source (hundreds in agent traffic), so the block table grows
277/// far faster than `compressions` and needs its own cap — reusing `DEFAULT_MAX_ROWS` here let
278/// the blocks table reach millions of rows and made `status --json` aggregate a
279/// multi-hundred-MB join on every call.
280///
281/// The expensive aggregate is now opt-in (`status --json --breakdown`), so the cap no longer
282/// governs the hot path — it only bounds disk. We keep it generous to preserve a long history
283/// for the breakdown TUI; the file scales with usage (heavy agent traffic at this cap can reach
284/// a few hundred MB to ~1 GB).
285pub const DEFAULT_MAX_BREAKDOWN_TURNS: i64 = 50_000;
286
287pub struct Tracker {
288    conn: Connection,
289}
290
291impl Tracker {
292    /// Open (creating if needed) the ledger at the default path, or the path in
293    /// `LLMTRIM_DB_PATH` when set.
294    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        // Bound the ledger on open: row cap + (if configured) age retention. The daemon
309        // opens once and re-prunes periodically (see serve.rs); CLI paths prune per call.
310        let _ = tracker.prune_default();
311        // Also bound the per-source breakdown here, not just in the daemon's write loop, so a
312        // restart immediately reclaims a table that grew unbounded under an older build (it
313        // checkpoints the WAL when it deletes). A no-op COUNT once the table is within cap.
314        let _ = tracker.prune_breakdown(Self::breakdown_turns_cap());
315        // At most one unpriced-model reprice scan per UTC day (needs [`register_rate_lookup`]).
316        let _ = tracker.maybe_reprice_unpriced_daily();
317        Ok(tracker)
318    }
319
320    /// Open the ledger for a **read-only** consumer (the breakdown TUI): migrate the schema if
321    /// needed, but do NOT prune (the daemon owns retention — a reader writing a `DELETE` on the
322    /// shared WAL is pure waste), and tune the connection for repeated SELECT-only aggregate
323    /// scans. `query_only` is set after `migrate()` (which may itself write schema on a fresh or
324    /// stale DB), so it guarantees no *query-time* writes — not that the open is write-free.
325    pub fn open_reader() -> Result<Self> {
326        let path = default_db_path()?;
327        Self::open_reader_at(&path)
328    }
329
330    /// `open_reader` at an explicit path.
331    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        // Daily reprice before query_only so a status open can heal unpriced rows.
341        let _ = tracker.maybe_reprice_unpriced_daily();
342        // Read-side tuning; all best-effort (a missing pragma must not break the TUI).
343        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        // Set last: from here the connection refuses query-time writes (the daemon keeps pruning).
349        let _ = tracker.conn.pragma_update(None, "query_only", "ON");
350        Ok(tracker)
351    }
352
353    /// Consume the tracker and return its raw connection. Used by the breakdown query
354    /// layer's tests to read back an in-memory ledger through one connection.
355    pub fn into_connection(self) -> Connection {
356        self.conn
357    }
358
359    /// Shared connection for read-side money / breakdown queries that live outside `Tracker`
360    /// (e.g. [`crate::money::money_totals`]). Prefer this over re-opening the file so in-memory
361    /// test ledgers and the daemon's live path stay on one SQLite handle.
362    pub fn connection(&self) -> &Connection {
363        &self.conn
364    }
365
366    /// In-memory ledger (tests).
367    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        // The ledger is written by the daemon thread AND by every CLI compress/send while
376        // the `status` TUI reads it every ~2s. With rusqlite's defaults (rollback journal,
377        // busy_timeout 0) a concurrent writer/reader hits SQLITE_BUSY immediately, dropping
378        // rows or failing reads. WAL lets a reader and a writer proceed together; a 2s busy
379        // timeout absorbs the brief writer-vs-writer overlap. Best-effort: an in-memory DB
380        // (tests) can't use WAL, and a missing pragma must not break recording.
381        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        // Additive columns for ledgers created before these fields existed — each ALTER errors
458        // with "duplicate column" once it exists (and on fresh DBs the CREATE already has it),
459        // which we ignore. A *different* failure (read-only / corrupt DB) must surface here, not
460        // hide until a confusing later INSERT error, so we only swallow the duplicate-column case.
461        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        // Additive columns for breakdown_turns ledgers created before these fields existed.
478        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        // TEXT columns, not INTEGER — additive ALTERs kept separate from the loop above.
488        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        // Key/value meta (daily reprice gate, future one-shot flags). Idempotent.
507        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    /// At most once per UTC day: find `(provider, model)` still frozen at zero rates, look
519    /// up prices via [`register_rate_lookup`], and reprice rows that now have non-zero rates.
520    /// Still-unknown models stay unpriced. No lookup registered → no-op. Best-effort: errors
521    /// never fail open. See #244.
522    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        // Mark first so a slow/failing pass still enforces the once-per-day cap.
530        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    /// Distinct wire `(provider, model)` with all four rates and `bill_micros` still zero.
549    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    /// Reprice exact `(provider, model)` rows still at zero rates / zero bill.
571    /// Returns rows updated. Valid frozen rates are never touched.
572    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        // bill_micros mirrors serve.rs: tokens × $/1M = micro-USD.
582        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    /// Apply retention to the ledger: drop rows older than `max_age_days` (when set), then
646    /// trim to the most recent `max_rows`. Returns the number of rows deleted. The ledger
647    /// holds only metadata (no prompt/response text), but it must still stay bounded for the
648    /// always-on daemon — analytics only need recent history.
649    pub fn prune(&self, max_rows: i64, max_age_days: Option<i64>) -> Result<u64> {
650        let mut deleted: u64 = 0;
651        // Age-based: `ts` is rfc3339 UTC (always `+00:00`), so a lexical `<` compare against
652        // the cutoff is a correct chronological compare — no date parsing needed.
653        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        // Row cap: keep only the most recent `max_rows` rows by id.
662        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    /// Prune with the default policy: the configured row cap (`LLMTRIM_MAX_ROWS` env or
679    /// `max_rows` in the config file, default [`DEFAULT_MAX_ROWS`]) plus the configured age
680    /// retention (`LLMTRIM_RETENTION_DAYS` env or `retention_days` in the config file;
681    /// `None` = age retention disabled, row cap only).
682    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    /// The configured per-source breakdown cap in turns (`LLMTRIM_MAX_BREAKDOWN_TURNS`
690    /// env or `max_breakdown_turns` in the config file) or [`DEFAULT_MAX_BREAKDOWN_TURNS`].
691    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    /// Insert one breakdown turn and its attributed blocks in a single transaction. Returns the
729    /// turn's rowid. Best-effort at the call site: the proxy swallows any error so a failed
730    /// attribution write never blocks proxying or the main `compressions` ledger.
731    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    /// Cap the breakdown tables to the most recent `max_turns` turns, deleting the blocks of any
800    /// dropped turn. Mirrors the `compressions` row cap so the always-on daemon stays bounded.
801    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    /// Test-only: insert a breakdown turn with NULL `input_before`/`input_after` to simulate
827    /// pre-meter rows (rows recorded before the compression-meter columns were added).
828    #[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    /// Test-only: insert a breakdown turn with an explicit `ts` (for bucket-boundary tests).
848    #[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    /// Test-only: insert a record stamped with an explicit `ts`, to exercise age retention
887    /// without waiting real time (`record` always stamps `now`).
888    #[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        // Mean compression overhead + total cached-prefix tokens reused + most recent ts.
982        // AVG/SUM ignore NULL (CLI rows / pre-feature ledgers); AVG returns NULL → None when
983        // nothing has it; MAX(ts) is a lexical max, correct because ts is rfc3339 UTC.
984        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        // Frozen-zone meter aggregates over the rows that have it (post-meter), so the
995        // new-content % is measured on a consistent population, never diluted by legacy rows.
996        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    /// Per-(provider, model) aggregates, for pricing each model's savings at its own rate.
1029    /// `fresh_input_est` falls back to `max(input_after − cache_read, 0)` on rows recorded
1030    /// before usage capture existed, so legacy ledgers still get a sane (slightly
1031    /// conservative) bill estimate instead of a full-rate one.
1032    pub fn by_model(&self) -> Result<Vec<ModelRow>> {
1033        self.by_model_where("")
1034    }
1035
1036    /// [`Self::by_model`] restricted to rows recorded today (UTC) — prices the dashboard's
1037    /// "today" figure. Same day bucketing as `by_period(Day)`: `ts` is rfc3339 UTC, so its
1038    /// first 10 chars are the UTC date.
1039    pub fn by_model_today(&self) -> Result<Vec<ModelRow>> {
1040        // Sargable range (ts is rfc3339 UTC with a `+00:00` offset, written by `record()`, so a
1041        // lexical compare == chronological) lets the idx_compressions_ts index seek today's slice
1042        // rather than scan. Recent SQLite (>= 3.38) uses the index here; older versions scan, but
1043        // correctness holds either way.
1044        self.by_model_where("WHERE ts >= date('now') || 'T00:00:00+00:00'")
1045    }
1046
1047    /// Shared query for [`Self::by_model`]/[`by_model_today`]. `where_clause` is a static SQL
1048    /// fragment chosen by the callers above, never user input.
1049    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    /// Savings grouped into time buckets (day/week/month), oldest first.
1095    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
1126/// True if `e` is SQLite's "duplicate column name" error — the expected outcome of
1127/// re-running an additive `ALTER TABLE … ADD COLUMN` on a ledger that already has it.
1128/// Any other ALTER failure (read-only / corrupt) is a real error to surface.
1129fn is_duplicate_column(e: &rusqlite::Error) -> bool {
1130    e.to_string().to_lowercase().contains("duplicate column")
1131}
1132
1133/// The ledger file path (respects `LLMTRIM_DB_PATH` / `XDG_DATA_HOME`). Exposed so
1134/// `uninstall --purge` can remove it.
1135pub 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        // Pre-meter row (frozen NULL) — must not dilute the metered population.
1321        t.record(&rec("anthropic", true, 1000, 900)).unwrap();
1322        // Metered row: 600 of 1000 frozen → compressible surface 400 → 300.
1323        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        // Global sums still cover everything.
1332        assert_eq!(s.input_before, 2000);
1333
1334        // Per-model meter: same restriction to metered rows.
1335        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        // Row 1: has measured output tokens.
1350        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        // Row 2: also has measured output tokens.
1370        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        // Row 3: network-free (no output measurement).
1390        t.record(&rec("openai", true, 50, 30)).unwrap();
1391
1392        let s = t.summary().unwrap();
1393
1394        // Three total events.
1395        assert_eq!(s.events, 3);
1396
1397        // Only two rows had output_after set.
1398        assert_eq!(s.output_events, 2);
1399
1400        // Sum of the two measured output_after values.
1401        assert_eq!(s.output_after, 59);
1402
1403        // output_before stays NULL → sums to 0.
1404        assert_eq!(s.output_before, 0);
1405
1406        // Mean compression overhead over the two timed rows (the rec() row is NULL → ignored).
1407        assert_eq!(s.avg_compress_micros, Some(400.0));
1408        // Cached-prefix tokens summed over the rows that reported them.
1409        assert_eq!(s.cache_read_tokens, 120);
1410
1411        // Per-provider reflects the same aggregation.
1412        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        // Per-model billing aggregates: usage sums, the legacy fresh-input fallback
1422        // (row 2 has no fresh_input → max(50 − 70, 0) = 0), and the shaped-output split
1423        // (only row 1 was shaped → 42 of the 59 output tokens).
1424        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        // Frozen-zone meter: row 1 metered (frozen NULL → both rows here are pre-meter),
1435        // so the metered sums stay zero — see `frozen_meter_sums_metered_rows_only`.
1436        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        // Three ancient rows (explicit old ts), two fresh.
1464        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        // No age policy and under the cap → the ancient row survives.
1482        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        // WAL + a non-zero busy timeout protect the always-on daemon writer against the
1490        // the `status` TUI reader. (In-memory DBs can't run WAL, so test a file path.)
1491        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        // Re-running migrate() must not error on the additive columns it already added
1511        // (the duplicate-column ALTER is the expected, swallowed case).
1512        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        // Transport failures used to look like anonymous out=0 rows. The outcome column
1522        // lets status/monitor distinguish a reset from a real zero-output completion.
1523        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        // Re-migrate must keep the column and not reject a second insert.
1537        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        // The classifier underpinning #2: only the duplicate-column ALTER is swallowed; a
1545        // genuine failure (here, a syntax error) is reported as distinct.
1546        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        // Direct UPDATE path: only exact zero-rate rows for that model; priced rows stay put.
1598        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        // 1000*5 + 2000*0.5 + 100*6.25 + 50*25 = 7875
1617        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        // #244: once a model is in the price table, zero-rate historical turns heal on open.
1628        register_rate_lookup(test_rate_lookup);
1629        let t = Tracker::open_in_memory().unwrap();
1630        // open_in_memory does not call maybe_reprice; seed then run explicitly.
1631        // Clear any mark from a prior test sharing the process OnceLock path.
1632        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        // Same-day second pass is a no-op (even if new zero-rate rows appear).
1642        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        // After rewinding the day mark, the late row is healed.
1647        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}