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}