1use std::collections::HashMap;
2
3use crate::event::{Event, LlmCallStatus};
4use crate::provider::TokenUsage;
5
6#[derive(Debug, Default, Clone)]
7pub struct CostSummary {
8 pub calls: u64,
9 pub failures: u64,
10 pub usage: TokenUsage,
11 pub wallclock_ms: u64,
12}
13
14impl CostSummary {
15 fn accumulate(&mut self, usage: &TokenUsage, wallclock_ms: u64, failed: bool) {
16 self.calls += 1;
17 if failed {
18 self.failures += 1;
19 }
20 self.usage.input = self.usage.input.saturating_add(usage.input);
21 self.usage.cached_input = self.usage.cached_input.saturating_add(usage.cached_input);
22 self.usage.output = self.usage.output.saturating_add(usage.output);
23 self.usage.cache_write = self.usage.cache_write.saturating_add(usage.cache_write);
24 self.wallclock_ms = self.wallclock_ms.saturating_add(wallclock_ms);
25 }
26}
27
28pub fn summarize_by_model(events: &[Event]) -> HashMap<String, CostSummary> {
29 let mut out: HashMap<String, CostSummary> = HashMap::new();
30 for e in events {
31 if let Event::LlmCall {
32 model,
33 usage,
34 wallclock_ms,
35 status,
36 ..
37 } = e
38 {
39 let entry = out.entry(model.clone()).or_default();
40 entry.accumulate(
41 usage,
42 *wallclock_ms,
43 matches!(status, LlmCallStatus::Errored { .. }),
44 );
45 }
46 }
47 out
48}
49
50pub fn summarize_by_provider(events: &[Event]) -> HashMap<String, CostSummary> {
51 let mut out: HashMap<String, CostSummary> = HashMap::new();
52 for e in events {
53 if let Event::LlmCall {
54 provider,
55 usage,
56 wallclock_ms,
57 status,
58 ..
59 } = e
60 {
61 let entry = out.entry(provider.clone()).or_default();
62 entry.accumulate(
63 usage,
64 *wallclock_ms,
65 matches!(status, LlmCallStatus::Errored { .. }),
66 );
67 }
68 }
69 out
70}
71
72pub fn total(events: &[Event]) -> CostSummary {
73 let mut acc = CostSummary::default();
74 for e in events {
75 if let Event::LlmCall {
76 usage,
77 wallclock_ms,
78 status,
79 ..
80 } = e
81 {
82 acc.accumulate(
83 usage,
84 *wallclock_ms,
85 matches!(status, LlmCallStatus::Errored { .. }),
86 );
87 }
88 }
89 acc
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 fn ok_call(model: &str, provider: &str, in_tok: u64, out_tok: u64) -> Event {
97 Event::LlmCall {
98 model: model.into(),
99 provider: provider.into(),
100 usage: TokenUsage {
101 input: in_tok,
102 output: out_tok,
103 ..Default::default()
104 },
105 wallclock_ms: 100,
106 status: LlmCallStatus::Ok,
107 ttft_ms: None,
108 tokens_per_second: None,
109 run_id: None,
110 node_id: None,
111 }
112 }
113
114 fn err_call(model: &str) -> Event {
115 Event::LlmCall {
116 model: model.into(),
117 provider: "p".into(),
118 usage: TokenUsage {
119 input: 5,
120 ..Default::default()
121 },
122 wallclock_ms: 50,
123 status: LlmCallStatus::Errored {
124 message: "boom".into(),
125 },
126 ttft_ms: None,
127 tokens_per_second: None,
128 run_id: None,
129 node_id: None,
130 }
131 }
132
133 #[test]
134 fn summarize_by_model_groups_multiple_calls() {
135 let events = vec![
136 ok_call("opus", "anthropic", 10, 20),
137 ok_call("opus", "anthropic", 5, 8),
138 ok_call("mini", "openai", 3, 4),
139 ];
140 let s = summarize_by_model(&events);
141 assert_eq!(s.get("opus").unwrap().calls, 2);
142 assert_eq!(s.get("opus").unwrap().usage.input, 15);
143 assert_eq!(s.get("opus").unwrap().usage.output, 28);
144 assert_eq!(s.get("mini").unwrap().calls, 1);
145 }
146
147 #[test]
148 fn failures_counted_separately() {
149 let events = vec![ok_call("m", "p", 1, 2), err_call("m"), err_call("m")];
150 let s = summarize_by_model(&events);
151 assert_eq!(s.get("m").unwrap().calls, 3);
152 assert_eq!(s.get("m").unwrap().failures, 2);
153 }
154
155 #[test]
156 fn total_sums_wallclock() {
157 let events = vec![ok_call("a", "p", 1, 1), ok_call("b", "p", 2, 2)];
158 let t = total(&events);
159 assert_eq!(t.calls, 2);
160 assert_eq!(t.wallclock_ms, 200);
161 }
162
163 #[test]
164 fn non_llm_events_ignored() {
165 use crate::event::{FlowRunId, FlowStatus};
166 let run_id = FlowRunId::now();
167 let events = vec![
168 Event::FlowStart {
169 run_id: run_id.clone(),
170 flow_name: "t".into(),
171 parent_run_id: None,
172 parent_node_id: None,
173 spawned: false,
174 },
175 ok_call("m", "p", 5, 5),
176 Event::FlowEnd {
177 run_id,
178 flow_name: "t".into(),
179 status: FlowStatus::Ok,
180 },
181 ];
182 let t = total(&events);
183 assert_eq!(t.calls, 1);
184 assert_eq!(t.usage.input, 5);
185 }
186}