Skip to main content

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}