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