lean_ctx/core/finops_export/
mod.rs1pub mod aliases;
17pub mod cbf;
18pub mod focus;
19pub mod vantage;
20
21use std::collections::BTreeMap;
22
23#[derive(Debug, Clone, PartialEq)]
25pub struct DailyCostRow {
26 pub date: String,
28 pub project: String,
31 pub agent_role: String,
33 pub model: String,
34 pub tool: String,
35 pub tokens_actual: u64,
37 pub tokens_saved: u64,
39 pub cost_usd: f64,
41 pub savings_usd: f64,
43}
44
45#[derive(Debug, Clone, Default)]
48pub struct DateRange {
49 pub from: Option<String>,
50 pub to: Option<String>,
51}
52
53impl DateRange {
54 fn contains(&self, date: &str) -> bool {
55 if let Some(f) = &self.from
56 && date < f.as_str()
57 {
58 return false;
59 }
60 if let Some(t) = &self.to
61 && date > t.as_str()
62 {
63 return false;
64 }
65 true
66 }
67}
68
69pub fn aggregate(range: &DateRange) -> Vec<DailyCostRow> {
72 let Some(path) = crate::core::savings_ledger::store::default_path() else {
73 return Vec::new();
74 };
75 aggregate_events(&crate::core::savings_ledger::store::load(&path), range)
76}
77
78fn aggregate_events(
79 events: &[crate::core::savings_ledger::SavingsEvent],
80 range: &DateRange,
81) -> Vec<DailyCostRow> {
82 let mut map: BTreeMap<(String, String, String, String, String), DailyCostRow> = BTreeMap::new();
83
84 for ev in events {
85 let Some(date) = ev.ts.get(..10) else {
86 continue;
87 };
88 if date.len() != 10 || !range.contains(date) {
89 continue;
90 }
91 let key = (
92 date.to_string(),
93 ev.repo_hash.clone(),
94 ev.agent_id.clone(),
95 ev.model_id.clone(),
96 ev.tool.clone(),
97 );
98 let row = map.entry(key).or_insert_with(|| DailyCostRow {
99 date: date.to_string(),
100 project: ev.repo_hash.clone(),
101 agent_role: ev.agent_id.clone(),
102 model: ev.model_id.clone(),
103 tool: ev.tool.clone(),
104 tokens_actual: 0,
105 tokens_saved: 0,
106 cost_usd: 0.0,
107 savings_usd: 0.0,
108 });
109 let net_saved = ev.saved_tokens.saturating_sub(ev.bounce_adjustment);
110 row.tokens_actual += ev.actual_tokens;
111 row.tokens_saved += net_saved;
112 row.cost_usd += ev.actual_tokens as f64 / 1_000_000.0 * ev.unit_price_per_m_usd;
113 row.savings_usd += ev.saved_usd;
114 }
115
116 map.into_values().collect()
117}
118
119pub(crate) fn csv_field(v: &str) -> String {
121 if v.contains([',', '"', '\n']) {
122 format!("\"{}\"", v.replace('"', "\"\""))
123 } else {
124 v.to_string()
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::core::savings_ledger::SavingsEvent;
132
133 pub(crate) fn event(
134 ts: &str,
135 repo: &str,
136 model: &str,
137 actual: u64,
138 saved: u64,
139 ) -> SavingsEvent {
140 SavingsEvent {
141 ts: ts.into(),
142 tool: "ctx_read".into(),
143 mechanism: crate::core::savings_ledger::MECHANISM_COMPRESSION.into(),
144 model_id: model.into(),
145 tokenizer: "o200k_base".into(),
146 baseline_tokens: actual + saved,
147 actual_tokens: actual,
148 saved_tokens: saved,
149 bounce_adjustment: 0,
150 unit_price_per_m_usd: 2.5,
151 saved_usd: saved as f64 / 1_000_000.0 * 2.5,
152 repo_hash: repo.into(),
153 agent_id: "coder".into(),
154 prev_hash: String::new(),
155 entry_hash: String::new(),
156 version: "3.9.0".into(),
157 }
158 }
159
160 #[test]
161 fn aggregates_per_day_and_dimensions() {
162 let events = vec![
163 event("2026-06-01T08:00:00+00:00", "proj_a", "claude", 300, 700),
164 event("2026-06-01T09:00:00+00:00", "proj_a", "claude", 100, 900),
165 event("2026-06-02T08:00:00+00:00", "proj_a", "claude", 50, 50),
166 event("2026-06-01T08:00:00+00:00", "proj_b", "gpt", 10, 90),
167 ];
168 let rows = aggregate_events(&events, &DateRange::default());
169 assert_eq!(rows.len(), 3, "day×project×model groups");
170
171 let a1 = rows
172 .iter()
173 .find(|r| r.project == "proj_a" && r.date == "2026-06-01")
174 .unwrap();
175 assert_eq!(a1.tokens_actual, 400);
176 assert_eq!(a1.tokens_saved, 1600);
177 assert!((a1.cost_usd - 400.0 / 1e6 * 2.5).abs() < 1e-12);
178 assert!((a1.savings_usd - 1600.0 / 1e6 * 2.5).abs() < 1e-12);
179 }
180
181 #[test]
182 fn date_range_filters_inclusively() {
183 let events = vec![
184 event("2026-06-01T08:00:00+00:00", "p", "m", 1, 1),
185 event("2026-06-02T08:00:00+00:00", "p", "m", 1, 1),
186 event("2026-06-03T08:00:00+00:00", "p", "m", 1, 1),
187 ];
188 let range = DateRange {
189 from: Some("2026-06-02".into()),
190 to: Some("2026-06-02".into()),
191 };
192 let rows = aggregate_events(&events, &range);
193 assert_eq!(rows.len(), 1);
194 assert_eq!(rows[0].date, "2026-06-02");
195 }
196
197 #[test]
198 fn bounce_adjustment_reduces_savings() {
199 let mut ev = event("2026-06-01T08:00:00+00:00", "p", "m", 100, 1000);
200 ev.bounce_adjustment = 400;
201 let rows = aggregate_events(&[ev], &DateRange::default());
202 assert_eq!(rows[0].tokens_saved, 600);
203 }
204
205 #[test]
206 fn csv_field_quotes_specials() {
207 assert_eq!(csv_field("plain"), "plain");
208 assert_eq!(csv_field("a,b"), "\"a,b\"");
209 assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
210 }
211}