Skip to main content

yapi_core/
cache_stats.rs

1//! Prompt cache misses: prompt tokens that were in the previous request's
2//! prompt but were billed again instead of read from the cache.
3//!
4//! Port of `packages/coding-agent/src/core/cache-stats.ts` in pi `v1.0.0`.
5
6use yapi_ai::registry::ModelRegistry;
7use yapi_types::message::{AssistantMessage, Message};
8use yapi_types::session::FileEntry;
9
10/// Idle gaps longer than this are worth naming as the likely cause of a
11/// miss: Anthropic's default cache lifetime.
12pub const CACHE_TTL_MS: u64 = 5 * 60 * 1000;
13
14/// Per-request misses at or below this are cache breakpoint granularity
15/// noise.
16const NOISE_FLOOR_TOKENS: u64 = 1024;
17
18/// A counted cache miss on one assistant message.
19#[derive(Clone, Copy, Debug, PartialEq)]
20pub struct CacheMiss {
21    /// Prompt tokens of the previous request that were not read from the
22    /// cache.
23    pub missed_tokens: u64,
24    /// Dollars paid beyond a full cache hit, or 0 when prices are unknown.
25    pub missed_cost: f64,
26    /// Milliseconds since the previous request, which last refreshed the
27    /// cache.
28    pub idle_ms: u64,
29    /// The model changed since the previous request.
30    pub model_changed: bool,
31}
32
33/// Counted misses across a session.
34#[derive(Clone, Copy, Debug, Default, PartialEq)]
35pub struct CacheWaste {
36    /// Tokens billed again.
37    pub missed_tokens: u64,
38    /// Dollars paid beyond full cache hits.
39    pub missed_cost: f64,
40    /// Misses above the noise floor.
41    pub miss_count: usize,
42}
43
44/// The last request a scan saw. Everything in its prompt should be cached.
45struct Previous {
46    prompt_tokens: u64,
47    model: String,
48    /// `None` for a time that does not parse, which counts no idle time,
49    /// as pi's `NaN` does.
50    timestamp: Option<u64>,
51    /// Some request of this scan segment reported cache activity, which
52    /// tells a total miss on a provider that reports only cache reads from a
53    /// provider that never reports caching.
54    reported_cache: bool,
55}
56
57fn prompt_tokens(message: &AssistantMessage) -> u64 {
58    let usage = &message.usage;
59    usage.input + usage.cache_read + usage.cache_write
60}
61
62/// The miss `message` paid for after `previous`, unless there is none to
63/// count: no previous request, no cache activity ever reported, or a miss
64/// within the noise floor.
65fn detect(
66    previous: Option<&Previous>,
67    message: &AssistantMessage,
68    registry: &ModelRegistry,
69) -> Option<CacheMiss> {
70    let usage = &message.usage;
71    let prompt = prompt_tokens(message);
72    let previous = previous?;
73    if prompt == 0 || (usage.cache_read + usage.cache_write == 0 && !previous.reported_cache) {
74        return None;
75    }
76    let missed_tokens = previous
77        .prompt_tokens
78        .min(prompt)
79        .saturating_sub(usage.cache_read);
80    if missed_tokens <= NOISE_FLOOR_TOKENS {
81        return None;
82    }
83    // Missed tokens are billed as input or cache write tokens, so this
84    // message's own costs give the rate paid for them.
85    let paid_tokens = usage.input + usage.cache_write;
86    let paid_per_token = if paid_tokens > 0 {
87        (usage.cost.input + usage.cost.cache_write) / paid_tokens as f64
88    } else {
89        0.0
90    };
91    let read_per_token = if usage.cache_read > 0 {
92        usage.cost.cache_read / usage.cache_read as f64
93    } else {
94        registry
95            .find(&message.provider, &message.model)
96            .map_or(0.0, |model| model.cost.cache_read)
97            / 1_000_000.0
98    };
99    Some(CacheMiss {
100        missed_tokens,
101        missed_cost: missed_tokens as f64 * (paid_per_token - read_per_token).max(0.0),
102        idle_ms: previous
103            .timestamp
104            .map_or(0, |at| message.timestamp.saturating_sub(at)),
105        model_changed: format!("{}/{}", message.provider, message.model) != previous.model,
106    })
107}
108
109/// Scans `entries` in order, calling `found` for each counted miss with the
110/// entry that paid for it. Returns the last request seen.
111fn scan<'a>(
112    entries: impl IntoIterator<Item = &'a FileEntry>,
113    registry: &ModelRegistry,
114    mut found: impl FnMut(&'a FileEntry, CacheMiss),
115) -> Option<Previous> {
116    let mut previous: Option<Previous> = None;
117    for entry in entries {
118        match entry {
119            // The context changed on purpose: the next prompt is new content,
120            // not content billed again. A model switch is no such reset.
121            FileEntry::Compaction(_) | FileEntry::BranchSummary(_) => previous = None,
122            FileEntry::Usage(usage) if usage.kind == "cache_warm" => {
123                let tokens = usage.usage.input + usage.usage.cache_read + usage.usage.cache_write;
124                if tokens > 0 {
125                    previous = Some(Previous {
126                        prompt_tokens: tokens,
127                        model: format!("{}/{}", usage.provider, usage.model),
128                        timestamp: crate::time::parse_iso(&usage.meta.timestamp),
129                        reported_cache: true,
130                    });
131                }
132            }
133            FileEntry::Message(message_entry) => {
134                let Message::Assistant(message) = &message_entry.message else {
135                    continue;
136                };
137                if let Some(miss) = detect(previous.as_ref(), message, registry) {
138                    found(entry, miss);
139                }
140                let usage = &message.usage;
141                if prompt_tokens(message) > 0 {
142                    previous = Some(Previous {
143                        prompt_tokens: prompt_tokens(message),
144                        model: format!("{}/{}", message.provider, message.model),
145                        timestamp: Some(message.timestamp),
146                        reported_cache: previous.as_ref().is_some_and(|p| p.reported_cache)
147                            || usage.cache_read + usage.cache_write > 0,
148                    });
149                }
150            }
151            _ => {}
152        }
153    }
154    previous
155}
156
157/// pi's `computeCacheWaste`: the misses over every entry of a session, in
158/// file order.
159pub fn compute_cache_waste<'a>(
160    entries: impl IntoIterator<Item = &'a FileEntry>,
161    registry: &ModelRegistry,
162) -> CacheWaste {
163    let mut waste = CacheWaste::default();
164    scan(entries, registry, |_, miss| {
165        waste.missed_tokens += miss.missed_tokens;
166        waste.missed_cost += miss.missed_cost;
167        waste.miss_count += 1;
168    });
169    waste
170}
171
172/// pi's `collectCacheMisses`: each counted miss with the id of the entry
173/// whose message paid for it, for notices when the transcript is drawn
174/// again.
175pub fn collect_cache_misses<'a>(
176    entries: impl IntoIterator<Item = &'a FileEntry>,
177    registry: &ModelRegistry,
178) -> Vec<(String, CacheMiss)> {
179    let mut misses = Vec::new();
180    scan(entries, registry, |entry, miss| {
181        if let Some(meta) = entry.meta() {
182            misses.push((meta.id.clone(), miss));
183        }
184    });
185    misses
186}
187
188/// pi's `detectCacheMiss`: the miss a newly finished assistant `message`
189/// paid for. `entries` may already hold the message, where the scan stops.
190pub fn detect_cache_miss<'a>(
191    entries: impl IntoIterator<Item = &'a FileEntry>,
192    message: &AssistantMessage,
193    registry: &ModelRegistry,
194) -> Option<CacheMiss> {
195    let before = entries.into_iter().take_while(|entry| {
196        !matches!(entry, FileEntry::Message(entry)
197            if matches!(&entry.message, Message::Assistant(other) if **other == *message))
198    });
199    let previous = scan(before, registry, |_, _| {});
200    detect(previous.as_ref(), message, registry)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use serde_json::json;
207
208    fn assistant(id: &str, timestamp: u64, input: u64, read: u64, write: u64) -> FileEntry {
209        serde_json::from_value(json!({
210            "type": "message", "id": id, "parentId": null, "timestamp": "2026-10-13T10:00:00.000Z",
211            "message": {
212                "role": "assistant", "content": [], "api": "faux", "provider": "p", "model": "m",
213                "usage": {
214                    "input": input, "output": 1, "cacheRead": read, "cacheWrite": write,
215                    "totalTokens": input + read + write + 1,
216                    "cost": {
217                        "input": input as f64 * 3e-6, "output": 0.0, "cacheRead": read as f64 * 3e-7,
218                        "cacheWrite": write as f64 * 3.75e-6, "total": 0.0,
219                    },
220                },
221                "stopReason": "stop", "timestamp": timestamp,
222            },
223        }))
224        .expect("an assistant entry")
225    }
226
227    #[test]
228    fn counts_rebilled_prompt_tokens() {
229        let registry = ModelRegistry::builtin();
230        let compaction: FileEntry = serde_json::from_value(json!({
231            "type": "compaction", "id": "c", "parentId": null, "timestamp": "2026-10-13T10:00:00.000Z",
232            "summary": "s", "firstKeptEntryId": "c", "tokensBefore": 1,
233        }))
234        .expect("a compaction entry");
235        let entries = [
236            assistant("a", 1_000, 100, 0, 20_000),
237            // A hit within the noise floor.
238            assistant("b", 2_000, 1_000, 20_100, 0),
239            // A total miss after an idle gap: 21,100 tokens billed again.
240            assistant("c", 2_000 + CACHE_TTL_MS, 21_500, 0, 0),
241            compaction,
242            assistant("d", 400_000, 30_000, 0, 0),
243        ];
244        let misses = collect_cache_misses(&entries, &registry);
245        assert_eq!(misses.len(), 1);
246        let (id, miss) = &misses[0];
247        assert_eq!(id, "c");
248        assert_eq!(miss.missed_tokens, 21_100);
249        assert!((miss.missed_cost - 21_100.0 * 3e-6).abs() < 1e-9);
250        assert_eq!(miss.idle_ms, CACHE_TTL_MS);
251        assert!(!miss.model_changed);
252        assert_eq!(compute_cache_waste(&entries, &registry).miss_count, 1);
253
254        // A message already recorded is compared with the request before it.
255        assert_eq!(
256            detect_cache_miss(&entries, message(&entries[2]), &registry),
257            Some(*miss)
258        );
259    }
260
261    fn message(entry: &FileEntry) -> &AssistantMessage {
262        match entry {
263            FileEntry::Message(entry) => match &entry.message {
264                Message::Assistant(message) => message,
265                _ => panic!("an assistant message"),
266            },
267            _ => panic!("a message entry"),
268        }
269    }
270
271    #[test]
272    fn uncached_providers_never_miss() {
273        let registry = ModelRegistry::builtin();
274        let entries = [
275            assistant("a", 0, 100_000, 0, 0),
276            assistant("b", 1_000, 110_000, 0, 0),
277        ];
278        assert_eq!(
279            compute_cache_waste(&entries, &registry),
280            CacheWaste::default()
281        );
282    }
283
284    #[test]
285    fn model_switches_miss() {
286        let registry = ModelRegistry::builtin();
287        let mut switched = assistant("b", 1_000, 0, 0, 100_000);
288        if let FileEntry::Message(entry) = &mut switched
289            && let Message::Assistant(message) = &mut entry.message
290        {
291            message.model = "other".into();
292        }
293        let entries = [assistant("a", 0, 0, 0, 100_000), switched];
294        let misses = collect_cache_misses(&entries, &registry);
295        assert_eq!(misses.len(), 1);
296        assert_eq!(misses[0].1.missed_tokens, 100_000);
297        assert!(misses[0].1.model_changed);
298    }
299
300    #[test]
301    fn only_warming_refreshes_count() {
302        let registry = ModelRegistry::builtin();
303        let usage = |kind: &str| -> FileEntry {
304            serde_json::from_value(json!({
305                "type": "usage", "id": kind, "parentId": null,
306                "timestamp": "1970-01-01T00:08:20.000Z",
307                "kind": kind, "provider": "p", "model": "m",
308                "usage": {"input": 0, "output": 0, "cacheRead": 100_000, "cacheWrite": 0,
309                    "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0}},
310            }))
311            .expect("a usage entry")
312        };
313        let first = assistant("a", 0, 0, 0, 100_000);
314        let missed = assistant("b", 600_000, 0, 0, 110_000);
315        let after = |kind| {
316            detect_cache_miss(&[first.clone(), usage(kind)], message(&missed), &registry)
317                .map(|miss| miss.idle_ms)
318        };
319        assert_eq!(after("cache_warm"), Some(100_000));
320        assert_eq!(after("custom_operation"), Some(600_000));
321    }
322}