1use std::sync::atomic::{AtomicBool, Ordering};
59use std::sync::Arc;
60use std::time::{Duration, SystemTime};
61
62use serde_json::Value;
63use tokio::sync::{mpsc, RwLock};
64use tokio::time::Instant;
65use tokio_util::sync::CancellationToken;
66
67use super::api::{ApiMethods, ApiOptions, KeepWarmMode, KeepWarmRequest};
68use super::keepwarm::{CacheAnchor, PingBooking, PingFailure, PingTap};
69use super::types::{AuthState, SessionEvent, StreamEvent};
70use crate::core::config::CacheTtl;
71use crate::session::cache_keepwarm::{
72 governing_ttl, honoured_ttl, ping_interval, ping_output_estimate, request_cost_usd,
73 rewrite_cost_usd, TtlEvidence, BACKOFF, LATE_GUARD, PING_INPUT_ESTIMATE, SHARE_LIMIT_PERCENT,
74 WALL_POLL,
75};
76use crate::{SharedMessage, ToolRegistry};
77
78pub const FINISH_GRACE: Duration = Duration::from_secs(20);
85
86#[derive(Debug, Clone, Default)]
88pub struct ToolKeepWarm {
89 pub trace: super::trace::TraceContext,
92 pub interval_override: Option<Duration>,
95 #[cfg(test)]
97 pub finish_grace: Option<Duration>,
98 #[cfg(test)]
101 pub finish_hook: Option<Arc<tokio::sync::Notify>>,
102}
103
104impl ToolKeepWarm {
105 pub fn new(trace: super::trace::TraceContext, interval_override: Option<Duration>) -> Self {
106 Self {
107 trace,
108 interval_override,
109 #[cfg(test)]
110 finish_grace: None,
111 #[cfg(test)]
112 finish_hook: None,
113 }
114 }
115
116 fn finish_grace(&self) -> Duration {
117 #[cfg(test)]
118 if let Some(grace) = self.finish_grace {
119 return grace;
120 }
121 FINISH_GRACE
122 }
123}
124
125#[cfg(any(test, feature = "testing"))]
128static INTERVAL_OVERRIDE_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
129
130pub fn interval_override() -> Option<Duration> {
135 #[cfg(any(test, feature = "testing"))]
136 {
137 let ms = INTERVAL_OVERRIDE_MS.load(std::sync::atomic::Ordering::Relaxed);
138 if ms > 0 {
139 return Some(Duration::from_millis(ms));
140 }
141 }
142 None
143}
144
145#[cfg(any(test, feature = "testing"))]
147pub mod testing {
148 use std::sync::atomic::Ordering;
149 use std::time::Duration;
150
151 #[must_use = "the override is cleared when the guard drops"]
154 pub struct IntervalOverride(());
155
156 impl IntervalOverride {
157 pub fn set(interval: Duration) -> Self {
158 let ms = u64::try_from(interval.as_millis())
159 .unwrap_or(u64::MAX)
160 .max(1);
161 super::INTERVAL_OVERRIDE_MS.store(ms, Ordering::Relaxed);
162 Self(())
163 }
164 }
165
166 impl Drop for IntervalOverride {
167 fn drop(&mut self) {
168 super::INTERVAL_OVERRIDE_MS.store(0, Ordering::Relaxed);
169 }
170 }
171}
172
173#[derive(Debug, Clone, Copy)]
175pub(super) struct RoundMark {
176 sent: CacheAnchor,
177 cached_before: u64,
178}
179
180fn cached_total(counters: &super::budget::UsageCounters) -> u64 {
181 let (_, _, read, write) = counters.totals();
182 read.saturating_add(write)
183}
184
185impl RoundMark {
186 pub(super) fn new(counters: &super::budget::UsageCounters) -> Self {
187 Self {
188 sent: CacheAnchor::now(),
189 cached_before: cached_total(counters),
190 }
191 }
192}
193
194pub(super) fn has_tool_use(response: &Value) -> bool {
196 response["content"]
197 .as_array()
198 .is_some_and(|blocks| blocks.iter().any(|b| b["type"] == "tool_use"))
199}
200
201pub(super) struct Round<'a> {
205 pub auth: &'a Arc<RwLock<AuthState>>,
206 pub client: &'a reqwest::Client,
207 pub model: &'a str,
208 pub tools: &'a ToolRegistry,
209 pub system_prompt: &'a Option<String>,
210 pub thinking_budget: u32,
211 pub reasoning_level: agent_core::reasoning::ReasoningLevel,
212 pub messages: &'a [SharedMessage],
213 pub options: &'a ApiOptions,
214 pub telemetry_level: super::telemetry::TelemetryLevel,
215}
216
217pub(super) struct Turn<'a> {
219 pub usage: &'a Arc<super::budget::UsageCounters>,
220 pub max_cost_usd: Option<f64>,
221 pub tx: &'a mpsc::UnboundedSender<StreamEvent>,
222 pub cancel: &'a CancellationToken,
223}
224
225#[derive(Debug, Clone, PartialEq)]
227pub(super) enum Stop {
228 Done,
230 Expired,
232 Bound,
234 TurnCost,
236 Miss,
238 Failed(String),
240}
241
242impl std::fmt::Display for Stop {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 Stop::Done => f.write_str("round finished"),
246 Stop::Expired => f.write_str("cache presumed expired"),
247 Stop::Bound => f.write_str("next ping would pass the rewrite it saves"),
248 Stop::TurnCost => f.write_str("turn max_cost_usd reached"),
249 Stop::Miss => f.write_str("ping missed the cache"),
250 Stop::Failed(why) => write!(f, "ping failed: {why}"),
251 }
252 }
253}
254
255struct Job {
257 auth: Arc<RwLock<AuthState>>,
258 client: reqwest::Client,
259 model: String,
260 tools: ToolRegistry,
261 system_prompt: Option<String>,
262 thinking_budget: u32,
263 reasoning_level: agent_core::reasoning::ReasoningLevel,
264 messages: Vec<SharedMessage>,
267 options: ApiOptions,
268 telemetry_level: super::telemetry::TelemetryLevel,
269 anchor: CacheAnchor,
270 anchor_cell: Arc<super::keepwarm::CacheAnchorCell>,
271 ttl: CacheTtl,
272 interval: Duration,
273 prefix_tokens: u64,
274 max_tokens: u64,
275 usage: Arc<super::budget::UsageCounters>,
276 max_cost_usd: Option<f64>,
277 tx: mpsc::UnboundedSender<StreamEvent>,
278}
279
280struct Controls {
283 stop: CancellationToken,
284 abort: CancellationToken,
285 in_flight: Arc<AtomicBool>,
286}
287
288pub(super) struct ToolRoundKeepWarm {
292 stop: CancellationToken,
293 abort: CancellationToken,
294 in_flight: Arc<AtomicBool>,
295 grace: Duration,
296 #[cfg(test)]
297 hook: Option<Arc<tokio::sync::Notify>>,
298 handle: Option<tokio::task::JoinHandle<Stop>>,
299}
300
301impl Drop for ToolRoundKeepWarm {
302 fn drop(&mut self) {
303 self.stop.cancel();
304 self.abort.cancel();
305 }
306}
307
308impl ToolRoundKeepWarm {
309 pub(super) async fn start(
313 settings: &ToolKeepWarm,
314 mark: RoundMark,
315 round: Round<'_>,
316 turn: Turn<'_>,
317 ) -> Option<Self> {
318 if !super::auth::model_is_anthropic(round.model) {
319 return None;
320 }
321 let prefix_tokens = cached_total(turn.usage).saturating_sub(mark.cached_before);
322 if prefix_tokens == 0 {
323 return None;
324 }
325 let auth_type = round.auth.read().await.auth_type.clone();
326 let configured = round.options.cache_ttl.resolve_for_auth_type(&auth_type);
327 let ttl = honoured_ttl(
328 configured,
329 TtlEvidence {
330 downgraded: round
331 .options
332 .ttl_downgrade_notified
333 .load(std::sync::atomic::Ordering::Relaxed),
334 saw_1h: round
335 .options
336 .saw_1h_honored
337 .load(std::sync::atomic::Ordering::Relaxed),
338 ..Default::default()
339 },
340 );
341 let interval = settings
342 .interval_override
343 .unwrap_or_else(|| ping_interval(ttl));
344 let anchor_cell = round.options.cache_anchor.clone().unwrap_or_default();
347 let anchor = anchor_cell
348 .get()
349 .filter(|a| a.mono >= mark.sent.mono)
350 .unwrap_or(mark.sent);
351 let messages = round.messages.to_vec();
352 let options = ApiOptions {
353 memory_backend: None,
354 trace: settings.trace.clone(),
355 request_correlation: None,
356 suppress_stream_deltas: true,
357 usage_counters: Some(Arc::clone(turn.usage)),
358 cache_anchor: Some(Arc::clone(&anchor_cell)),
359 context_rollover: None,
360 keepwarm: None,
361 ..round.options.clone()
362 };
363 let max_tokens = match round.thinking_budget {
364 0 => super::keepwarm::KEEPWARM_OUTPUT_TOKENS,
365 budget => u64::from(budget) + super::keepwarm::KEEPWARM_OUTPUT_TOKENS,
366 };
367 let job = Job {
368 auth: Arc::clone(round.auth),
369 client: round.client.clone(),
370 model: round.model.to_string(),
371 tools: round.tools.clone(),
372 system_prompt: round.system_prompt.clone(),
373 thinking_budget: round.thinking_budget,
374 reasoning_level: round.reasoning_level,
375 messages,
376 options,
377 telemetry_level: round.telemetry_level,
378 anchor,
379 anchor_cell,
380 ttl,
381 interval,
382 prefix_tokens,
383 max_tokens,
384 usage: Arc::clone(turn.usage),
385 max_cost_usd: turn.max_cost_usd,
386 tx: turn.tx.clone(),
387 };
388 let controls = Controls {
389 stop: CancellationToken::new(),
390 abort: turn.cancel.child_token(),
391 in_flight: Arc::default(),
392 };
393 let (stop, abort, in_flight) = (
394 controls.stop.clone(),
395 controls.abort.clone(),
396 Arc::clone(&controls.in_flight),
397 );
398 let handle = tokio::spawn(job.run(controls));
399 Some(Self {
400 stop,
401 abort,
402 in_flight,
403 grace: settings.finish_grace(),
404 #[cfg(test)]
405 hook: settings.finish_hook.clone(),
406 handle: Some(handle),
407 })
408 }
409
410 pub(super) async fn finish(mut self) -> Stop {
414 self.stop.cancel();
415 let Some(mut handle) = self.handle.take() else {
416 return Stop::Done;
417 };
418 if self.in_flight.load(Ordering::Acquire) {
419 tracing::debug!(
420 event = "tool_round_keepwarm_drain",
421 "tool round done with a keep-warm ping in flight; letting it finish"
422 );
423 #[cfg(test)]
424 if let Some(hook) = &self.hook {
425 hook.notify_one();
426 }
427 }
428 match tokio::time::timeout(self.grace, &mut handle).await {
429 Ok(stop) => stop.unwrap_or(Stop::Done),
430 Err(_) => {
431 tracing::info!(
432 event = "tool_round_keepwarm_grace_expired",
433 grace_ms = self.grace.as_millis() as u64,
434 "tool-round keep-warm ping still in flight at the end of the round; aborting"
435 );
436 self.abort.cancel();
437 handle.await.unwrap_or(Stop::Done)
438 }
439 }
440 }
441}
442
443fn over_share(tokens: u64, prefix: u64) -> bool {
444 tokens.saturating_mul(100) > prefix.saturating_mul(SHARE_LIMIT_PERCENT)
445}
446
447impl Job {
448 fn elapsed(&self) -> Duration {
449 let mono = Instant::now().saturating_duration_since(self.anchor.mono);
450 let wall = SystemTime::now()
451 .duration_since(self.anchor.wall)
452 .unwrap_or_default();
453 mono.max(wall)
454 }
455
456 async fn wait_due(&self, c: &Controls) -> bool {
459 loop {
460 if c.stop.is_cancelled() || c.abort.is_cancelled() {
461 return false;
462 }
463 if self.elapsed() >= self.interval {
464 return true;
465 }
466 let due = self.anchor.mono + self.interval;
467 let wake = due.min(Instant::now() + WALL_POLL);
468 tokio::select! {
469 _ = c.stop.cancelled() => return false,
470 _ = c.abort.cancelled() => return false,
471 _ = tokio::time::sleep_until(wake) => {}
472 }
473 }
474 }
475
476 fn book_unmeasured(&self, bill: &super::keepwarm::PingBill) {
481 if bill.booking != PingBooking::WorstCase {
482 return;
483 }
484 let (input, output, read) = (PING_INPUT_ESTIMATE, self.max_tokens, self.prefix_tokens);
485 self.usage
486 .record_priced(&self.model, input, output, read, 0, None, None);
487 let _ = self.tx.send(StreamEvent::Session(SessionEvent::Usage {
488 input_tokens: input,
489 output_tokens: output,
490 cache_read_input_tokens: read,
491 cache_creation_input_tokens: 0,
492 cache_creation_5m: None,
493 cache_creation_1h: None,
494 model: Some(self.model.clone()),
495 }));
496 tracing::info!(
497 event = "tool_round_keepwarm_unmeasured",
498 model = %self.model,
499 cost_usd = bill.cost_usd,
500 "tool-round keep-warm ping dispatched without usage; booked at its worst case"
501 );
502 }
503
504 fn ping_cost(&self, output: u64) -> f64 {
505 request_cost_usd(
506 &self.model,
507 self.ttl,
508 self.prefix_tokens,
509 0,
510 PING_INPUT_ESTIMATE,
511 output,
512 )
513 }
514
515 async fn run(mut self, controls: Controls) -> Stop {
516 let stop = self.pings(&controls).await;
517 if stop != Stop::Done {
518 tracing::info!(
519 event = "tool_round_keepwarm_stop",
520 model = %self.model,
521 reason = %stop,
522 "tool-round keep-warm stopped"
523 );
524 }
525 stop
526 }
527
528 async fn pings(&mut self, c: &Controls) -> Stop {
529 let rewrite = rewrite_cost_usd(&self.model, self.ttl, self.prefix_tokens);
530 let worst = self.ping_cost(self.max_tokens);
531 let mut expected = self.ping_cost(ping_output_estimate(self.max_tokens));
532 let mut ledger = 0.0;
533 let mut retries = 0usize;
534 let mut pings = 0u32;
535 loop {
536 if !self.wait_due(c).await {
537 return Stop::Done;
538 }
539 if self.elapsed() + LATE_GUARD >= governing_ttl(self.ttl) {
542 return Stop::Expired;
543 }
544 if ledger + expected > rewrite {
545 return Stop::Bound;
546 }
547 if let Some(cap) = self.max_cost_usd.filter(|c| c.is_finite()) {
548 if self.usage.cost_usd() + worst > cap {
549 return Stop::TurnCost;
550 }
551 }
552 if let Err(e) = super::auth::AuthMethods::refresh_if_needed(
555 Arc::clone(&self.auth),
556 &self.client,
557 &self.options.credential_source,
558 &self.options.token_cache,
559 Some(&self.model),
560 super::auth::RefreshPoint::KeepWarm,
561 )
562 .await
563 {
564 return Stop::Failed(format!("auth refresh: {e}"));
565 }
566 if c.stop.is_cancelled() || c.abort.is_cancelled() {
568 return Stop::Done;
569 }
570 let tap = PingTap::default();
571 let sent_at = CacheAnchor::now();
572 c.in_flight.store(true, Ordering::Release);
573 let result = self.ping(&c.abort, &tap).await;
574 c.in_flight.store(false, Ordering::Release);
575 let bill = tap.bill(&self.model, worst);
576 ledger += bill.cost_usd;
577 self.book_unmeasured(&bill);
578 match result {
579 Ok(()) => {
580 pings += 1;
581 retries = 0;
582 let u = &bill.usage;
583 tracing::info!(
584 event = "tool_round_keepwarm_ping",
585 model = %self.model,
586 ping = pings,
587 cache_read = u.cache_read_tokens,
588 cache_write = u.cache_write_tokens,
589 input = u.input_tokens,
590 output = u.output_tokens,
591 cost_usd = bill.cost_usd,
592 ledger_usd = ledger,
593 rewrite_usd = rewrite,
594 "tool-round keep-warm ping"
595 );
596 if u.cache_read_tokens == 0
597 || over_share(u.cache_write_tokens, self.prefix_tokens)
598 || over_share(u.input_tokens, self.prefix_tokens)
599 {
600 return Stop::Miss;
601 }
602 expected = bill.cost_usd;
603 self.anchor = self
604 .anchor_cell
605 .get()
606 .filter(|a| a.mono >= sent_at.mono)
607 .unwrap_or(sent_at);
608 }
609 Err(PingFailure::Canceled) => {
610 tracing::info!(
611 event = "tool_round_keepwarm_aborted",
612 booking = ?bill.booking,
613 cost_usd = bill.cost_usd,
614 "tool-round keep-warm ping aborted in flight"
615 );
616 return Stop::Done;
617 }
618 Err(PingFailure::Transient(why)) if retries < BACKOFF.len() => {
619 tracing::debug!(%why, retry = retries + 1, "tool-round keep-warm ping: transient failure");
620 let delay = BACKOFF[retries];
621 retries += 1;
622 tokio::select! {
623 _ = c.stop.cancelled() => return Stop::Done,
624 _ = c.abort.cancelled() => return Stop::Done,
625 _ = tokio::time::sleep(delay) => {}
626 }
627 }
628 Err(failure) => return Stop::Failed(failure.to_string()),
629 }
630 }
631 }
632
633 async fn ping(&self, cancel: &CancellationToken, tap: &PingTap) -> Result<(), PingFailure> {
635 let options = ApiOptions {
636 keepwarm: Some(KeepWarmRequest {
637 mode: KeepWarmMode::Replay,
638 status: Arc::clone(&tap.status),
639 sent: Arc::clone(&tap.sent),
640 connect_failed: Arc::clone(&tap.connect_failed),
641 }),
642 ..self.options.clone()
643 };
644 let (tx, mut rx) = mpsc::unbounded_channel();
645 let call = ApiMethods::call_api_stream_inner(
646 &self.auth,
647 &self.client,
648 &self.model,
649 &self.tools,
650 &self.system_prompt,
651 self.thinking_budget,
652 self.reasoning_level,
653 &self.messages,
654 tx,
655 cancel,
656 0,
657 0,
658 &options,
659 self.telemetry_level,
660 );
661 let (result, _notices) =
662 super::keepwarm::drive_ping(call, &mut rx, tap, Some(&self.tx)).await;
663 match result {
664 Ok(value) if value.get("keepwarm_prefix").is_none() => Ok(()),
665 Ok(_) => Err(PingFailure::Fatal("ping was not sent".into())),
666 Err(e) => Err(super::keepwarm::classify_failure(
667 &e,
668 tap.status.load(std::sync::atomic::Ordering::Relaxed),
669 )),
670 }
671 }
672}