Skip to main content

behest_runtime/
cache_stats.rs

1//! Aggregated prompt cache statistics for a run or a window of events.
2//!
3//! [`CacheStats`] reduces a sequence of [`AgentEvent`] values (typically
4//! replayed from a [`RuntimeEventStore`](crate::event_store::RuntimeEventStore)) into
5//! the totals needed to evaluate cache effectiveness:
6//!
7//! - Total input / output tokens consumed
8//! - Total cache writes (Anthropic `cache_creation_input_tokens`)
9//! - Total cache reads (Anthropic / DeepSeek `cache_read_input_tokens`)
10//! - Total cached input (OpenAI `prompt_tokens_details.cached_tokens`)
11//! - Model call count
12//!
13//! Use [`CacheStats::cache_hit_rate`] to get a normalized `0.0..=1.0` score
14//! describing what fraction of input tokens came from cache.
15//!
16//! # Example
17//!
18//! ```rust,no_run
19//! use behest_runtime::cache_stats::CacheStats;
20//! use behest_runtime::event::AgentEvent;
21//! use behest_runtime::event_store::{RuntimeEventStore, MemoryRuntimeEventStore};
22//! use behest_runtime::run::RunId;
23//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
24//! let store = MemoryRuntimeEventStore::new();
25//! // ... append events to `store` for a given run ...
26//! let run_id = behest_runtime::run::RunId::new();
27//! let envelopes = store.list_after(run_id, None, 1024).await?;
28//! let stats = CacheStats::from_envelopes(&envelopes);
29//! println!("hit rate: {:.1}%", stats.cache_hit_rate() * 100.0);
30//! # Ok(()) }
31//! ```
32
33use serde::{Deserialize, Serialize};
34
35use super::event::{AgentEvent, CacheMetrics};
36use super::stream::RuntimeEventEnvelope;
37use behest_event::UsageRecorded;
38
39/// Aggregated prompt-cache statistics over a window of events.
40#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub struct CacheStats {
42    /// Number of `UsageRecorded` events observed.
43    pub call_count: u64,
44    /// Sum of `UsageRecorded.usage.input_tokens` across the window.
45    pub total_input_tokens: u64,
46    /// Sum of `UsageRecorded.usage.output_tokens` across the window.
47    pub total_output_tokens: u64,
48    /// Sum of [`CacheMetrics::cache_creation_input_tokens`].
49    ///
50    /// Populated by Anthropic for input tokens that were written to a
51    /// cache entry during this run. `None` per-event is treated as `0`.
52    pub total_cache_creation_input_tokens: u64,
53    /// Sum of [`CacheMetrics::cache_read_input_tokens`].
54    ///
55    /// Populated by Anthropic and DeepSeek for input tokens served from
56    /// an existing cache entry.
57    pub total_cache_read_input_tokens: u64,
58    /// Sum of [`CacheMetrics::cached_input_tokens`].
59    ///
60    /// Populated by OpenAI's `prompt_tokens_details.cached_tokens`.
61    pub total_cached_input_tokens: u64,
62}
63
64impl CacheStats {
65    /// Computes aggregate stats by walking a slice of [`AgentEvent`]
66    /// values in arrival order.
67    ///
68    /// Events that are neither `UsageRecorded` nor `CacheMetrics` are
69    /// ignored. Order does not matter for accumulation but is preserved
70    /// for deterministic output in the returned `Debug` print.
71    #[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    /// Computes aggregate stats from a slice of [`RuntimeEventEnvelope`]
107    /// values (the shape returned by
108    /// [`RuntimeEventStore::list_after`](crate::event_store::RuntimeEventStore::list_after)).
109    #[must_use]
110    pub fn from_envelopes(envelopes: &[RuntimeEventEnvelope]) -> Self {
111        let events: Vec<&AgentEvent> = envelopes.iter().map(|e| &e.event).collect();
112        // SAFETY: events live as long as envelopes
113        let events: Vec<AgentEvent> = events.into_iter().cloned().collect();
114        Self::from_events(&events)
115    }
116
117    /// Returns the total cache-related input tokens across all three
118    /// provider-specific fields.
119    #[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    /// Returns the fraction of input tokens that came from cache.
127    ///
128    /// The denominator is `input_tokens + cache_creation + cache_read +
129    /// cached` — the total input tokens actually processed by the
130    /// provider, whether fresh or served from cache. The numerator is
131    /// `cache_read + cached` (tokens that were served from a cache
132    /// entry instead of recomputed).
133    ///
134    /// Returns `0.0` when the window contains no input tokens.
135    #[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    /// Combines two `CacheStats` by summing every field. Useful for
147    /// aggregating per-run stats into a session-level or
148    /// session-store-wide total.
149    ///
150    /// Uses `saturating_add` to match [`Self::from_events`] and to remain
151    /// panic-free at the `u64` boundary when aggregating very long runs.
152    #[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), // Anthropic: wrote 800 to cache
238            usage_event(500, 30),
239            cache_metrics_event(0, 400, 0), // Anthropic: read 400 from cache
240        ];
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), // OpenAI: 1500 of 2000 cached
255        ];
256        let stats = CacheStats::from_events(&events);
257        assert_eq!(stats.total_cached_input_tokens, 1500);
258        // hit rate: cached / (input + cached) = 1500 / 3500
259        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        // One call, input=1000, 200 read from Anthropic cache, 100 from OpenAI cache.
272        // Denominator: 1000 + 200 + 100 = 1300. Numerator: 200 + 100 = 300.
273        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        // Build envelopes from events
342        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        // We don't have the store here; just verify the helper matches
359        // `from_events` by construction. (from_envelopes is a thin shim.)
360        let a = CacheStats::from_events(&events);
361        let b = {
362            // Simulate the envelope deref: same events, same stats
363            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}