Skip to main content

runtime/prefix/
warmth.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! One session's keep-warm account: the last real request each prefix
7//! carried, and the renewal sent for it when `kernel::keep_warm` says it
8//! is due (`crates/runtime/spec/Prefix.lean` ยง8-4-2).
9//!
10//! **Off keeps nothing.** Under [`KeepWarm::Off`] a request is not even
11//! cloned, so the default setting costs no memory per session and has no
12//! renewal it could send.
13
14use std::collections::BTreeMap;
15
16use kernel::keep_warm::{CacheUse, KeepWarm, renewal_due};
17use kernel::{AxError, B3Hash, Ceiling, Increments, Model, ModelRequest, ModelReturn, TimeMs};
18
19/// Keep-warm bookkeeping for the prefixes one session has sent.
20#[derive(Debug, Clone)]
21pub struct Warmth {
22    setting: KeepWarm,
23    lead_ms: u64,
24    kept: BTreeMap<[B3Hash; 4], Kept>,
25}
26
27/// A prefix's last real request, and its cache as last seen.
28#[derive(Debug, Clone)]
29struct Kept {
30    request: ModelRequest<'static>,
31    cache: CacheUse,
32}
33
34impl Warmth {
35    /// `lead_ms` is the round trip to the provider as the caller measured
36    /// it: each renewal leaves that long before the cache expires.
37    pub fn new(setting: KeepWarm, lead_ms: u64) -> Warmth {
38        Warmth {
39            setting,
40            lead_ms,
41            kept: BTreeMap::new(),
42        }
43    }
44
45    /// A real request carrying `request.segments` was sent at `at_ms`.
46    pub fn sent(&mut self, request: &ModelRequest, at_ms: u64) {
47        match self.setting {
48            KeepWarm::Off => return,
49            KeepWarm::FiveMinute => {}
50        }
51        self.kept.insert(
52            request.segments,
53            Kept {
54                request: request.clone().into_owned(),
55                cache: CacheUse::sent(at_ms),
56            },
57        );
58    }
59
60    /// The instant the earliest renewal is sent, or `None` when no prefix
61    /// is to be renewed and a timer need not wake.
62    pub fn next_due(&self) -> Option<u64> {
63        self.kept
64            .values()
65            .filter_map(|kept| renewal_due(self.setting, kept.cache, self.lead_ms))
66            .min()
67    }
68
69    /// Sends, through `model`, every renewal due by `now_ms` and returns
70    /// each one's answer, whose usage the caller records like any other.
71    ///
72    /// # Errors
73    /// The model's failure, unchanged; the prefixes not yet renewed stay
74    /// for the next wake.
75    pub fn renew_due(
76        &mut self,
77        model: &mut dyn Model,
78        now_ms: u64,
79    ) -> Result<Vec<ModelReturn>, AxError> {
80        let (setting, lead_ms) = (self.setting, self.lead_ms);
81        self.kept
82            .retain(|_, kept| renewal_due(setting, kept.cache, lead_ms).is_some());
83        let mut answers = Vec::new();
84        for kept in self.kept.values_mut() {
85            if renewal_due(setting, kept.cache, lead_ms).is_some_and(|due| due <= now_ms) {
86                answers.push(model.call(&renewal_of(&kept.request))?);
87                kept.cache = kept.cache.renewed(now_ms);
88            }
89        }
90        Ok(answers)
91    }
92}
93
94/// The last real request, asking for one output token: the cache hits on
95/// the prefix bytes whatever the ceiling, so one token is the lowest
96/// price a renewal can pay.
97fn renewal_of(request: &ModelRequest<'static>) -> ModelRequest<'static> {
98    let mut renewal = request.clone();
99    renewal.chat.max_tokens = Ceiling::new(1);
100    renewal
101}
102
103/// The door a run's requests enter the keep-warm account by: it owns the
104/// adapter a run calls and is itself the [`Model`] the turn loop is
105/// handed, so every request that reached the provider is recorded
106/// without the turn loop knowing keep-warm exists.
107///
108/// The clock is the caller's, injected from the one sampling point; the
109/// round trip it measures around each call is the lead the next renewal
110/// leaves with, so the lead follows the provider this door reaches.
111pub struct Warmed<C> {
112    model: Box<dyn Model + Send>,
113    warmth: Warmth,
114    clock: C,
115}
116
117impl<C: FnMut() -> Result<TimeMs, AxError>> Warmed<C> {
118    /// Wraps `model`; under [`KeepWarm::Off`] it only forwards.
119    pub fn new(model: Box<dyn Model + Send>, setting: KeepWarm, clock: C) -> Warmed<C> {
120        Warmed {
121            model,
122            warmth: Warmth::new(setting, 0),
123            clock,
124        }
125    }
126
127    /// See [`Warmth::next_due`].
128    pub fn next_due(&self) -> Option<u64> {
129        self.warmth.next_due()
130    }
131
132    /// Sends every renewal due by `now_ms` through the adapter this door
133    /// owns; see [`Warmth::renew_due`].
134    ///
135    /// # Errors
136    /// The model's failure, unchanged.
137    pub fn renew_due(&mut self, now_ms: u64) -> Result<Vec<ModelReturn>, AxError> {
138        let left = (self.clock)()?;
139        let answers = self.warmth.renew_due(self.model.as_mut(), now_ms)?;
140        let back = (self.clock)()?;
141        // A renewal asks for one token, so its round trip is the closest
142        // reading of the lead. Several in one wake are timed together,
143        // which can only send the next ones earlier, never late.
144        if !answers.is_empty() {
145            self.warmth.lead_ms = back.value().saturating_sub(left.value());
146        }
147        Ok(answers)
148    }
149
150    /// Makes one real call through `send` and, when the provider
151    /// answered, records it with the round trip it took.
152    fn recorded(
153        &mut self,
154        request: &ModelRequest,
155        send: impl FnOnce(&mut dyn Model) -> Result<ModelReturn, AxError>,
156    ) -> Result<ModelReturn, AxError> {
157        let left = (self.clock)()?;
158        let answer = send(self.model.as_mut())?;
159        let back = (self.clock)()?;
160        self.warmth.lead_ms = back.value().saturating_sub(left.value());
161        self.warmth.sent(request, left.value());
162        Ok(answer)
163    }
164}
165
166impl<C: FnMut() -> Result<TimeMs, AxError>> Model for Warmed<C> {
167    fn provider_account(&self) -> Option<kernel::event::record::ProviderAccountBinding> {
168        self.model.provider_account()
169    }
170
171    fn account_roster(&self) -> Option<kernel::model::AccountRoster> {
172        self.model.account_roster()
173    }
174
175    fn select_account(&mut self, account: &kernel::ServerLabel) {
176        self.model.select_account(account);
177    }
178
179    fn call(&mut self, req: &ModelRequest) -> Result<ModelReturn, AxError> {
180        self.recorded(req, |model| model.call(req))
181    }
182
183    fn call_streaming(
184        &mut self,
185        req: &ModelRequest,
186        onto: Increments<'_>,
187    ) -> Result<ModelReturn, AxError> {
188        self.recorded(req, |model| model.call_streaming(req, onto))
189    }
190}
191
192#[cfg(test)]
193#[allow(
194    clippy::unwrap_used,
195    clippy::expect_used,
196    clippy::indexing_slicing,
197    clippy::arithmetic_side_effects
198)]
199mod tests {
200    use super::*;
201    use kernel::consts_external::PROMPT_CACHE_TTL_SECS;
202    use kernel::{BuildingPolicy, ChatRequest, ContentBlock};
203
204    const TTL_MS: u64 = PROMPT_CACHE_TTL_SECS * 1000;
205    const LEAD_MS: u64 = 2_000;
206    const USED_AT: u64 = 1_000_000;
207
208    /// Counts what reaches the provider, and keeps each request it saw.
209    #[derive(Default)]
210    struct Provider {
211        seen: Vec<ModelRequest<'static>>,
212    }
213
214    impl Model for Provider {
215        fn call(&mut self, req: &ModelRequest) -> Result<ModelReturn, AxError> {
216            self.seen.push(req.clone().into_owned());
217            Ok(ModelReturn::bare(
218                kernel::model::message_payload(&[ContentBlock::Text {
219                    text: "warm".to_owned(),
220                }])
221                .unwrap(),
222                Vec::new(),
223            ))
224        }
225    }
226
227    fn request() -> ModelRequest<'static> {
228        ModelRequest {
229            policy: BuildingPolicy::default(),
230            segments: [B3Hash::digest(b"prefix"); 4],
231            chat: ChatRequest::empty("script", Ceiling::new(512).unwrap()),
232        }
233    }
234
235    /// Asks `warmth` for renewals at every instant a timer could wake,
236    /// from the real use to well past one cache lifetime.
237    fn wake_through_two_lifetimes(warmth: &mut Warmth, provider: &mut Provider) {
238        for now in (USED_AT..=USED_AT + 2 * TTL_MS).step_by(1_000) {
239            warmth.renew_due(provider, now).unwrap();
240        }
241    }
242
243    #[test]
244    fn the_default_setting_sends_no_request_of_its_own() {
245        let mut warmth = Warmth::new(KeepWarm::default(), LEAD_MS);
246        let mut provider = Provider::default();
247        warmth.sent(&request(), USED_AT);
248        assert_eq!(warmth.next_due(), None);
249        wake_through_two_lifetimes(&mut warmth, &mut provider);
250        assert!(
251            provider.seen.is_empty(),
252            "{} renewals sent",
253            provider.seen.len()
254        );
255    }
256
257    #[test]
258    fn five_minute_resends_the_last_request_once_with_one_output_token() {
259        let mut warmth = Warmth::new(KeepWarm::FiveMinute, LEAD_MS);
260        let mut provider = Provider::default();
261        warmth.sent(&request(), USED_AT);
262        assert_eq!(warmth.next_due(), Some(USED_AT + TTL_MS - LEAD_MS));
263        wake_through_two_lifetimes(&mut warmth, &mut provider);
264        let mut renewal = request();
265        renewal.chat.max_tokens = Ceiling::new(1);
266        assert_eq!(provider.seen, vec![renewal]);
267        assert_eq!(warmth.next_due(), None);
268    }
269
270    /// What reached the provider behind a [`Warmed`] door, read after
271    /// the door owns the provider.
272    type Seen = std::sync::Arc<std::sync::Mutex<Vec<ModelRequest<'static>>>>;
273
274    struct Behind(Seen);
275
276    impl Model for Behind {
277        fn call(&mut self, req: &ModelRequest) -> Result<ModelReturn, AxError> {
278            let mut provider = Provider::default();
279            let answer = provider.call(req);
280            self.0.lock().unwrap().push(req.clone().into_owned());
281            answer
282        }
283    }
284
285    fn door(setting: KeepWarm) -> (Warmed<impl FnMut() -> Result<TimeMs, AxError>>, Seen) {
286        let seen = Seen::default();
287        let clock = || Ok(TimeMs::new(USED_AT));
288        let warmed = Warmed::new(Box::new(Behind(seen.clone())), setting, clock);
289        (warmed, seen)
290    }
291
292    fn wake_door_through_two_lifetimes(
293        warmed: &mut Warmed<impl FnMut() -> Result<TimeMs, AxError>>,
294    ) {
295        for now in (USED_AT..=USED_AT + 2 * TTL_MS).step_by(1_000) {
296            warmed.renew_due(now).unwrap();
297        }
298    }
299
300    #[test]
301    fn the_default_door_forwards_a_run_and_sends_nothing_of_its_own() {
302        let (mut warmed, seen) = door(KeepWarm::default());
303        warmed.call(&request()).unwrap();
304        wake_door_through_two_lifetimes(&mut warmed);
305        assert_eq!(*seen.lock().unwrap(), vec![request()]);
306    }
307
308    #[test]
309    fn a_five_minute_door_renews_what_a_run_sent_through_it_once() {
310        let (mut warmed, seen) = door(KeepWarm::FiveMinute);
311        warmed.call_streaming(&request(), &mut |_| {}).unwrap();
312        assert_eq!(warmed.next_due(), Some(USED_AT + TTL_MS));
313        wake_door_through_two_lifetimes(&mut warmed);
314        let mut renewal = request();
315        renewal.chat.max_tokens = Ceiling::new(1);
316        assert_eq!(*seen.lock().unwrap(), vec![request(), renewal]);
317    }
318}