1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
//! Aggregated prompt cache statistics for a run or a window of events.
//!
//! [`CacheStats`] reduces a sequence of [`AgentEvent`] values (typically
//! replayed from a [`RuntimeEventStore`](crate::event_store::RuntimeEventStore)) into
//! the totals needed to evaluate cache effectiveness:
//!
//! - Total input / output tokens consumed
//! - Total cache writes (Anthropic `cache_creation_input_tokens`)
//! - Total cache reads (Anthropic / DeepSeek `cache_read_input_tokens`)
//! - Total cached input (OpenAI `prompt_tokens_details.cached_tokens`)
//! - Model call count
//!
//! Use [`CacheStats::cache_hit_rate`] to get a normalized `0.0..=1.0` score
//! describing what fraction of input tokens came from cache.
//!
//! # Example
//!
//! ```rust,no_run
//! use behest_runtime::cache_stats::CacheStats;
//! use behest_runtime::event::AgentEvent;
//! use behest_runtime::event_store::{RuntimeEventStore, MemoryRuntimeEventStore};
//! use behest_runtime::run::RunId;
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let store = MemoryRuntimeEventStore::new();
//! // ... append events to `store` for a given run ...
//! let run_id = behest_runtime::run::RunId::new();
//! let envelopes = store.list_after(run_id, None, 1024).await?;
//! let stats = CacheStats::from_envelopes(&envelopes);
//! println!("hit rate: {:.1}%", stats.cache_hit_rate() * 100.0);
//! # Ok(()) }
//! ```
use serde::{Deserialize, Serialize};
use super::event::{AgentEvent, CacheMetrics};
use super::stream::RuntimeEventEnvelope;
use behest_event::UsageRecorded;
/// Aggregated prompt-cache statistics over a window of events.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheStats {
/// Number of `UsageRecorded` events observed.
pub call_count: u64,
/// Sum of `UsageRecorded.usage.input_tokens` across the window.
pub total_input_tokens: u64,
/// Sum of `UsageRecorded.usage.output_tokens` across the window.
pub total_output_tokens: u64,
/// Sum of [`CacheMetrics::cache_creation_input_tokens`].
///
/// Populated by Anthropic for input tokens that were written to a
/// cache entry during this run. `None` per-event is treated as `0`.
pub total_cache_creation_input_tokens: u64,
/// Sum of [`CacheMetrics::cache_read_input_tokens`].
///
/// Populated by Anthropic and DeepSeek for input tokens served from
/// an existing cache entry.
pub total_cache_read_input_tokens: u64,
/// Sum of [`CacheMetrics::cached_input_tokens`].
///
/// Populated by OpenAI's `prompt_tokens_details.cached_tokens`.
pub total_cached_input_tokens: u64,
}
impl CacheStats {
/// Computes aggregate stats by walking a slice of [`AgentEvent`]
/// values in arrival order.
///
/// Events that are neither `UsageRecorded` nor `CacheMetrics` are
/// ignored. Order does not matter for accumulation but is preserved
/// for deterministic output in the returned `Debug` print.
#[must_use]
pub fn from_events(events: &[AgentEvent]) -> Self {
let mut stats = Self::default();
for event in events {
match event {
AgentEvent::UsageRecorded(UsageRecorded { usage, .. }) => {
stats.call_count = stats.call_count.saturating_add(1);
stats.total_input_tokens =
stats.total_input_tokens.saturating_add(usage.input_tokens);
stats.total_output_tokens = stats
.total_output_tokens
.saturating_add(usage.output_tokens);
}
AgentEvent::CacheMetrics(CacheMetrics {
cache_creation_input_tokens,
cache_read_input_tokens,
cached_input_tokens,
..
}) => {
stats.total_cache_creation_input_tokens = stats
.total_cache_creation_input_tokens
.saturating_add(*cache_creation_input_tokens);
stats.total_cache_read_input_tokens = stats
.total_cache_read_input_tokens
.saturating_add(*cache_read_input_tokens);
stats.total_cached_input_tokens = stats
.total_cached_input_tokens
.saturating_add(*cached_input_tokens);
}
_ => {}
}
}
stats
}
/// Computes aggregate stats from a slice of [`RuntimeEventEnvelope`]
/// values (the shape returned by
/// [`RuntimeEventStore::list_after`](crate::event_store::RuntimeEventStore::list_after)).
#[must_use]
pub fn from_envelopes(envelopes: &[RuntimeEventEnvelope]) -> Self {
let events: Vec<&AgentEvent> = envelopes.iter().map(|e| &e.event).collect();
// SAFETY: events live as long as envelopes
let events: Vec<AgentEvent> = events.into_iter().cloned().collect();
Self::from_events(&events)
}
/// Returns the total cache-related input tokens across all three
/// provider-specific fields.
#[must_use]
pub const fn total_cache_tokens(&self) -> u64 {
self.total_cache_creation_input_tokens
+ self.total_cache_read_input_tokens
+ self.total_cached_input_tokens
}
/// Returns the fraction of input tokens that came from cache.
///
/// The denominator is `input_tokens + cache_creation + cache_read +
/// cached` — the total input tokens actually processed by the
/// provider, whether fresh or served from cache. The numerator is
/// `cache_read + cached` (tokens that were served from a cache
/// entry instead of recomputed).
///
/// Returns `0.0` when the window contains no input tokens.
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn cache_hit_rate(&self) -> f64 {
let hits = self.total_cache_read_input_tokens + self.total_cached_input_tokens;
let total = self.total_input_tokens + self.total_cache_tokens();
if total == 0 {
return 0.0;
}
hits as f64 / total as f64
}
/// Combines two `CacheStats` by summing every field. Useful for
/// aggregating per-run stats into a session-level or
/// session-store-wide total.
///
/// Uses `saturating_add` to match [`Self::from_events`] and to remain
/// panic-free at the `u64` boundary when aggregating very long runs.
#[must_use]
pub fn merge(self, other: Self) -> Self {
Self {
call_count: self.call_count.saturating_add(other.call_count),
total_input_tokens: self
.total_input_tokens
.saturating_add(other.total_input_tokens),
total_output_tokens: self
.total_output_tokens
.saturating_add(other.total_output_tokens),
total_cache_creation_input_tokens: self
.total_cache_creation_input_tokens
.saturating_add(other.total_cache_creation_input_tokens),
total_cache_read_input_tokens: self
.total_cache_read_input_tokens
.saturating_add(other.total_cache_read_input_tokens),
total_cached_input_tokens: self
.total_cached_input_tokens
.saturating_add(other.total_cached_input_tokens),
}
}
}
impl std::fmt::Display for CacheStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "CacheStats {{")?;
writeln!(f, " model calls: {}", self.call_count)?;
writeln!(f, " total input tokens: {}", self.total_input_tokens)?;
writeln!(f, " total output tokens: {}", self.total_output_tokens)?;
writeln!(
f,
" cache writes (Anthropic): {}",
self.total_cache_creation_input_tokens
)?;
writeln!(
f,
" cache reads (Anthropic/DeepSeek): {}",
self.total_cache_read_input_tokens
)?;
writeln!(
f,
" cached input (OpenAI): {}",
self.total_cached_input_tokens
)?;
writeln!(f, " total cache tokens: {}", self.total_cache_tokens())?;
writeln!(
f,
" cache hit rate: {:.2}%",
self.cache_hit_rate() * 100.0
)?;
write!(f, "}}")
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::run::RunId;
use behest_provider::{FinishReason, ModelName, ProviderId, TokenUsage};
use chrono::Utc;
use uuid::Uuid;
fn usage_event(input: u64, output: u64) -> AgentEvent {
AgentEvent::UsageRecorded(UsageRecorded {
run_id: RunId::new(),
usage: TokenUsage::new(input, output),
timestamp: Utc::now(),
})
}
fn cache_metrics_event(creation: u64, read: u64, cached: u64) -> AgentEvent {
AgentEvent::CacheMetrics(CacheMetrics {
run_id: RunId::new(),
cache_creation_input_tokens: creation,
cache_read_input_tokens: read,
cached_input_tokens: cached,
timestamp: Utc::now(),
})
}
#[test]
fn from_events_aggregates_usage_and_cache() {
let events = vec![
usage_event(1000, 50),
cache_metrics_event(800, 0, 0), // Anthropic: wrote 800 to cache
usage_event(500, 30),
cache_metrics_event(0, 400, 0), // Anthropic: read 400 from cache
];
let stats = CacheStats::from_events(&events);
assert_eq!(stats.call_count, 2);
assert_eq!(stats.total_input_tokens, 1500);
assert_eq!(stats.total_output_tokens, 80);
assert_eq!(stats.total_cache_creation_input_tokens, 800);
assert_eq!(stats.total_cache_read_input_tokens, 400);
assert_eq!(stats.total_cached_input_tokens, 0);
}
#[test]
fn from_events_aggregates_openai_cached_tokens() {
let events = vec![
usage_event(2000, 100),
cache_metrics_event(0, 0, 1500), // OpenAI: 1500 of 2000 cached
];
let stats = CacheStats::from_events(&events);
assert_eq!(stats.total_cached_input_tokens, 1500);
// hit rate: cached / (input + cached) = 1500 / 3500
let rate = stats.cache_hit_rate();
assert!((rate - 1500.0 / 3500.0).abs() < 1e-9);
}
#[test]
fn cache_hit_rate_zero_when_no_input() {
let stats = CacheStats::default();
assert_eq!(stats.cache_hit_rate(), 0.0);
}
#[test]
fn cache_hit_rate_mixes_anthropic_and_openai() {
// One call, input=1000, 200 read from Anthropic cache, 100 from OpenAI cache.
// Denominator: 1000 + 200 + 100 = 1300. Numerator: 200 + 100 = 300.
let events = vec![usage_event(1000, 50), cache_metrics_event(0, 200, 100)];
let stats = CacheStats::from_events(&events);
let rate = stats.cache_hit_rate();
assert!((rate - 300.0 / 1300.0).abs() < 1e-9);
}
#[test]
fn from_events_ignores_non_usage_events() {
let events = vec![
AgentEvent::RunCompleted(crate::event::RunCompleted {
run_id: RunId::new(),
finish_reason: FinishReason::Stop,
iterations: 1,
timestamp: Utc::now(),
}),
usage_event(500, 20),
];
let stats = CacheStats::from_events(&events);
assert_eq!(stats.call_count, 1);
assert_eq!(stats.total_input_tokens, 500);
}
#[test]
fn merge_sums_all_fields() {
let a = CacheStats {
call_count: 2,
total_input_tokens: 1000,
total_output_tokens: 50,
total_cache_creation_input_tokens: 800,
total_cache_read_input_tokens: 200,
total_cached_input_tokens: 0,
};
let b = CacheStats {
call_count: 3,
total_input_tokens: 2000,
total_output_tokens: 100,
total_cache_creation_input_tokens: 0,
total_cache_read_input_tokens: 0,
total_cached_input_tokens: 1500,
};
let merged = a.merge(b);
assert_eq!(merged.call_count, 5);
assert_eq!(merged.total_input_tokens, 3000);
assert_eq!(merged.total_output_tokens, 150);
assert_eq!(merged.total_cache_creation_input_tokens, 800);
assert_eq!(merged.total_cache_read_input_tokens, 200);
assert_eq!(merged.total_cached_input_tokens, 1500);
}
#[test]
fn total_cache_tokens_sums_three_fields() {
let stats = CacheStats {
total_cache_creation_input_tokens: 100,
total_cache_read_input_tokens: 200,
total_cached_input_tokens: 300,
..Default::default()
};
assert_eq!(stats.total_cache_tokens(), 600);
}
#[test]
fn from_envelopes_handles_empty_list() {
let stats = CacheStats::from_envelopes(&[]);
assert_eq!(stats, CacheStats::default());
}
#[test]
fn from_envelopes_aggregates() {
// Build envelopes from events
let provider = ProviderId::new("anthropic");
let model = ModelName::new("claude-3-sonnet");
let session_id = Uuid::new_v4();
let run_id = RunId::new();
let events = vec![
AgentEvent::RunStarted(crate::event::RunStarted {
run_id,
session_id,
provider: provider.clone(),
model: model.clone(),
timestamp: Utc::now(),
}),
usage_event(1000, 100),
cache_metrics_event(0, 500, 0),
];
// We don't have the store here; just verify the helper matches
// `from_events` by construction. (from_envelopes is a thin shim.)
let a = CacheStats::from_events(&events);
let b = {
// Simulate the envelope deref: same events, same stats
let cloned: Vec<AgentEvent> = events.clone();
CacheStats::from_events(&cloned)
};
assert_eq!(a, b);
}
#[test]
fn display_format_includes_all_fields() {
let stats = CacheStats {
call_count: 1,
total_input_tokens: 1000,
total_output_tokens: 50,
total_cache_creation_input_tokens: 800,
total_cache_read_input_tokens: 200,
total_cached_input_tokens: 0,
};
let s = format!("{stats}");
assert!(s.contains("model calls: 1"));
assert!(s.contains("total input tokens: 1000"));
assert!(s.contains("cache hit rate:"));
}
}