1use serde::{Deserialize, Serialize};
34
35use super::event::{AgentEvent, CacheMetrics};
36use super::stream::RuntimeEventEnvelope;
37use behest_event::UsageRecorded;
38
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub struct CacheStats {
42 pub call_count: u64,
44 pub total_input_tokens: u64,
46 pub total_output_tokens: u64,
48 pub total_cache_creation_input_tokens: u64,
53 pub total_cache_read_input_tokens: u64,
58 pub total_cached_input_tokens: u64,
62}
63
64impl CacheStats {
65 #[must_use]
72 pub fn from_events(events: &[AgentEvent]) -> Self {
73 let mut stats = Self::default();
74 for event in events {
75 match event {
76 AgentEvent::UsageRecorded(UsageRecorded { usage, .. }) => {
77 stats.call_count = stats.call_count.saturating_add(1);
78 stats.total_input_tokens =
79 stats.total_input_tokens.saturating_add(usage.input_tokens);
80 stats.total_output_tokens = stats
81 .total_output_tokens
82 .saturating_add(usage.output_tokens);
83 }
84 AgentEvent::CacheMetrics(CacheMetrics {
85 cache_creation_input_tokens,
86 cache_read_input_tokens,
87 cached_input_tokens,
88 ..
89 }) => {
90 stats.total_cache_creation_input_tokens = stats
91 .total_cache_creation_input_tokens
92 .saturating_add(*cache_creation_input_tokens);
93 stats.total_cache_read_input_tokens = stats
94 .total_cache_read_input_tokens
95 .saturating_add(*cache_read_input_tokens);
96 stats.total_cached_input_tokens = stats
97 .total_cached_input_tokens
98 .saturating_add(*cached_input_tokens);
99 }
100 _ => {}
101 }
102 }
103 stats
104 }
105
106 #[must_use]
110 pub fn from_envelopes(envelopes: &[RuntimeEventEnvelope]) -> Self {
111 let events: Vec<&AgentEvent> = envelopes.iter().map(|e| &e.event).collect();
112 let events: Vec<AgentEvent> = events.into_iter().cloned().collect();
114 Self::from_events(&events)
115 }
116
117 #[must_use]
120 pub const fn total_cache_tokens(&self) -> u64 {
121 self.total_cache_creation_input_tokens
122 + self.total_cache_read_input_tokens
123 + self.total_cached_input_tokens
124 }
125
126 #[must_use]
136 #[allow(clippy::cast_precision_loss)]
137 pub fn cache_hit_rate(&self) -> f64 {
138 let hits = self.total_cache_read_input_tokens + self.total_cached_input_tokens;
139 let total = self.total_input_tokens + self.total_cache_tokens();
140 if total == 0 {
141 return 0.0;
142 }
143 hits as f64 / total as f64
144 }
145
146 #[must_use]
153 pub fn merge(self, other: Self) -> Self {
154 Self {
155 call_count: self.call_count.saturating_add(other.call_count),
156 total_input_tokens: self
157 .total_input_tokens
158 .saturating_add(other.total_input_tokens),
159 total_output_tokens: self
160 .total_output_tokens
161 .saturating_add(other.total_output_tokens),
162 total_cache_creation_input_tokens: self
163 .total_cache_creation_input_tokens
164 .saturating_add(other.total_cache_creation_input_tokens),
165 total_cache_read_input_tokens: self
166 .total_cache_read_input_tokens
167 .saturating_add(other.total_cache_read_input_tokens),
168 total_cached_input_tokens: self
169 .total_cached_input_tokens
170 .saturating_add(other.total_cached_input_tokens),
171 }
172 }
173}
174
175impl std::fmt::Display for CacheStats {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 writeln!(f, "CacheStats {{")?;
178 writeln!(f, " model calls: {}", self.call_count)?;
179 writeln!(f, " total input tokens: {}", self.total_input_tokens)?;
180 writeln!(f, " total output tokens: {}", self.total_output_tokens)?;
181 writeln!(
182 f,
183 " cache writes (Anthropic): {}",
184 self.total_cache_creation_input_tokens
185 )?;
186 writeln!(
187 f,
188 " cache reads (Anthropic/DeepSeek): {}",
189 self.total_cache_read_input_tokens
190 )?;
191 writeln!(
192 f,
193 " cached input (OpenAI): {}",
194 self.total_cached_input_tokens
195 )?;
196 writeln!(f, " total cache tokens: {}", self.total_cache_tokens())?;
197 writeln!(
198 f,
199 " cache hit rate: {:.2}%",
200 self.cache_hit_rate() * 100.0
201 )?;
202 write!(f, "}}")
203 }
204}
205
206#[cfg(test)]
207#[allow(clippy::unwrap_used)]
208mod tests {
209 use super::*;
210 use crate::run::RunId;
211 use behest_provider::{FinishReason, ModelName, ProviderId, TokenUsage};
212 use chrono::Utc;
213 use uuid::Uuid;
214
215 fn usage_event(input: u64, output: u64) -> AgentEvent {
216 AgentEvent::UsageRecorded(UsageRecorded {
217 run_id: RunId::new(),
218 usage: TokenUsage::new(input, output),
219 timestamp: Utc::now(),
220 })
221 }
222
223 fn cache_metrics_event(creation: u64, read: u64, cached: u64) -> AgentEvent {
224 AgentEvent::CacheMetrics(CacheMetrics {
225 run_id: RunId::new(),
226 cache_creation_input_tokens: creation,
227 cache_read_input_tokens: read,
228 cached_input_tokens: cached,
229 timestamp: Utc::now(),
230 })
231 }
232
233 #[test]
234 fn from_events_aggregates_usage_and_cache() {
235 let events = vec![
236 usage_event(1000, 50),
237 cache_metrics_event(800, 0, 0), usage_event(500, 30),
239 cache_metrics_event(0, 400, 0), ];
241 let stats = CacheStats::from_events(&events);
242 assert_eq!(stats.call_count, 2);
243 assert_eq!(stats.total_input_tokens, 1500);
244 assert_eq!(stats.total_output_tokens, 80);
245 assert_eq!(stats.total_cache_creation_input_tokens, 800);
246 assert_eq!(stats.total_cache_read_input_tokens, 400);
247 assert_eq!(stats.total_cached_input_tokens, 0);
248 }
249
250 #[test]
251 fn from_events_aggregates_openai_cached_tokens() {
252 let events = vec![
253 usage_event(2000, 100),
254 cache_metrics_event(0, 0, 1500), ];
256 let stats = CacheStats::from_events(&events);
257 assert_eq!(stats.total_cached_input_tokens, 1500);
258 let rate = stats.cache_hit_rate();
260 assert!((rate - 1500.0 / 3500.0).abs() < 1e-9);
261 }
262
263 #[test]
264 fn cache_hit_rate_zero_when_no_input() {
265 let stats = CacheStats::default();
266 assert_eq!(stats.cache_hit_rate(), 0.0);
267 }
268
269 #[test]
270 fn cache_hit_rate_mixes_anthropic_and_openai() {
271 let events = vec![usage_event(1000, 50), cache_metrics_event(0, 200, 100)];
274 let stats = CacheStats::from_events(&events);
275 let rate = stats.cache_hit_rate();
276 assert!((rate - 300.0 / 1300.0).abs() < 1e-9);
277 }
278
279 #[test]
280 fn from_events_ignores_non_usage_events() {
281 let events = vec![
282 AgentEvent::RunCompleted(crate::event::RunCompleted {
283 run_id: RunId::new(),
284 finish_reason: FinishReason::Stop,
285 iterations: 1,
286 timestamp: Utc::now(),
287 }),
288 usage_event(500, 20),
289 ];
290 let stats = CacheStats::from_events(&events);
291 assert_eq!(stats.call_count, 1);
292 assert_eq!(stats.total_input_tokens, 500);
293 }
294
295 #[test]
296 fn merge_sums_all_fields() {
297 let a = CacheStats {
298 call_count: 2,
299 total_input_tokens: 1000,
300 total_output_tokens: 50,
301 total_cache_creation_input_tokens: 800,
302 total_cache_read_input_tokens: 200,
303 total_cached_input_tokens: 0,
304 };
305 let b = CacheStats {
306 call_count: 3,
307 total_input_tokens: 2000,
308 total_output_tokens: 100,
309 total_cache_creation_input_tokens: 0,
310 total_cache_read_input_tokens: 0,
311 total_cached_input_tokens: 1500,
312 };
313 let merged = a.merge(b);
314 assert_eq!(merged.call_count, 5);
315 assert_eq!(merged.total_input_tokens, 3000);
316 assert_eq!(merged.total_output_tokens, 150);
317 assert_eq!(merged.total_cache_creation_input_tokens, 800);
318 assert_eq!(merged.total_cache_read_input_tokens, 200);
319 assert_eq!(merged.total_cached_input_tokens, 1500);
320 }
321
322 #[test]
323 fn total_cache_tokens_sums_three_fields() {
324 let stats = CacheStats {
325 total_cache_creation_input_tokens: 100,
326 total_cache_read_input_tokens: 200,
327 total_cached_input_tokens: 300,
328 ..Default::default()
329 };
330 assert_eq!(stats.total_cache_tokens(), 600);
331 }
332
333 #[test]
334 fn from_envelopes_handles_empty_list() {
335 let stats = CacheStats::from_envelopes(&[]);
336 assert_eq!(stats, CacheStats::default());
337 }
338
339 #[test]
340 fn from_envelopes_aggregates() {
341 let provider = ProviderId::new("anthropic");
343 let model = ModelName::new("claude-3-sonnet");
344 let session_id = Uuid::new_v4();
345 let run_id = RunId::new();
346
347 let events = vec![
348 AgentEvent::RunStarted(crate::event::RunStarted {
349 run_id,
350 session_id,
351 provider: provider.clone(),
352 model: model.clone(),
353 timestamp: Utc::now(),
354 }),
355 usage_event(1000, 100),
356 cache_metrics_event(0, 500, 0),
357 ];
358 let a = CacheStats::from_events(&events);
361 let b = {
362 let cloned: Vec<AgentEvent> = events.clone();
364 CacheStats::from_events(&cloned)
365 };
366 assert_eq!(a, b);
367 }
368
369 #[test]
370 fn display_format_includes_all_fields() {
371 let stats = CacheStats {
372 call_count: 1,
373 total_input_tokens: 1000,
374 total_output_tokens: 50,
375 total_cache_creation_input_tokens: 800,
376 total_cache_read_input_tokens: 200,
377 total_cached_input_tokens: 0,
378 };
379 let s = format!("{stats}");
380 assert!(s.contains("model calls: 1"));
381 assert!(s.contains("total input tokens: 1000"));
382 assert!(s.contains("cache hit rate:"));
383 }
384}