gglib_core/cache_metrics.rs
1//! Prompt-cache reuse telemetry.
2//!
3//! Records how many prompt tokens each completed request reused from
4//! llama-server's KV cache instead of re-processing, sourced from
5//! `usage.prompt_tokens_details.cached_tokens` (see
6//! [`crate::LlmStreamEvent::Usage`]).
7//!
8//! The store is location-neutral — a lock-free counter with a
9//! `serde::Serialize` snapshot and no dependency on any particular request
10//! path — so more than one caller can keep its own instance. The proxy keeps
11//! one for the traffic it forwards; a separate instance covers the in-process
12//! agent path (council and GUI chat). The two populations are reported side by
13//! side, never merged: a council run's many small sub-agent calls have a reuse
14//! profile nothing like a user's conversation, so averaging them would make
15//! either figure harder to read.
16//!
17//! Deliberately raw counters. Everything exposed is something the upstream
18//! actually measured; nothing is derived, extrapolated, or turned into a
19//! "time saved" figure. Reuse counts are exact, but what that reuse *saved*
20//! depends on a counterfactual prefill that never ran — presenting an estimate
21//! of it as a dashboard number would invite trust it can't earn. Consumers that
22//! want a ratio can divide two figures that are both real.
23//!
24//! Requests whose upstream didn't report the field are counted separately
25//! (`unreported_requests`) rather than folded in as zero-reuse, so a server
26//! that never reports can't masquerade as a cache that never hits.
27
28use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
29
30/// A point-in-time view of prompt-cache reuse since the proxy started.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
32pub struct CacheUsage {
33 /// Completed requests whose upstream reported a cached-token count.
34 /// The denominator for [`Self::cached_tokens`] and [`Self::prompt_tokens`].
35 pub reporting_requests: u64,
36 /// Completed requests whose upstream omitted the field. Excluded from
37 /// every other figure here — counted so a consumer can tell "no reuse"
38 /// apart from "no data".
39 pub unreported_requests: u64,
40 /// Total prompt tokens across [`Self::reporting_requests`].
41 pub prompt_tokens: u64,
42 /// Total prompt tokens served from the KV cache across those requests.
43 /// Always `<= prompt_tokens`.
44 pub cached_tokens: u64,
45 /// Prompt tokens in the most recent reporting request.
46 pub last_prompt_tokens: Option<u32>,
47 /// Tokens reused from cache in the most recent reporting request.
48 pub last_cached_tokens: Option<u32>,
49}
50
51/// Running totals of prompt-cache reuse.
52///
53/// Lock-free: every field is an independent atomic, updated with `Relaxed`
54/// ordering. The counters are a display aid, not a consistency boundary — a
55/// dashboard tick that reads mid-update sees one request's figures land
56/// slightly out of step, which is invisible at a one-second refresh and not
57/// worth a mutex on the request path.
58#[derive(Debug, Default)]
59pub struct CacheMetricsStore {
60 reporting_requests: AtomicU64,
61 unreported_requests: AtomicU64,
62 prompt_tokens: AtomicU64,
63 cached_tokens: AtomicU64,
64 /// Last reporting request's figures, packed as `(prompt << 32) | cached`
65 /// so the pair is written in one store and can never be read half-updated
66 /// (e.g. a new prompt count beside the previous cached count).
67 ///
68 /// Validity is tracked by [`Self::has_last`] rather than by an in-band
69 /// sentinel: `u64::MAX` looks unreachable but is exactly what a
70 /// `(u32::MAX, u32::MAX)` request packs to, which would then read back as
71 /// "nothing recorded".
72 last: AtomicU64,
73 /// Whether [`Self::last`] holds a real measurement. Stored with `Release`
74 /// after `last`, and loaded with `Acquire` before it, so observing `true`
75 /// guarantees the packed pair beside it is fully written.
76 has_last: AtomicBool,
77}
78
79impl CacheMetricsStore {
80 /// Create an empty store.
81 #[must_use]
82 pub fn new() -> Self {
83 Self::default()
84 }
85
86 /// Record a completed request's usage figures.
87 ///
88 /// `cached_tokens` is `None` when the upstream didn't report the field;
89 /// such a request bumps only [`CacheUsage::unreported_requests`] and
90 /// leaves the token totals untouched.
91 pub fn record(&self, prompt_tokens: u32, cached_tokens: Option<u32>) {
92 let Some(cached) = cached_tokens else {
93 self.unreported_requests.fetch_add(1, Ordering::Relaxed);
94 return;
95 };
96
97 // Guard the invariant rather than trusting the upstream: a cached
98 // count above the prompt count would be nonsense, and letting it
99 // through would make `cached_tokens > prompt_tokens` in the aggregate,
100 // which reads as a cache that returned more than was asked for.
101 let cached = cached.min(prompt_tokens);
102
103 self.reporting_requests.fetch_add(1, Ordering::Relaxed);
104 self.prompt_tokens
105 .fetch_add(u64::from(prompt_tokens), Ordering::Relaxed);
106 self.cached_tokens
107 .fetch_add(u64::from(cached), Ordering::Relaxed);
108 self.last.store(
109 (u64::from(prompt_tokens) << 32) | u64::from(cached),
110 Ordering::Relaxed,
111 );
112 // Released after `last` so a reader that sees `true` is guaranteed to
113 // see the pair it refers to.
114 self.has_last.store(true, Ordering::Release);
115 }
116
117 /// Snapshot the current totals.
118 #[must_use]
119 pub fn snapshot(&self) -> CacheUsage {
120 let (last_prompt_tokens, last_cached_tokens) = if self.has_last.load(Ordering::Acquire) {
121 let last = self.last.load(Ordering::Relaxed);
122 #[allow(clippy::cast_possible_truncation)]
123 (Some((last >> 32) as u32), Some(last as u32))
124 } else {
125 (None, None)
126 };
127
128 CacheUsage {
129 reporting_requests: self.reporting_requests.load(Ordering::Relaxed),
130 unreported_requests: self.unreported_requests.load(Ordering::Relaxed),
131 prompt_tokens: self.prompt_tokens.load(Ordering::Relaxed),
132 cached_tokens: self.cached_tokens.load(Ordering::Relaxed),
133 last_prompt_tokens,
134 last_cached_tokens,
135 }
136 }
137}
138
139/// The in-process store is the default [`CacheMetricsSink`]: the adapter
140/// records straight into the atomics the dashboard snapshots.
141///
142/// [`CacheMetricsSink`]: crate::ports::CacheMetricsSink
143impl crate::ports::CacheMetricsSink for CacheMetricsStore {
144 fn record(&self, prompt_tokens: u32, cached_tokens: Option<u32>) {
145 Self::record(self, prompt_tokens, cached_tokens);
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn empty_store_reports_nothing_recorded() {
155 let got = CacheMetricsStore::new().snapshot();
156 assert_eq!(got, CacheUsage::default());
157 assert_eq!(got.last_prompt_tokens, None);
158 assert_eq!(got.last_cached_tokens, None);
159 }
160
161 #[test]
162 fn records_accumulate_across_requests() {
163 let store = CacheMetricsStore::new();
164 store.record(10_000, Some(9_000));
165 store.record(12_000, Some(11_500));
166
167 let got = store.snapshot();
168 assert_eq!(got.reporting_requests, 2);
169 assert_eq!(got.prompt_tokens, 22_000);
170 assert_eq!(got.cached_tokens, 20_500);
171 assert_eq!(got.last_prompt_tokens, Some(12_000));
172 assert_eq!(got.last_cached_tokens, Some(11_500));
173 }
174
175 /// Zero reuse is a real measurement — a full re-prefill — and must count
176 /// toward the totals rather than being treated as missing data.
177 #[test]
178 fn zero_reuse_is_recorded_as_a_reporting_request() {
179 let store = CacheMetricsStore::new();
180 store.record(5_000, Some(0));
181
182 let got = store.snapshot();
183 assert_eq!(got.reporting_requests, 1);
184 assert_eq!(got.prompt_tokens, 5_000);
185 assert_eq!(got.cached_tokens, 0);
186 assert_eq!(got.last_cached_tokens, Some(0));
187 }
188
189 /// An upstream that never reports must not look like a cache that never
190 /// hits: its requests stay out of the token totals entirely.
191 #[test]
192 fn unreported_requests_are_counted_separately() {
193 let store = CacheMetricsStore::new();
194 store.record(5_000, None);
195 store.record(6_000, None);
196
197 let got = store.snapshot();
198 assert_eq!(got.unreported_requests, 2);
199 assert_eq!(got.reporting_requests, 0);
200 assert_eq!(got.prompt_tokens, 0, "must not inflate the denominator");
201 assert_eq!(got.cached_tokens, 0);
202 assert_eq!(got.last_prompt_tokens, None, "no reporting request yet");
203 }
204
205 #[test]
206 fn mixed_reporting_and_unreported_requests_stay_separated() {
207 let store = CacheMetricsStore::new();
208 store.record(1_000, Some(900));
209 store.record(2_000, None);
210
211 let got = store.snapshot();
212 assert_eq!(got.reporting_requests, 1);
213 assert_eq!(got.unreported_requests, 1);
214 assert_eq!(got.prompt_tokens, 1_000);
215 assert_eq!(got.last_prompt_tokens, Some(1_000));
216 }
217
218 /// A nonsensical upstream figure is clamped rather than propagated —
219 /// otherwise the aggregate could report more tokens reused than sent.
220 #[test]
221 fn cached_count_is_clamped_to_the_prompt_count() {
222 let store = CacheMetricsStore::new();
223 store.record(100, Some(500));
224
225 let got = store.snapshot();
226 assert_eq!(got.cached_tokens, 100);
227 assert_eq!(got.last_cached_tokens, Some(100));
228 assert!(got.cached_tokens <= got.prompt_tokens);
229 }
230
231 /// The packed `last` pair must round-trip at the extremes, since a
232 /// shift-based encoding is exactly where an off-by-32 would hide.
233 #[test]
234 fn last_pair_round_trips_at_u32_bounds() {
235 let store = CacheMetricsStore::new();
236 store.record(u32::MAX, Some(u32::MAX));
237
238 let got = store.snapshot();
239 assert_eq!(got.last_prompt_tokens, Some(u32::MAX));
240 assert_eq!(got.last_cached_tokens, Some(u32::MAX));
241 }
242
243 #[test]
244 fn last_reflects_only_the_most_recent_reporting_request() {
245 let store = CacheMetricsStore::new();
246 store.record(1_000, Some(900));
247 store.record(2_000, None); // must not clobber `last`
248
249 let got = store.snapshot();
250 assert_eq!(got.last_prompt_tokens, Some(1_000));
251 assert_eq!(got.last_cached_tokens, Some(900));
252 }
253}