1use yapi_ai::registry::ModelRegistry;
7use yapi_types::message::{AssistantMessage, Message};
8use yapi_types::session::FileEntry;
9
10pub const CACHE_TTL_MS: u64 = 5 * 60 * 1000;
13
14const NOISE_FLOOR_TOKENS: u64 = 1024;
17
18#[derive(Clone, Copy, Debug, PartialEq)]
20pub struct CacheMiss {
21 pub missed_tokens: u64,
24 pub missed_cost: f64,
26 pub idle_ms: u64,
29 pub model_changed: bool,
31}
32
33#[derive(Clone, Copy, Debug, Default, PartialEq)]
35pub struct CacheWaste {
36 pub missed_tokens: u64,
38 pub missed_cost: f64,
40 pub miss_count: usize,
42}
43
44struct Previous {
46 prompt_tokens: u64,
47 model: String,
48 timestamp: Option<u64>,
51 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
62fn 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 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
109fn 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 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
157pub 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
172pub 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
188pub 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 assistant("b", 2_000, 1_000, 20_100, 0),
239 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, ®istry);
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, ®istry).miss_count, 1);
253
254 assert_eq!(
256 detect_cache_miss(&entries, message(&entries[2]), ®istry),
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, ®istry),
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, ®istry);
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), ®istry)
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}