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 (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}