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 quality_signal: None,
170 attribution_group: None,
171 attribution_id: None,
172 baseline_ref: None,
173 price_version: None,
174 customer_approval: None,
175 settlement_status: None,
176 is_first_inject: None,
177 cache_read_per_m_usd: None,
178 cache_write_per_m_usd: None,
179 }
180 }
181
182 #[test]
183 fn aggregates_per_day_and_dimensions() {
184 let events = vec![
185 event("2026-06-01T08:00:00+00:00", "proj_a", "claude", 300, 700),
186 event("2026-06-01T09:00:00+00:00", "proj_a", "claude", 100, 900),
187 event("2026-06-02T08:00:00+00:00", "proj_a", "claude", 50, 50),
188 event("2026-06-01T08:00:00+00:00", "proj_b", "gpt", 10, 90),
189 ];
190 let rows = aggregate_events(&events, &DateRange::default());
191 assert_eq!(rows.len(), 3, "day×project×model groups");
192
193 let a1 = rows
194 .iter()
195 .find(|r| r.project == "proj_a" && r.date == "2026-06-01")
196 .unwrap();
197 assert_eq!(a1.tokens_actual, 400);
198 assert_eq!(a1.tokens_saved, 1600);
199 assert!((a1.cost_usd - 400.0 / 1e6 * 2.5).abs() < 1e-12);
200 assert!((a1.savings_usd - 1600.0 / 1e6 * 2.5).abs() < 1e-12);
201 }
202
203 #[test]
204 fn date_range_filters_inclusively() {
205 let events = vec![
206 event("2026-06-01T08:00:00+00:00", "p", "m", 1, 1),
207 event("2026-06-02T08:00:00+00:00", "p", "m", 1, 1),
208 event("2026-06-03T08:00:00+00:00", "p", "m", 1, 1),
209 ];
210 let range = DateRange {
211 from: Some("2026-06-02".into()),
212 to: Some("2026-06-02".into()),
213 };
214 let rows = aggregate_events(&events, &range);
215 assert_eq!(rows.len(), 1);
216 assert_eq!(rows[0].date, "2026-06-02");
217 }
218
219 #[test]
220 fn bounce_adjustment_reduces_savings() {
221 let mut ev = event("2026-06-01T08:00:00+00:00", "p", "m", 100, 1000);
222 ev.bounce_adjustment = 400;
223 let rows = aggregate_events(&[ev], &DateRange::default());
224 assert_eq!(rows[0].tokens_saved, 600);
225 }
226
227 #[test]
228 fn csv_field_quotes_specials() {
229 assert_eq!(csv_field("plain"), "plain");
230 assert_eq!(csv_field("a,b"), "\"a,b\"");
231 assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
232 }
233}