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 intent_tag: None,
158 outcome: None,
159 model_original: None,
160 model_routed: None,
161 routing_savings: None,
162 response_original_tokens: None,
163 response_delivered_tokens: None,
164 agent_chain_id: None,
165 chain_depth: None,
166 measurement_method: None,
167 evidence_class: None,
168 confidence: None,
169 request_id: None,
170 session_id: None,
171 trace_id: None,
172 solution_decision: None,
173 loc_added: None,
174 loc_removed: None,
175 path: None,
176 lines_added: None,
177 lines_removed: None,
178 net: None,
179 quality_signal: None,
180 attribution_group: None,
181 attribution_id: None,
182 baseline_ref: None,
183 price_version: None,
184 customer_approval: None,
185 settlement_status: None,
186 is_first_inject: None,
187 cache_read_per_m_usd: None,
188 cache_write_per_m_usd: None,
189 governor: None,
190 }
191 }
192
193 #[test]
194 fn aggregates_per_day_and_dimensions() {
195 let events = vec![
196 event("2026-06-01T08:00:00+00:00", "proj_a", "claude", 300, 700),
197 event("2026-06-01T09:00:00+00:00", "proj_a", "claude", 100, 900),
198 event("2026-06-02T08:00:00+00:00", "proj_a", "claude", 50, 50),
199 event("2026-06-01T08:00:00+00:00", "proj_b", "gpt", 10, 90),
200 ];
201 let rows = aggregate_events(&events, &DateRange::default());
202 assert_eq!(rows.len(), 3, "day×project×model groups");
203
204 let a1 = rows
205 .iter()
206 .find(|r| r.project == "proj_a" && r.date == "2026-06-01")
207 .unwrap();
208 assert_eq!(a1.tokens_actual, 400);
209 assert_eq!(a1.tokens_saved, 1600);
210 assert!((a1.cost_usd - 400.0 / 1e6 * 2.5).abs() < 1e-12);
211 assert!((a1.savings_usd - 1600.0 / 1e6 * 2.5).abs() < 1e-12);
212 }
213
214 #[test]
215 fn date_range_filters_inclusively() {
216 let events = vec![
217 event("2026-06-01T08:00:00+00:00", "p", "m", 1, 1),
218 event("2026-06-02T08:00:00+00:00", "p", "m", 1, 1),
219 event("2026-06-03T08:00:00+00:00", "p", "m", 1, 1),
220 ];
221 let range = DateRange {
222 from: Some("2026-06-02".into()),
223 to: Some("2026-06-02".into()),
224 };
225 let rows = aggregate_events(&events, &range);
226 assert_eq!(rows.len(), 1);
227 assert_eq!(rows[0].date, "2026-06-02");
228 }
229
230 #[test]
231 fn bounce_adjustment_reduces_savings() {
232 let mut ev = event("2026-06-01T08:00:00+00:00", "p", "m", 100, 1000);
233 ev.bounce_adjustment = 400;
234 let rows = aggregate_events(&[ev], &DateRange::default());
235 assert_eq!(rows[0].tokens_saved, 600);
236 }
237
238 #[test]
239 fn csv_field_quotes_specials() {
240 assert_eq!(csv_field("plain"), "plain");
241 assert_eq!(csv_field("a,b"), "\"a,b\"");
242 assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
243 }
244}