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 (GUI and CLI chat). The two populations are reported side by
13//! side, never merged: an agent turn's many small tool-driven 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)]
32#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
33pub struct CacheUsage {
34 /// Completed requests whose upstream reported a cached-token count.
35 /// The denominator for [`Self::cached_tokens`] and [`Self::prompt_tokens`].
36 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
37 pub reporting_requests: u64,
38 /// Completed requests whose upstream omitted the field. Excluded from
39 /// every other figure here — counted so a consumer can tell "no reuse"
40 /// apart from "no data".
41 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
42 pub unreported_requests: u64,
43 /// Total prompt tokens across [`Self::reporting_requests`].
44 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
45 pub prompt_tokens: u64,
46 /// Total prompt tokens served from the KV cache across those requests.
47 /// Always `<= prompt_tokens`.
48 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
49 pub cached_tokens: u64,
50 /// Prompt tokens in the most recent reporting request.
51 pub last_prompt_tokens: Option<u32>,
52 /// Tokens reused from cache in the most recent reporting request.
53 pub last_cached_tokens: Option<u32>,
54}
55
56/// Running totals of prompt-cache reuse.
57///
58/// Lock-free: every field is an independent atomic, updated with `Relaxed`
59/// ordering. The counters are a display aid, not a consistency boundary — a
60/// dashboard tick that reads mid-update sees one request's figures land
61/// slightly out of step, which is invisible at a one-second refresh and not
62/// worth a mutex on the request path.
63#[derive(Debug, Default)]
64pub struct CacheMetricsStore {
65 reporting_requests: AtomicU64,
66 unreported_requests: AtomicU64,
67 prompt_tokens: AtomicU64,
68 cached_tokens: AtomicU64,
69 /// Last reporting request's figures, packed as `(prompt << 32) | cached`
70 /// so the pair is written in one store and can never be read half-updated
71 /// (e.g. a new prompt count beside the previous cached count).
72 ///
73 /// Validity is tracked by [`Self::has_last`] rather than by an in-band
74 /// sentinel: `u64::MAX` looks unreachable but is exactly what a
75 /// `(u32::MAX, u32::MAX)` request packs to, which would then read back as
76 /// "nothing recorded".
77 last: AtomicU64,
78 /// Whether [`Self::last`] holds a real measurement. Stored with `Release`
79 /// after `last`, and loaded with `Acquire` before it, so observing `true`
80 /// guarantees the packed pair beside it is fully written.
81 has_last: AtomicBool,
82}
83
84impl CacheMetricsStore {
85 /// Create an empty store.
86 #[must_use]
87 pub fn new() -> Self {
88 Self::default()
89 }
90
91 /// Record a completed request's usage figures.
92 ///
93 /// `cached_tokens` is `None` when the upstream didn't report the field;
94 /// such a request bumps only [`CacheUsage::unreported_requests`] and
95 /// leaves the token totals untouched.
96 pub fn record(&self, prompt_tokens: u32, cached_tokens: Option<u32>) {
97 let Some(cached) = cached_tokens else {
98 self.unreported_requests.fetch_add(1, Ordering::Relaxed);
99 return;
100 };
101
102 // Guard the invariant rather than trusting the upstream: a cached
103 // count above the prompt count would be nonsense, and letting it
104 // through would make `cached_tokens > prompt_tokens` in the aggregate,
105 // which reads as a cache that returned more than was asked for.
106 let cached = cached.min(prompt_tokens);
107
108 self.reporting_requests.fetch_add(1, Ordering::Relaxed);
109 self.prompt_tokens
110 .fetch_add(u64::from(prompt_tokens), Ordering::Relaxed);
111 self.cached_tokens
112 .fetch_add(u64::from(cached), Ordering::Relaxed);
113 self.last.store(
114 (u64::from(prompt_tokens) << 32) | u64::from(cached),
115 Ordering::Relaxed,
116 );
117 // Released after `last` so a reader that sees `true` is guaranteed to
118 // see the pair it refers to.
119 self.has_last.store(true, Ordering::Release);
120 }
121
122 /// Snapshot the current totals.
123 #[must_use]
124 pub fn snapshot(&self) -> CacheUsage {
125 let (last_prompt_tokens, last_cached_tokens) = if self.has_last.load(Ordering::Acquire) {
126 let last = self.last.load(Ordering::Relaxed);
127 #[allow(clippy::cast_possible_truncation)]
128 (Some((last >> 32) as u32), Some(last as u32))
129 } else {
130 (None, None)
131 };
132
133 CacheUsage {
134 reporting_requests: self.reporting_requests.load(Ordering::Relaxed),
135 unreported_requests: self.unreported_requests.load(Ordering::Relaxed),
136 prompt_tokens: self.prompt_tokens.load(Ordering::Relaxed),
137 cached_tokens: self.cached_tokens.load(Ordering::Relaxed),
138 last_prompt_tokens,
139 last_cached_tokens,
140 }
141 }
142}
143
144/// The in-process store is the dashboard's [`UsageSink`]: the adapter records
145/// straight into the atomics the dashboard snapshots.
146///
147/// `completion_tokens` is deliberately ignored — this store exists to answer
148/// "how much of the prompt was served from cache", and the dashboard has no
149/// generation-side figure to render.
150///
151/// [`UsageSink`]: crate::ports::UsageSink
152impl crate::ports::UsageSink for CacheMetricsStore {
153 fn record(&self, prompt_tokens: u32, _completion_tokens: u32, cached_tokens: Option<u32>) {
154 Self::record(self, prompt_tokens, cached_tokens);
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn empty_store_reports_nothing_recorded() {
164 let got = CacheMetricsStore::new().snapshot();
165 assert_eq!(got, CacheUsage::default());
166 assert_eq!(got.last_prompt_tokens, None);
167 assert_eq!(got.last_cached_tokens, None);
168 }
169
170 #[test]
171 fn records_accumulate_across_requests() {
172 let store = CacheMetricsStore::new();
173 store.record(10_000, Some(9_000));
174 store.record(12_000, Some(11_500));
175
176 let got = store.snapshot();
177 assert_eq!(got.reporting_requests, 2);
178 assert_eq!(got.prompt_tokens, 22_000);
179 assert_eq!(got.cached_tokens, 20_500);
180 assert_eq!(got.last_prompt_tokens, Some(12_000));
181 assert_eq!(got.last_cached_tokens, Some(11_500));
182 }
183
184 /// Zero reuse is a real measurement — a full re-prefill — and must count
185 /// toward the totals rather than being treated as missing data.
186 #[test]
187 fn zero_reuse_is_recorded_as_a_reporting_request() {
188 let store = CacheMetricsStore::new();
189 store.record(5_000, Some(0));
190
191 let got = store.snapshot();
192 assert_eq!(got.reporting_requests, 1);
193 assert_eq!(got.prompt_tokens, 5_000);
194 assert_eq!(got.cached_tokens, 0);
195 assert_eq!(got.last_cached_tokens, Some(0));
196 }
197
198 /// An upstream that never reports must not look like a cache that never
199 /// hits: its requests stay out of the token totals entirely.
200 #[test]
201 fn unreported_requests_are_counted_separately() {
202 let store = CacheMetricsStore::new();
203 store.record(5_000, None);
204 store.record(6_000, None);
205
206 let got = store.snapshot();
207 assert_eq!(got.unreported_requests, 2);
208 assert_eq!(got.reporting_requests, 0);
209 assert_eq!(got.prompt_tokens, 0, "must not inflate the denominator");
210 assert_eq!(got.cached_tokens, 0);
211 assert_eq!(got.last_prompt_tokens, None, "no reporting request yet");
212 }
213
214 #[test]
215 fn mixed_reporting_and_unreported_requests_stay_separated() {
216 let store = CacheMetricsStore::new();
217 store.record(1_000, Some(900));
218 store.record(2_000, None);
219
220 let got = store.snapshot();
221 assert_eq!(got.reporting_requests, 1);
222 assert_eq!(got.unreported_requests, 1);
223 assert_eq!(got.prompt_tokens, 1_000);
224 assert_eq!(got.last_prompt_tokens, Some(1_000));
225 }
226
227 /// A nonsensical upstream figure is clamped rather than propagated —
228 /// otherwise the aggregate could report more tokens reused than sent.
229 #[test]
230 fn cached_count_is_clamped_to_the_prompt_count() {
231 let store = CacheMetricsStore::new();
232 store.record(100, Some(500));
233
234 let got = store.snapshot();
235 assert_eq!(got.cached_tokens, 100);
236 assert_eq!(got.last_cached_tokens, Some(100));
237 assert!(got.cached_tokens <= got.prompt_tokens);
238 }
239
240 /// The packed `last` pair must round-trip at the extremes, since a
241 /// shift-based encoding is exactly where an off-by-32 would hide.
242 #[test]
243 fn last_pair_round_trips_at_u32_bounds() {
244 let store = CacheMetricsStore::new();
245 store.record(u32::MAX, Some(u32::MAX));
246
247 let got = store.snapshot();
248 assert_eq!(got.last_prompt_tokens, Some(u32::MAX));
249 assert_eq!(got.last_cached_tokens, Some(u32::MAX));
250 }
251
252 #[test]
253 fn last_reflects_only_the_most_recent_reporting_request() {
254 let store = CacheMetricsStore::new();
255 store.record(1_000, Some(900));
256 store.record(2_000, None); // must not clobber `last`
257
258 let got = store.snapshot();
259 assert_eq!(got.last_prompt_tokens, Some(1_000));
260 assert_eq!(got.last_cached_tokens, Some(900));
261 }
262}