1use jiff::Timestamp;
12use std::collections::BTreeMap;
13
14use super::{CostSource, RunCost, TokenUsage};
15use crate::agent::AgentName;
16use crate::flight::ItineraryId;
17use crate::pipeline::PipelineName;
18
19#[derive(Debug, Clone, Default, PartialEq)]
21pub struct Summary {
22 pub runs: u32,
24 pub usd: f64,
26 pub usage: TokenUsage,
28 pub unreported_runs: u32,
30 pub estimated_runs: u32,
32}
33
34impl Summary {
35 fn add(&mut self, cost: &RunCost) {
37 self.runs = self.runs.saturating_add(1);
38 self.usd += cost.usd;
39 self.usage = self.usage.saturating_add(cost.usage);
40
41 match cost.source {
42 CostSource::Reported => {}
43 CostSource::RateCard => self.estimated_runs = self.estimated_runs.saturating_add(1),
44 CostSource::Unreported => {
45 self.unreported_runs = self.unreported_runs.saturating_add(1);
46 }
47 }
48 }
49
50 #[must_use]
55 pub fn confidence(&self) -> CostSource {
56 if self.unreported_runs > 0 {
57 CostSource::Unreported
58 } else if self.estimated_runs > 0 {
59 CostSource::RateCard
60 } else {
61 CostSource::Reported
62 }
63 }
64
65 #[must_use]
69 pub fn measured_share(&self) -> f64 {
70 if self.runs == 0 {
71 return 1.0;
72 }
73 let inferred = self.unreported_runs.saturating_add(self.estimated_runs);
74 f64::from(self.runs.saturating_sub(inferred)) / f64::from(self.runs)
75 }
76
77 #[must_use]
79 pub fn is_fully_measured(&self) -> bool {
80 self.confidence().is_measured()
81 }
82}
83
84#[derive(Debug, Clone, Default)]
89pub struct Ledger {
90 entries: Vec<RunCost>,
91}
92
93impl Ledger {
94 #[must_use]
96 pub fn new() -> Self {
97 Self::default()
98 }
99
100 pub fn record(&mut self, cost: RunCost) {
102 self.entries.push(cost);
103 }
104
105 pub fn entries(&self) -> impl Iterator<Item = &RunCost> {
107 self.entries.iter()
108 }
109
110 #[must_use]
112 pub fn len(&self) -> usize {
113 self.entries.len()
114 }
115
116 #[must_use]
118 pub fn is_empty(&self) -> bool {
119 self.entries.is_empty()
120 }
121
122 #[must_use]
124 pub fn total(&self) -> Summary {
125 self.summarise(|_| true)
126 }
127
128 #[must_use]
130 pub fn summarise(&self, keep: impl Fn(&RunCost) -> bool) -> Summary {
131 let mut summary = Summary::default();
132 for cost in self.entries.iter().filter(|cost| keep(cost)) {
133 summary.add(cost);
134 }
135 summary
136 }
137
138 #[must_use]
140 pub fn for_itinerary(&self, itinerary: &ItineraryId) -> Summary {
141 self.summarise(|cost| &cost.itinerary == itinerary)
142 }
143
144 #[must_use]
146 pub fn since(&self, since: Timestamp) -> Summary {
147 self.summarise(|cost| cost.at >= since)
148 }
149
150 #[must_use]
155 pub fn by_agent(&self) -> BTreeMap<AgentName, Summary> {
156 let mut grouped: BTreeMap<AgentName, Summary> = BTreeMap::new();
157 for cost in &self.entries {
158 grouped.entry(cost.agent.clone()).or_default().add(cost);
159 }
160 grouped
161 }
162
163 #[must_use]
165 pub fn by_model(&self) -> BTreeMap<String, Summary> {
166 let mut grouped: BTreeMap<String, Summary> = BTreeMap::new();
167 for cost in &self.entries {
168 if let Some(model) = &cost.model {
169 grouped.entry(model.clone()).or_default().add(cost);
170 }
171 }
172 grouped
173 }
174
175 #[must_use]
181 pub fn by_pipeline(&self) -> BTreeMap<PipelineName, Summary> {
182 let mut grouped: BTreeMap<PipelineName, Summary> = BTreeMap::new();
183 for cost in &self.entries {
184 if let Some(pipeline) = &cost.pipeline {
185 grouped.entry(pipeline.clone()).or_default().add(cost);
186 }
187 }
188 grouped
189 }
190
191 #[must_use]
193 pub fn top_agents(&self, limit: usize) -> Vec<(AgentName, Summary)> {
194 let mut ranked: Vec<(AgentName, Summary)> = self.by_agent().into_iter().collect();
195 ranked.sort_by(|(left_name, left), (right_name, right)| {
197 right
198 .usd
199 .total_cmp(&left.usd)
200 .then_with(|| left_name.cmp(right_name))
201 });
202 ranked.truncate(limit);
203 ranked
204 }
205
206 pub fn prune(&mut self, before: Timestamp) -> usize {
210 let was = self.entries.len();
211 self.entries.retain(|cost| cost.at >= before);
212 was - self.entries.len()
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::cost::TokenUsage;
220 use crate::flight::RunId;
221 use std::time::Duration;
222
223 fn usage(output: u64) -> TokenUsage {
224 TokenUsage {
225 input: 100,
226 output,
227 ..TokenUsage::default()
228 }
229 }
230
231 fn reported(agent: &str, model: &str, usd: f64) -> RunCost {
232 RunCost::reported(
233 RunId::generate(),
234 ItineraryId::generate(),
235 agent.into(),
236 Some(model.to_owned()),
237 usage(10),
238 usd,
239 )
240 }
241
242 fn ledger() -> Ledger {
243 let mut ledger = Ledger::new();
244 ledger.record(reported("developer", "claude-opus-5", 4.00));
245 ledger.record(reported("tester", "codex-mini", 0.50));
246 ledger.record(reported("developer", "claude-opus-5", 2.00));
247 ledger.record(reported("reviewer", "codex-mini", 1.00));
248 ledger
249 }
250
251 #[test]
252 fn totals_add_up_across_runs() {
253 let total = ledger().total();
254
255 assert_eq!(total.runs, 4);
256 assert!((total.usd - 7.50).abs() < 1e-9);
257 assert_eq!(total.usage.output, 40);
258 }
259
260 #[test]
261 fn spend_breaks_down_by_agent() {
262 let by_agent = ledger().by_agent();
263
264 assert!((by_agent[&AgentName::from("developer")].usd - 6.00).abs() < 1e-9);
265 assert_eq!(by_agent[&AgentName::from("developer")].runs, 2);
266 assert!((by_agent[&AgentName::from("tester")].usd - 0.50).abs() < 1e-9);
267 }
268
269 #[test]
270 fn spend_breaks_down_by_model() {
271 let by_model = ledger().by_model();
272
273 assert!((by_model["claude-opus-5"].usd - 6.00).abs() < 1e-9);
274 assert!((by_model["codex-mini"].usd - 1.50).abs() < 1e-9);
275 }
276
277 #[test]
278 fn the_biggest_spender_is_identifiable() {
279 let top = ledger().top_agents(2);
280
281 assert_eq!(top[0].0, AgentName::from("developer"));
282 assert_eq!(top[1].0, AgentName::from("reviewer"));
283 assert_eq!(top.len(), 2);
284 }
285
286 #[test]
287 fn a_run_with_no_model_is_left_out_of_the_model_breakdown() {
288 let mut ledger = Ledger::new();
291 ledger.record(RunCost::reported(
292 RunId::generate(),
293 ItineraryId::generate(),
294 "analyst".into(),
295 None,
296 usage(10),
297 1.00,
298 ));
299
300 assert!(ledger.by_model().is_empty());
301 assert_eq!(ledger.total().runs, 1);
302 }
303
304 #[test]
305 fn one_unreported_run_makes_the_whole_total_a_lower_bound() {
306 let mut ledger = ledger();
308 ledger.record(RunCost::unreported(
309 RunId::generate(),
310 ItineraryId::generate(),
311 "kusto".into(),
312 None,
313 ));
314
315 let total = ledger.total();
316 assert_eq!(total.confidence(), CostSource::Unreported);
317 assert!(!total.is_fully_measured());
318 assert_eq!(total.unreported_runs, 1);
319 assert!((total.measured_share() - 0.8).abs() < 1e-9);
320 }
321
322 #[test]
323 fn an_estimate_downgrades_confidence_without_hiding_the_money() {
324 let mut ledger = Ledger::new();
325 ledger.record(reported("developer", "claude-opus-5", 4.00));
326 ledger.record(RunCost {
327 source: CostSource::RateCard,
328 ..reported("tester", "codex-mini", 1.00)
329 });
330
331 let total = ledger.total();
332 assert_eq!(total.confidence(), CostSource::RateCard);
333 assert_eq!(total.estimated_runs, 1);
334 assert!(
335 (total.usd - 5.00).abs() < 1e-9,
336 "an estimate still counts towards the bill"
337 );
338 assert!((total.measured_share() - 0.5).abs() < 1e-9);
339 }
340
341 #[test]
342 fn an_empty_ledger_is_fully_measured() {
343 let total = Ledger::new().total();
344
345 assert!(total.is_fully_measured());
346 assert!((total.measured_share() - 1.0).abs() < f64::EPSILON);
347 assert!(Ledger::new().is_empty());
348 }
349
350 #[test]
351 fn spend_is_attributable_to_one_chain() {
352 let itinerary = ItineraryId::generate();
353 let mut ledger = ledger();
354 ledger.record(RunCost {
355 itinerary: itinerary.clone(),
356 ..reported("publisher", "codex-mini", 3.00)
357 });
358
359 let summary = ledger.for_itinerary(&itinerary);
360 assert_eq!(summary.runs, 1);
361 assert!((summary.usd - 3.00).abs() < 1e-9);
362 }
363
364 #[test]
365 fn old_entries_can_be_pruned() {
366 let now = Timestamp::now();
367 let mut ledger = Ledger::new();
368 ledger.record(reported("old", "codex-mini", 1.00).at(now - Duration::from_secs(7_200)));
369 ledger.record(reported("new", "codex-mini", 2.00).at(now));
370
371 let cutoff = now - Duration::from_secs(3_600);
372 assert_eq!(ledger.since(cutoff).runs, 1);
373 assert_eq!(ledger.prune(cutoff), 1);
374 assert_eq!(ledger.len(), 1);
375 }
376}