Skip to main content

agent_engine/runtime/
tool_keepwarm.rs

1//! Keep the prompt cache warm while a provider round runs
2//! (`cache_keepwarm_tools`).
3//!
4//! A provider round that asks for tools is followed, once they return, by a
5//! request that re-sends that round's whole prefix. The cache entry that
6//! request reads lives for one TTL from the START of the round's request
7//! (workers are pinned to 5m), and a round can outlast it twice over:
8//!
9//! - **while its response streams.** One long generation is enough:
10//!   minutes of thinking before a short tool call, or a 30k-token `write`.
11//! - **while its tools run.** Builds, test sweeps and `sleep; poll` loops
12//!   routinely take that long.
13//!
14//! Either way the entry has expired by the time the next request goes out,
15//! and that request rewrites the whole prefix at the write price (1.25× on
16//! 5m) instead of reading it (0.1×, 0.05× on the 5.5 models).
17//!
18//! From the moment a round's request is sent, this task re-sends **that
19//! round's own request** — the exact request messages, tools, system prompt,
20//! thinking config and options it went out with — plus one trailing ping
21//! text block (`KeepWarmMode::Replay`, appended by the transport after
22//! marker placement), shortly before the TTL lapses, and again after every
23//! accepted ping, until the tools return, the response ends without asking
24//! for any, the turn is cancelled or the stream ends (the guard drops). It
25//! is a keep-warm ping in every other respect (`runtime::keepwarm`):
26//! `ApiOptions::keepwarm` cuts `max_tokens`, disables retries and marks the
27//! telemetry record `"keepwarm": true`; the transport holds the ping text
28//! out of marker placement, so every breakpoint lands on the block the round
29//! itself marked and the ping is an uncached tail. A read refreshes the
30//! entry's TTL, so the request after the round reads the prefix the round
31//! cached. Nothing is persisted and no history is touched.
32//!
33//! Policy (the idle keep-warm's, `session::cache_keepwarm`, minus most of
34//! the idle guesswork). While the tools run, the next request is certain
35//! unless the turn is cancelled. While the response streams it is not: the
36//! response may be the turn's last word. But a response that outlasts the
37//! TTL has lost its cache by the time it ends, whatever reads the prefix
38//! next (its tool results, a resume of the worker, the user's next
39//! message), and what a long response does first is think: the tool call
40//! comes at the end, too late to wait for. So the response is pinged as
41//! the tools are, under the same bound:
42//! - eligible only on the native Anthropic transport and when the round's
43//!   request cached something. While the response streams that is the
44//!   prefix the provider reports in `message_start`, passed on by the
45//!   transport ([`RoundSignal`]): the round's own usage is not booked until
46//!   the response ends;
47//! - due at `anchor + ping_interval(honoured TTL)` (4.5 min on 5m, 55 min on
48//!   1h) on whichever clock comes first; never once the cache is within
49//!   `LATE_GUARD` of expiry (it is presumed gone: the next request rewrites
50//!   it anyway, a ping would only add a write);
51//! - ski-rental bound: a ping is sent only while `ledger + expected ping ≤
52//!   rewriting the prefix at the honoured TTL`;
53//! - the turn's `max_cost_usd` (if any) must admit the ping's worst case.
54//!   While the response streams, the round's own request is not booked yet:
55//!   it is counted at the most its input side can cost, a full write of the
56//!   prefix;
57//! - stop on a ping that read nothing, wrote or paid uncached for more than
58//!   `SHARE_LIMIT_PERCENT` of the prefix, a 4xx, or exhausted transient
59//!   retries (`BACKOFF`).
60//!
61//! Stopping. When the tools return, no new ping is scheduled, but a ping
62//! already in flight is allowed to finish — for up to [`FINISH_GRACE`] —
63//! and is booked like any other before the next request goes out. Only a
64//! turn cancel, the stream ending, or the grace running out aborts one in
65//! flight. Either way the ping is settled before the round's next request
66//! is sent, so its usage never lands after that request's. (Provider-side
67//! order would not matter anyway: an aborted ping and the next request read
68//! the same prefix, and a ping writes nothing — its tail is uncached.) A
69//! response that ends without asking for tools ends the round and its
70//! pings: one in flight is aborted and booked before the turn goes on. (A
71//! ping it got while streaming is spent on a prefix that only a resume or
72//! the next turn can still read; the idle keep-warm, where one runs,
73//! schedules from the anchor that ping left.)
74//!
75//! Booking. Every ping that may have been billed is booked into the turn,
76//! by the idle keep-warm's rule (`keepwarm::ping_booking`): with measured
77//! usage the transport records it in the turn's usage counters (turn
78//! budget) and its `Usage` event is forwarded to the turn's event channel
79//! (session / worker cost); a ping dispatched with no usage seen (aborted in
80//! flight, a read timeout) is booked at its worst case — the whole prefix
81//! read, [`PING_INPUT_ESTIMATE`] uncached, `max_tokens` out — through both;
82//! one refused before the network or rejected with an HTTP error books
83//! nothing. A ping sent while the response streams is booked when it
84//! completes, normally before the round's own usage; both are sums, so the
85//! order does not matter to them. `UsageCounters::latest_context_tokens` is
86//! the one last-writer field: a ping that completes in the instant between
87//! the round's own booking and the stream loop's read leaves its own
88//! footprint there, which is the round's request plus the ping text (~40
89//! tokens).
90
91use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
92use std::sync::Arc;
93use std::time::{Duration, SystemTime};
94
95use serde_json::Value;
96use tokio::sync::{mpsc, RwLock};
97use tokio::time::Instant;
98use tokio_util::sync::CancellationToken;
99
100use super::api::{ApiMethods, ApiOptions, KeepWarmMode, KeepWarmRequest};
101use super::keepwarm::{CacheAnchor, PingBooking, PingFailure, PingTap};
102use super::types::{AuthState, SessionEvent, StreamEvent};
103use crate::core::config::CacheTtl;
104use crate::session::cache_keepwarm::{
105    governing_ttl, honoured_ttl, ping_interval, ping_output_estimate, request_cost_usd,
106    rewrite_cost_usd, TtlEvidence, BACKOFF, LATE_GUARD, PING_INPUT_ESTIMATE, SHARE_LIMIT_PERCENT,
107    WALL_POLL,
108};
109use crate::{SharedMessage, ToolRegistry};
110
111/// How long the end of a tool round waits for a ping already in flight
112/// before aborting it (and booking it at its worst case). Longer than every
113/// keep-warm ping in the api-log so far (122 real pings: p50 1.9 s, p95
114/// 11 s, max 15.8 s): the wait only happens when a ping straddles the end of
115/// the round (a few seconds out of every 270 s interval), and it delays the
116/// next request by at most the rest of that ping.
117pub const FINISH_GRACE: Duration = Duration::from_secs(20);
118
119/// Per-turn settings: present only when `cache_keepwarm_tools` is on.
120#[derive(Debug, Clone, Default)]
121pub struct ToolKeepWarm {
122    /// The runtime's base trace context: a ping is traced like an idle
123    /// keep-warm ping and never consumes a one-shot `/trace next`.
124    pub trace: super::trace::TraceContext,
125    /// Replaces `ping_interval(honoured TTL)` (tests only, see
126    /// [`interval_override`]).
127    pub interval_override: Option<Duration>,
128    /// Replaces [`FINISH_GRACE`] (tests only).
129    #[cfg(test)]
130    pub finish_grace: Option<Duration>,
131    /// Notified when the end of a round starts waiting for a ping in flight
132    /// (tests only: the sync point that releases a held ping).
133    #[cfg(test)]
134    pub finish_hook: Option<Arc<tokio::sync::Notify>>,
135}
136
137impl ToolKeepWarm {
138    pub fn new(trace: super::trace::TraceContext, interval_override: Option<Duration>) -> Self {
139        Self {
140            trace,
141            interval_override,
142            #[cfg(test)]
143            finish_grace: None,
144            #[cfg(test)]
145            finish_hook: None,
146        }
147    }
148
149    fn finish_grace(&self) -> Duration {
150        #[cfg(test)]
151        if let Some(grace) = self.finish_grace {
152            return grace;
153        }
154        FINISH_GRACE
155    }
156}
157
158/// The ping interval override, in ms (0 = none). Test-only, like the idle
159/// keep-warm's: written solely by [`testing::IntervalOverride`].
160#[cfg(any(test, feature = "testing"))]
161static INTERVAL_OVERRIDE_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
162
163/// Test seam replacing `ping_interval(honoured TTL)` for tool-round pings.
164/// Separate from the idle keep-warm's seam so tests of one never fire the
165/// other. A constant `None` without `cfg(test)` / the `testing` feature; no
166/// binary input (env, config, argv) can reach it.
167pub fn interval_override() -> Option<Duration> {
168    #[cfg(any(test, feature = "testing"))]
169    {
170        let ms = INTERVAL_OVERRIDE_MS.load(std::sync::atomic::Ordering::Relaxed);
171        if ms > 0 {
172            return Some(Duration::from_millis(ms));
173        }
174    }
175    None
176}
177
178/// Test-only controls (`cfg(test)` / `testing` feature).
179#[cfg(any(test, feature = "testing"))]
180pub mod testing {
181    use std::sync::atomic::Ordering;
182    use std::time::Duration;
183
184    /// Shortens the tool-round ping interval for every runtime in this
185    /// process while alive (tests that use it must be `#[serial]`).
186    #[must_use = "the override is cleared when the guard drops"]
187    pub struct IntervalOverride(());
188
189    impl IntervalOverride {
190        pub fn set(interval: Duration) -> Self {
191            let ms = u64::try_from(interval.as_millis())
192                .unwrap_or(u64::MAX)
193                .max(1);
194            super::INTERVAL_OVERRIDE_MS.store(ms, Ordering::Relaxed);
195            Self(())
196        }
197    }
198
199    impl Drop for IntervalOverride {
200        fn drop(&mut self) {
201            super::INTERVAL_OVERRIDE_MS.store(0, Ordering::Relaxed);
202        }
203    }
204}
205
206/// Taken immediately before a round's provider call.
207#[derive(Debug, Clone, Copy)]
208pub(super) struct RoundMark {
209    sent: CacheAnchor,
210    cached_before: u64,
211}
212
213fn cached_total(counters: &super::budget::UsageCounters) -> u64 {
214    let (_, _, read, write) = counters.totals();
215    read.saturating_add(write)
216}
217
218impl RoundMark {
219    pub(super) fn new(counters: &super::budget::UsageCounters) -> Self {
220        Self {
221            sent: CacheAnchor::now(),
222            cached_before: cached_total(counters),
223        }
224    }
225}
226
227/// What a round's own request tells its keep-warm while the response is
228/// still streaming ([`ApiOptions::round_signal`]): written by the transport
229/// as the response starts, and by the stream loop once it is in; read by
230/// the round's task. One per round, never shared with a ping (a ping's own
231/// `message_start` must not pass for the round's).
232#[derive(Debug, Default)]
233pub struct RoundSignal {
234    /// Tokens the provider read from or wrote to the cache for the round's
235    /// request (`message_start`). 0 until reported: nothing to keep warm.
236    cached_tokens: AtomicU64,
237    /// The response is in and asks for tools.
238    response_in: AtomicBool,
239    /// Wakes the round's task (its one waiter) when the above change.
240    changed: tokio::sync::Notify,
241}
242
243impl RoundSignal {
244    /// `message_start` of an attempt of the round's request: the size of
245    /// its cached prefix (a retried attempt reports its own).
246    pub(super) fn message_started(&self, cached_tokens: u64) {
247        self.cached_tokens.store(cached_tokens, Ordering::Release);
248        self.changed.notify_one();
249    }
250
251    /// The response is in and asks for tools. `booked` is what the turn's
252    /// counters say the round cached, for a provider whose `message_start`
253    /// carried no usage (no ping can have gone out by then: one needs a
254    /// reported prefix). Returns whether there is a prefix to keep warm.
255    fn tools_running(&self, booked: u64) -> bool {
256        let _ = self
257            .cached_tokens
258            .compare_exchange(0, booked, Ordering::AcqRel, Ordering::Acquire);
259        self.response_in.store(true, Ordering::Release);
260        self.changed.notify_one();
261        self.cached_tokens() > 0
262    }
263
264    pub(super) fn cached_tokens(&self) -> u64 {
265        self.cached_tokens.load(Ordering::Acquire)
266    }
267
268    fn response_in(&self) -> bool {
269        self.response_in.load(Ordering::Acquire)
270    }
271}
272
273/// Whether a provider response asks for tools.
274pub(super) fn has_tool_use(response: &Value) -> bool {
275    response["content"]
276        .as_array()
277        .is_some_and(|blocks| blocks.iter().any(|b| b["type"] == "tool_use"))
278}
279
280/// Everything one round's request went out with (borrowed from the stream
281/// loop; [`ToolRoundKeepWarm::start`] copies what it keeps — Arc bumps for
282/// the messages).
283pub(super) struct Round<'a> {
284    pub auth: &'a Arc<RwLock<AuthState>>,
285    pub client: &'a reqwest::Client,
286    pub model: &'a str,
287    pub tools: &'a ToolRegistry,
288    pub system_prompt: &'a Option<String>,
289    pub thinking_budget: u32,
290    pub reasoning_level: agent_core::reasoning::ReasoningLevel,
291    pub messages: &'a [SharedMessage],
292    pub options: &'a ApiOptions,
293    pub telemetry_level: super::telemetry::TelemetryLevel,
294}
295
296/// The turn the round belongs to.
297pub(super) struct Turn<'a> {
298    pub usage: &'a Arc<super::budget::UsageCounters>,
299    pub max_cost_usd: Option<f64>,
300    pub tx: &'a mpsc::UnboundedSender<StreamEvent>,
301    pub cancel: &'a CancellationToken,
302}
303
304/// Why the pings of a round stopped (logged once).
305#[derive(Debug, Clone, PartialEq)]
306pub(super) enum Stop {
307    /// The tools returned, the response asked for none, the turn was
308    /// cancelled or the stream ended.
309    Done,
310    /// Within `LATE_GUARD` of expiry (or past it): presumed expired.
311    Expired,
312    /// The next ping would pass the rewrite it saves.
313    Bound,
314    /// The turn's `max_cost_usd` would not admit the next ping.
315    TurnCost,
316    /// A ping read nothing, or wrote / paid uncached for a large share.
317    Miss,
318    /// 4xx / configuration / exhausted transient retries.
319    Failed(String),
320}
321
322impl std::fmt::Display for Stop {
323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        match self {
325            Stop::Done => f.write_str("round finished"),
326            Stop::Expired => f.write_str("cache presumed expired"),
327            Stop::Bound => f.write_str("next ping would pass the rewrite it saves"),
328            Stop::TurnCost => f.write_str("turn max_cost_usd reached"),
329            Stop::Miss => f.write_str("ping missed the cache"),
330            Stop::Failed(why) => write!(f, "ping failed: {why}"),
331        }
332    }
333}
334
335/// What the task needs to send and schedule pings.
336struct Job {
337    auth: Arc<RwLock<AuthState>>,
338    client: reqwest::Client,
339    model: String,
340    tools: ToolRegistry,
341    system_prompt: Option<String>,
342    thinking_budget: u32,
343    reasoning_level: agent_core::reasoning::ReasoningLevel,
344    /// The round's request messages, exactly (the transport appends the
345    /// ping text after marker placement, `KeepWarmMode::Replay`).
346    messages: Vec<SharedMessage>,
347    options: ApiOptions,
348    telemetry_level: super::telemetry::TelemetryLevel,
349    /// The earliest the cache entry can have been written or last
350    /// refreshed: the round's send, then each accepted ping's.
351    floor: CacheAnchor,
352    /// Stamped by the round's request (every accepted attempt) and by the
353    /// pings: the latest stamp at or after `floor` is the anchor.
354    anchor_cell: Arc<super::keepwarm::CacheAnchorCell>,
355    signal: Arc<RoundSignal>,
356    /// The configured TTL for the turn's credentials; what the server
357    /// honours is read from the session's latches at every ping
358    /// ([`Job::ttl`]): the round's own response can be the first evidence.
359    configured_ttl: CacheTtl,
360    interval_override: Option<Duration>,
361    max_tokens: u64,
362    usage: Arc<super::budget::UsageCounters>,
363    max_cost_usd: Option<f64>,
364    tx: mpsc::UnboundedSender<StreamEvent>,
365}
366
367/// The task's controls: `stop` ends the schedule (no new ping), `abort`
368/// (a child of the turn's cancel) also aborts a ping in flight.
369struct Controls {
370    stop: CancellationToken,
371    abort: CancellationToken,
372    in_flight: Arc<AtomicBool>,
373}
374
375/// Whether the turn's `max_cost_usd` admits a ping: what is booked, plus
376/// the round's own request while it is not booked yet (`unbooked_usd`),
377/// plus the ping's worst case. No cap, or a non-finite one, admits it.
378fn turn_admits(cap: Option<f64>, booked_usd: f64, unbooked_usd: f64, worst_usd: f64) -> bool {
379    match cap.filter(|c| c.is_finite()) {
380        Some(cap) => booked_usd + unbooked_usd + worst_usd <= cap,
381        None => true,
382    }
383}
384
385/// Running keep-warm for one round, started as its request goes out.
386/// [`tools_running`](Self::tools_running) carries it from the response into
387/// the tool calls, [`finish`](Self::finish) stops it gracefully once they
388/// return, [`abandon`](Self::abandon) ends it when the response asks for
389/// none; dropping it (any other way out of the round) aborts a ping in
390/// flight, which the task still books.
391pub(super) struct ToolRoundKeepWarm {
392    signal: Arc<RoundSignal>,
393    cached_before: u64,
394    stop: CancellationToken,
395    abort: CancellationToken,
396    in_flight: Arc<AtomicBool>,
397    grace: Duration,
398    #[cfg(test)]
399    hook: Option<Arc<tokio::sync::Notify>>,
400    handle: Option<tokio::task::JoinHandle<Stop>>,
401}
402
403impl Drop for ToolRoundKeepWarm {
404    fn drop(&mut self) {
405        self.stop.cancel();
406        self.abort.cancel();
407    }
408}
409
410impl ToolRoundKeepWarm {
411    /// Start keeping `round`'s prefix warm, as its request goes out (`mark`
412    /// was taken just before, `signal` rides in the request's options), or
413    /// `None` when it cannot be: not the native Anthropic transport.
414    /// Nothing is sent until the transport reports a cached prefix
415    /// ([`RoundSignal`]).
416    pub(super) async fn start(
417        settings: &ToolKeepWarm,
418        mark: RoundMark,
419        signal: &Arc<RoundSignal>,
420        round: Round<'_>,
421        turn: Turn<'_>,
422    ) -> Option<Self> {
423        if !super::auth::model_is_anthropic(round.model) {
424            return None;
425        }
426        let auth_type = round.auth.read().await.auth_type.clone();
427        let configured_ttl = round.options.cache_ttl.resolve_for_auth_type(&auth_type);
428        // The round's request and the pings stamp the round's anchor cell
429        // (or the pings a private one): every accepted request moves the
430        // anchor the next ping is scheduled from.
431        let anchor_cell = round.options.cache_anchor.clone().unwrap_or_default();
432        let messages = round.messages.to_vec();
433        let options = ApiOptions {
434            // A ping runs no tool: nothing of the chat's goes with it.
435            tool_context: None,
436            trace: settings.trace.clone(),
437            request_correlation: None,
438            suppress_stream_deltas: true,
439            usage_counters: Some(Arc::clone(turn.usage)),
440            cache_anchor: Some(Arc::clone(&anchor_cell)),
441            context_rollover: None,
442            keepwarm: None,
443            // A ping reports nothing about the round.
444            round_signal: None,
445            ..round.options.clone()
446        };
447        let max_tokens = match round.thinking_budget {
448            0 => super::keepwarm::KEEPWARM_OUTPUT_TOKENS,
449            budget => u64::from(budget) + super::keepwarm::KEEPWARM_OUTPUT_TOKENS,
450        };
451        let job = Job {
452            auth: Arc::clone(round.auth),
453            client: round.client.clone(),
454            model: round.model.to_string(),
455            tools: round.tools.clone(),
456            system_prompt: round.system_prompt.clone(),
457            thinking_budget: round.thinking_budget,
458            reasoning_level: round.reasoning_level,
459            messages,
460            options,
461            telemetry_level: round.telemetry_level,
462            floor: mark.sent,
463            anchor_cell,
464            signal: Arc::clone(signal),
465            configured_ttl,
466            interval_override: settings.interval_override,
467            max_tokens,
468            usage: Arc::clone(turn.usage),
469            max_cost_usd: turn.max_cost_usd,
470            tx: turn.tx.clone(),
471        };
472        let controls = Controls {
473            stop: CancellationToken::new(),
474            abort: turn.cancel.child_token(),
475            in_flight: Arc::default(),
476        };
477        let (stop, abort, in_flight) = (
478            controls.stop.clone(),
479            controls.abort.clone(),
480            Arc::clone(&controls.in_flight),
481        );
482        let handle = tokio::spawn(job.run(controls));
483        Some(Self {
484            signal: Arc::clone(signal),
485            cached_before: mark.cached_before,
486            stop,
487            abort,
488            in_flight,
489            grace: settings.finish_grace(),
490            #[cfg(test)]
491            hook: settings.finish_hook.clone(),
492            handle: Some(handle),
493        })
494    }
495
496    /// The round's response is in and asks for tools: its prefix is kept
497    /// warm while they run. `false` when its request cached nothing (there
498    /// is nothing to keep: [`abandon`](Self::abandon) it).
499    pub(super) fn tools_running(&self, usage: &super::budget::UsageCounters) -> bool {
500        self.signal
501            .tools_running(cached_total(usage).saturating_sub(self.cached_before))
502    }
503
504    /// The round is over with no tools to run (the response asked for none,
505    /// or was cut by a cancel): nothing in this turn reads its prefix next.
506    /// Schedule no new ping, abort one in flight, and wait until it is
507    /// booked.
508    pub(super) async fn abandon(mut self) -> Stop {
509        self.stop.cancel();
510        self.abort.cancel();
511        match self.handle.take() {
512            Some(handle) => handle.await.unwrap_or(Stop::Done),
513            None => Stop::Done,
514        }
515    }
516
517    /// The round's tools are done: schedule no new ping, let one in flight
518    /// finish (up to the grace, then abort it), and wait until it is booked.
519    /// Returns why the pings stopped.
520    pub(super) async fn finish(mut self) -> Stop {
521        self.stop.cancel();
522        let Some(mut handle) = self.handle.take() else {
523            return Stop::Done;
524        };
525        if self.in_flight.load(Ordering::Acquire) {
526            tracing::debug!(
527                event = "tool_round_keepwarm_drain",
528                "tool round done with a keep-warm ping in flight; letting it finish"
529            );
530            #[cfg(test)]
531            if let Some(hook) = &self.hook {
532                hook.notify_one();
533            }
534        }
535        match tokio::time::timeout(self.grace, &mut handle).await {
536            Ok(stop) => stop.unwrap_or(Stop::Done),
537            Err(_) => {
538                tracing::info!(
539                    event = "tool_round_keepwarm_grace_expired",
540                    grace_ms = self.grace.as_millis() as u64,
541                    "tool-round keep-warm ping still in flight at the end of the round; aborting"
542                );
543                self.abort.cancel();
544                handle.await.unwrap_or(Stop::Done)
545            }
546        }
547    }
548}
549
550fn over_share(tokens: u64, prefix: u64) -> bool {
551    tokens.saturating_mul(100) > prefix.saturating_mul(SHARE_LIMIT_PERCENT)
552}
553
554impl Job {
555    /// When the cache entry was last written or refreshed, as far as this
556    /// side can tell (never later than the provider's own request start).
557    fn anchor(&self) -> CacheAnchor {
558        self.anchor_cell
559            .get()
560            .filter(|a| a.mono >= self.floor.mono)
561            .unwrap_or(self.floor)
562    }
563
564    fn elapsed_since(anchor: CacheAnchor) -> Duration {
565        let mono = Instant::now().saturating_duration_since(anchor.mono);
566        let wall = SystemTime::now()
567            .duration_since(anchor.wall)
568            .unwrap_or_default();
569        mono.max(wall)
570    }
571
572    fn elapsed(&self) -> Duration {
573        Self::elapsed_since(self.anchor())
574    }
575
576    /// The TTL the server honours for this session, on the evidence so far.
577    fn ttl(&self) -> CacheTtl {
578        honoured_ttl(
579            self.configured_ttl,
580            TtlEvidence {
581                downgraded: self
582                    .options
583                    .ttl_downgrade_notified
584                    .load(std::sync::atomic::Ordering::Relaxed),
585                saw_1h: self
586                    .options
587                    .saw_1h_honored
588                    .load(std::sync::atomic::Ordering::Relaxed),
589                ..Default::default()
590            },
591        )
592    }
593
594    fn interval(&self, ttl: CacheTtl) -> Duration {
595        self.interval_override
596            .unwrap_or_else(|| ping_interval(ttl))
597    }
598
599    /// Sleep until the next ping is due (either clock), or `false` once
600    /// stopped or aborted. Nothing is due before the provider has reported
601    /// a cached prefix for the round: there is nothing to keep warm, and no
602    /// accepted request to schedule from.
603    async fn wait_due(&self, c: &Controls) -> bool {
604        loop {
605            if c.stop.is_cancelled() || c.abort.is_cancelled() {
606                return false;
607            }
608            let wake = if self.signal.cached_tokens() > 0 {
609                let anchor = self.anchor();
610                let interval = self.interval(self.ttl());
611                if Self::elapsed_since(anchor) >= interval {
612                    return true;
613                }
614                (anchor.mono + interval).min(Instant::now() + WALL_POLL)
615            } else {
616                Instant::now() + WALL_POLL
617            };
618            tokio::select! {
619                _ = c.stop.cancelled() => return false,
620                _ = c.abort.cancelled() => return false,
621                _ = self.signal.changed.notified() => {}
622                _ = tokio::time::sleep_until(wake) => {}
623            }
624        }
625    }
626
627    /// Book a ping that may have been billed but reported no usage (aborted
628    /// in flight, a read timeout after dispatch) at its worst case, into the
629    /// turn's counters and event channel — where a measured ping's usage
630    /// goes. A measured ping is already booked by the transport.
631    fn book_unmeasured(&self, bill: &super::keepwarm::PingBill, prefix_tokens: u64) {
632        if bill.booking != PingBooking::WorstCase {
633            return;
634        }
635        let (input, output, read) = (PING_INPUT_ESTIMATE, self.max_tokens, prefix_tokens);
636        self.usage
637            .record_priced(&self.model, input, output, read, 0, None, None);
638        let _ = self.tx.send(StreamEvent::Session(SessionEvent::Usage {
639            input_tokens: input,
640            output_tokens: output,
641            cache_read_input_tokens: read,
642            cache_creation_input_tokens: 0,
643            cache_creation_5m: None,
644            cache_creation_1h: None,
645            model: Some(self.model.clone()),
646        }));
647        tracing::info!(
648            event = "tool_round_keepwarm_unmeasured",
649            model = %self.model,
650            cost_usd = bill.cost_usd,
651            "tool-round keep-warm ping dispatched without usage; booked at its worst case"
652        );
653    }
654
655    fn ping_cost(&self, ttl: CacheTtl, prefix_tokens: u64, output: u64) -> f64 {
656        request_cost_usd(
657            &self.model,
658            ttl,
659            prefix_tokens,
660            0,
661            PING_INPUT_ESTIMATE,
662            output,
663        )
664    }
665
666    async fn run(mut self, controls: Controls) -> Stop {
667        let stop = self.pings(&controls).await;
668        if stop != Stop::Done {
669            tracing::info!(
670                event = "tool_round_keepwarm_stop",
671                model = %self.model,
672                reason = %stop,
673                "tool-round keep-warm stopped"
674            );
675        }
676        stop
677    }
678
679    async fn pings(&mut self, c: &Controls) -> Stop {
680        // The cost of the last measured ping; an estimate before the first.
681        let mut measured: Option<f64> = None;
682        let mut ledger = 0.0;
683        let mut retries = 0usize;
684        let mut pings = 0u32;
685        loop {
686            if !self.wait_due(c).await {
687                return Stop::Done;
688            }
689            let ttl = self.ttl();
690            // Test intervals are far below the TTL; production ones leave
691            // more than LATE_GUARD.
692            if self.elapsed() + LATE_GUARD >= governing_ttl(ttl) {
693                return Stop::Expired;
694            }
695            // Known by now: a ping is only due once the prefix is reported.
696            let prefix_tokens = self.signal.cached_tokens();
697            let rewrite = rewrite_cost_usd(&self.model, ttl, prefix_tokens);
698            let worst = self.ping_cost(ttl, prefix_tokens, self.max_tokens);
699            let expected = measured.unwrap_or_else(|| {
700                self.ping_cost(ttl, prefix_tokens, ping_output_estimate(self.max_tokens))
701            });
702            if ledger + expected > rewrite {
703                return Stop::Bound;
704            }
705            // The round's own request is booked when its response ends;
706            // until then it counts at the most its input side can cost.
707            let mid_stream = !self.signal.response_in();
708            let unbooked = if mid_stream { rewrite } else { 0.0 };
709            if !turn_admits(self.max_cost_usd, self.usage.cost_usd(), unbooked, worst) {
710                return Stop::TurnCost;
711            }
712            // A ping never selects, switches or re-binds an account (the
713            // auth state is the turn's): it stops if its account is cooling.
714            if let Err(e) = super::auth::AuthMethods::refresh_if_needed(
715                Arc::clone(&self.auth),
716                &self.client,
717                &self.options.credential_source,
718                &self.options.token_cache,
719                Some(&self.model),
720                super::auth::RefreshPoint::KeepWarm,
721            )
722            .await
723            {
724                return Stop::Failed(format!("auth refresh: {e}"));
725            }
726            // The tools may have returned during the refresh.
727            if c.stop.is_cancelled() || c.abort.is_cancelled() {
728                return Stop::Done;
729            }
730            let tap = PingTap::default();
731            let sent_at = CacheAnchor::now();
732            c.in_flight.store(true, Ordering::Release);
733            let result = self.ping(&c.abort, &tap).await;
734            c.in_flight.store(false, Ordering::Release);
735            let bill = tap.bill(&self.model, worst);
736            ledger += bill.cost_usd;
737            self.book_unmeasured(&bill, prefix_tokens);
738            match result {
739                Ok(()) => {
740                    pings += 1;
741                    retries = 0;
742                    let u = &bill.usage;
743                    tracing::info!(
744                        event = "tool_round_keepwarm_ping",
745                        model = %self.model,
746                        ping = pings,
747                        mid_stream,
748                        cache_read = u.cache_read_tokens,
749                        cache_write = u.cache_write_tokens,
750                        input = u.input_tokens,
751                        output = u.output_tokens,
752                        cost_usd = bill.cost_usd,
753                        ledger_usd = ledger,
754                        rewrite_usd = rewrite,
755                        "tool-round keep-warm ping"
756                    );
757                    if u.cache_read_tokens == 0
758                        || over_share(u.cache_write_tokens, prefix_tokens)
759                        || over_share(u.input_tokens, prefix_tokens)
760                    {
761                        return Stop::Miss;
762                    }
763                    measured = Some(bill.cost_usd);
764                    // The accepted ping refreshed the entry: the anchor is
765                    // its stamp, or failing that its send.
766                    self.floor = sent_at;
767                }
768                Err(PingFailure::Canceled) => {
769                    tracing::info!(
770                        event = "tool_round_keepwarm_aborted",
771                        booking = ?bill.booking,
772                        cost_usd = bill.cost_usd,
773                        "tool-round keep-warm ping aborted in flight"
774                    );
775                    return Stop::Done;
776                }
777                Err(PingFailure::Transient(why)) if retries < BACKOFF.len() => {
778                    tracing::debug!(%why, retry = retries + 1, "tool-round keep-warm ping: transient failure");
779                    let delay = BACKOFF[retries];
780                    retries += 1;
781                    tokio::select! {
782                        _ = c.stop.cancelled() => return Stop::Done,
783                        _ = c.abort.cancelled() => return Stop::Done,
784                        _ = tokio::time::sleep(delay) => {}
785                    }
786                }
787                Err(failure) => return Stop::Failed(failure.to_string()),
788            }
789        }
790    }
791
792    /// Send one ping: the round's request plus the ping text.
793    async fn ping(&self, cancel: &CancellationToken, tap: &PingTap) -> Result<(), PingFailure> {
794        let options = ApiOptions {
795            keepwarm: Some(KeepWarmRequest {
796                mode: KeepWarmMode::Replay,
797                status: Arc::clone(&tap.status),
798                sent: Arc::clone(&tap.sent),
799                connect_failed: Arc::clone(&tap.connect_failed),
800            }),
801            ..self.options.clone()
802        };
803        let (tx, mut rx) = mpsc::unbounded_channel();
804        let call = ApiMethods::call_api_stream_inner(
805            &self.auth,
806            &self.client,
807            &self.model,
808            &self.tools,
809            &self.system_prompt,
810            self.thinking_budget,
811            self.reasoning_level,
812            &self.messages,
813            tx,
814            cancel,
815            0,
816            0,
817            &options,
818            self.telemetry_level,
819        );
820        let (result, _notices) =
821            super::keepwarm::drive_ping(call, &mut rx, tap, Some(&self.tx)).await;
822        match result {
823            Ok(value) if value.get("keepwarm_prefix").is_none() => Ok(()),
824            Ok(_) => Err(PingFailure::Fatal("ping was not sent".into())),
825            Err(e) => Err(super::keepwarm::classify_failure(
826                &e,
827                tap.status.load(std::sync::atomic::Ordering::Relaxed),
828            )),
829        }
830    }
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    /// The provider's `message_start` reports the round's cached prefix,
838    /// and wakes the round's task even when it starts waiting later. A
839    /// retried attempt reports its own.
840    #[tokio::test]
841    async fn the_reported_prefix_reaches_the_task() {
842        let signal = RoundSignal::default();
843        assert_eq!(signal.cached_tokens(), 0, "nothing reported: nothing to keep warm");
844        assert!(!signal.response_in());
845        signal.message_started(120_000);
846        assert_eq!(signal.cached_tokens(), 120_000);
847        assert!(!signal.response_in(), "still streaming");
848        tokio::time::timeout(Duration::from_secs(5), signal.changed.notified())
849            .await
850            .expect("the wake-up is kept for a task that starts waiting later");
851        signal.message_started(120_500);
852        assert_eq!(signal.cached_tokens(), 120_500, "the retry's own prefix");
853    }
854
855    /// Once the response is in and asks for tools, the turn's counters
856    /// only stand in for a provider that reported no prefix, and a request
857    /// that cached nothing has nothing to keep warm.
858    #[test]
859    fn the_response_being_in_settles_the_round() {
860        let signal = RoundSignal::default();
861        signal.message_started(120_000);
862        assert!(signal.tools_running(999), "a prefix to keep warm");
863        assert!(signal.response_in());
864        assert_eq!(signal.cached_tokens(), 120_000, "the reported prefix wins");
865
866        let unreported = RoundSignal::default();
867        assert!(unreported.tools_running(80_000));
868        assert_eq!(unreported.cached_tokens(), 80_000, "the turn's counters");
869
870        let uncached = RoundSignal::default();
871        uncached.message_started(0);
872        assert!(!uncached.tools_running(0), "nothing cached: nothing to keep");
873        assert_eq!(uncached.cached_tokens(), 0);
874    }
875
876    /// The turn's cost cap counts the round's own request while it is not
877    /// booked yet, so a ping sent as the response streams cannot be what
878    /// takes the turn past it.
879    #[test]
880    fn the_cost_cap_counts_the_unbooked_round() {
881        // No cap, or a non-finite one: always admitted.
882        assert!(turn_admits(None, 9.0, 9.0, 9.0));
883        assert!(turn_admits(Some(f64::INFINITY), 9.0, 9.0, 9.0));
884        assert!(turn_admits(Some(f64::NAN), 9.0, 9.0, 9.0));
885        // Tools running: the round is booked, only the ping is added.
886        assert!(turn_admits(Some(1.0), 0.70, 0.0, 0.25));
887        assert!(!turn_admits(Some(1.0), 0.70, 0.0, 0.35));
888        // Response streaming: the round (up to 0.60 for its input side) is
889        // not booked yet, and the same ping no longer fits on top of 0.20.
890        assert!(turn_admits(Some(1.0), 0.10, 0.60, 0.25));
891        assert!(!turn_admits(Some(1.0), 0.20, 0.60, 0.25));
892    }
893}