use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use serde_json::Value;
use tokio::sync::{mpsc, RwLock};
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use super::api::{ApiMethods, ApiOptions, KeepWarmMode, KeepWarmRequest};
use super::keepwarm::{CacheAnchor, PingBooking, PingFailure, PingTap};
use super::types::{AuthState, SessionEvent, StreamEvent};
use crate::core::config::CacheTtl;
use crate::session::cache_keepwarm::{
governing_ttl, honoured_ttl, ping_interval, ping_output_estimate, request_cost_usd,
rewrite_cost_usd, TtlEvidence, BACKOFF, LATE_GUARD, PING_INPUT_ESTIMATE, SHARE_LIMIT_PERCENT,
WALL_POLL,
};
use crate::{SharedMessage, ToolRegistry};
pub const FINISH_GRACE: Duration = Duration::from_secs(20);
#[derive(Debug, Clone, Default)]
pub struct ToolKeepWarm {
pub trace: super::trace::TraceContext,
pub interval_override: Option<Duration>,
#[cfg(test)]
pub finish_grace: Option<Duration>,
#[cfg(test)]
pub finish_hook: Option<Arc<tokio::sync::Notify>>,
}
impl ToolKeepWarm {
pub fn new(trace: super::trace::TraceContext, interval_override: Option<Duration>) -> Self {
Self {
trace,
interval_override,
#[cfg(test)]
finish_grace: None,
#[cfg(test)]
finish_hook: None,
}
}
fn finish_grace(&self) -> Duration {
#[cfg(test)]
if let Some(grace) = self.finish_grace {
return grace;
}
FINISH_GRACE
}
}
#[cfg(any(test, feature = "testing"))]
static INTERVAL_OVERRIDE_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub fn interval_override() -> Option<Duration> {
#[cfg(any(test, feature = "testing"))]
{
let ms = INTERVAL_OVERRIDE_MS.load(std::sync::atomic::Ordering::Relaxed);
if ms > 0 {
return Some(Duration::from_millis(ms));
}
}
None
}
#[cfg(any(test, feature = "testing"))]
pub mod testing {
use std::sync::atomic::Ordering;
use std::time::Duration;
#[must_use = "the override is cleared when the guard drops"]
pub struct IntervalOverride(());
impl IntervalOverride {
pub fn set(interval: Duration) -> Self {
let ms = u64::try_from(interval.as_millis())
.unwrap_or(u64::MAX)
.max(1);
super::INTERVAL_OVERRIDE_MS.store(ms, Ordering::Relaxed);
Self(())
}
}
impl Drop for IntervalOverride {
fn drop(&mut self) {
super::INTERVAL_OVERRIDE_MS.store(0, Ordering::Relaxed);
}
}
}
#[derive(Debug, Clone, Copy)]
pub(super) struct RoundMark {
sent: CacheAnchor,
cached_before: u64,
}
fn cached_total(counters: &super::budget::UsageCounters) -> u64 {
let (_, _, read, write) = counters.totals();
read.saturating_add(write)
}
impl RoundMark {
pub(super) fn new(counters: &super::budget::UsageCounters) -> Self {
Self {
sent: CacheAnchor::now(),
cached_before: cached_total(counters),
}
}
}
#[derive(Debug, Default)]
pub struct RoundSignal {
cached_tokens: AtomicU64,
response_in: AtomicBool,
changed: tokio::sync::Notify,
}
impl RoundSignal {
pub(super) fn message_started(&self, cached_tokens: u64) {
self.cached_tokens.store(cached_tokens, Ordering::Release);
self.changed.notify_one();
}
fn tools_running(&self, booked: u64) -> bool {
let _ = self
.cached_tokens
.compare_exchange(0, booked, Ordering::AcqRel, Ordering::Acquire);
self.response_in.store(true, Ordering::Release);
self.changed.notify_one();
self.cached_tokens() > 0
}
pub(super) fn cached_tokens(&self) -> u64 {
self.cached_tokens.load(Ordering::Acquire)
}
fn response_in(&self) -> bool {
self.response_in.load(Ordering::Acquire)
}
}
pub(super) fn has_tool_use(response: &Value) -> bool {
response["content"]
.as_array()
.is_some_and(|blocks| blocks.iter().any(|b| b["type"] == "tool_use"))
}
pub(super) struct Round<'a> {
pub auth: &'a Arc<RwLock<AuthState>>,
pub client: &'a reqwest::Client,
pub model: &'a str,
pub tools: &'a ToolRegistry,
pub system_prompt: &'a Option<String>,
pub thinking_budget: u32,
pub reasoning_level: agent_core::reasoning::ReasoningLevel,
pub messages: &'a [SharedMessage],
pub options: &'a ApiOptions,
pub telemetry_level: super::telemetry::TelemetryLevel,
}
pub(super) struct Turn<'a> {
pub usage: &'a Arc<super::budget::UsageCounters>,
pub max_cost_usd: Option<f64>,
pub tx: &'a mpsc::UnboundedSender<StreamEvent>,
pub cancel: &'a CancellationToken,
}
#[derive(Debug, Clone, PartialEq)]
pub(super) enum Stop {
Done,
Expired,
Bound,
TurnCost,
Miss,
Failed(String),
}
impl std::fmt::Display for Stop {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Stop::Done => f.write_str("round finished"),
Stop::Expired => f.write_str("cache presumed expired"),
Stop::Bound => f.write_str("next ping would pass the rewrite it saves"),
Stop::TurnCost => f.write_str("turn max_cost_usd reached"),
Stop::Miss => f.write_str("ping missed the cache"),
Stop::Failed(why) => write!(f, "ping failed: {why}"),
}
}
}
struct Job {
auth: Arc<RwLock<AuthState>>,
client: reqwest::Client,
model: String,
tools: ToolRegistry,
system_prompt: Option<String>,
thinking_budget: u32,
reasoning_level: agent_core::reasoning::ReasoningLevel,
messages: Vec<SharedMessage>,
options: ApiOptions,
telemetry_level: super::telemetry::TelemetryLevel,
floor: CacheAnchor,
anchor_cell: Arc<super::keepwarm::CacheAnchorCell>,
signal: Arc<RoundSignal>,
configured_ttl: CacheTtl,
interval_override: Option<Duration>,
max_tokens: u64,
usage: Arc<super::budget::UsageCounters>,
max_cost_usd: Option<f64>,
tx: mpsc::UnboundedSender<StreamEvent>,
}
struct Controls {
stop: CancellationToken,
abort: CancellationToken,
in_flight: Arc<AtomicBool>,
}
fn turn_admits(cap: Option<f64>, booked_usd: f64, unbooked_usd: f64, worst_usd: f64) -> bool {
match cap.filter(|c| c.is_finite()) {
Some(cap) => booked_usd + unbooked_usd + worst_usd <= cap,
None => true,
}
}
pub(super) struct ToolRoundKeepWarm {
signal: Arc<RoundSignal>,
cached_before: u64,
stop: CancellationToken,
abort: CancellationToken,
in_flight: Arc<AtomicBool>,
grace: Duration,
#[cfg(test)]
hook: Option<Arc<tokio::sync::Notify>>,
handle: Option<tokio::task::JoinHandle<Stop>>,
}
impl Drop for ToolRoundKeepWarm {
fn drop(&mut self) {
self.stop.cancel();
self.abort.cancel();
}
}
impl ToolRoundKeepWarm {
pub(super) async fn start(
settings: &ToolKeepWarm,
mark: RoundMark,
signal: &Arc<RoundSignal>,
round: Round<'_>,
turn: Turn<'_>,
) -> Option<Self> {
if !super::auth::model_is_anthropic(round.model) {
return None;
}
let auth_type = round.auth.read().await.auth_type.clone();
let configured_ttl = round.options.cache_ttl.resolve_for_auth_type(&auth_type);
let anchor_cell = round.options.cache_anchor.clone().unwrap_or_default();
let messages = round.messages.to_vec();
let options = ApiOptions {
tool_context: None,
trace: settings.trace.clone(),
request_correlation: None,
suppress_stream_deltas: true,
usage_counters: Some(Arc::clone(turn.usage)),
cache_anchor: Some(Arc::clone(&anchor_cell)),
context_rollover: None,
keepwarm: None,
round_signal: None,
..round.options.clone()
};
let max_tokens = match round.thinking_budget {
0 => super::keepwarm::KEEPWARM_OUTPUT_TOKENS,
budget => u64::from(budget) + super::keepwarm::KEEPWARM_OUTPUT_TOKENS,
};
let job = Job {
auth: Arc::clone(round.auth),
client: round.client.clone(),
model: round.model.to_string(),
tools: round.tools.clone(),
system_prompt: round.system_prompt.clone(),
thinking_budget: round.thinking_budget,
reasoning_level: round.reasoning_level,
messages,
options,
telemetry_level: round.telemetry_level,
floor: mark.sent,
anchor_cell,
signal: Arc::clone(signal),
configured_ttl,
interval_override: settings.interval_override,
max_tokens,
usage: Arc::clone(turn.usage),
max_cost_usd: turn.max_cost_usd,
tx: turn.tx.clone(),
};
let controls = Controls {
stop: CancellationToken::new(),
abort: turn.cancel.child_token(),
in_flight: Arc::default(),
};
let (stop, abort, in_flight) = (
controls.stop.clone(),
controls.abort.clone(),
Arc::clone(&controls.in_flight),
);
let handle = tokio::spawn(job.run(controls));
Some(Self {
signal: Arc::clone(signal),
cached_before: mark.cached_before,
stop,
abort,
in_flight,
grace: settings.finish_grace(),
#[cfg(test)]
hook: settings.finish_hook.clone(),
handle: Some(handle),
})
}
pub(super) fn tools_running(&self, usage: &super::budget::UsageCounters) -> bool {
self.signal
.tools_running(cached_total(usage).saturating_sub(self.cached_before))
}
pub(super) async fn abandon(mut self) -> Stop {
self.stop.cancel();
self.abort.cancel();
match self.handle.take() {
Some(handle) => handle.await.unwrap_or(Stop::Done),
None => Stop::Done,
}
}
pub(super) async fn finish(mut self) -> Stop {
self.stop.cancel();
let Some(mut handle) = self.handle.take() else {
return Stop::Done;
};
if self.in_flight.load(Ordering::Acquire) {
tracing::debug!(
event = "tool_round_keepwarm_drain",
"tool round done with a keep-warm ping in flight; letting it finish"
);
#[cfg(test)]
if let Some(hook) = &self.hook {
hook.notify_one();
}
}
match tokio::time::timeout(self.grace, &mut handle).await {
Ok(stop) => stop.unwrap_or(Stop::Done),
Err(_) => {
tracing::info!(
event = "tool_round_keepwarm_grace_expired",
grace_ms = self.grace.as_millis() as u64,
"tool-round keep-warm ping still in flight at the end of the round; aborting"
);
self.abort.cancel();
handle.await.unwrap_or(Stop::Done)
}
}
}
}
fn over_share(tokens: u64, prefix: u64) -> bool {
tokens.saturating_mul(100) > prefix.saturating_mul(SHARE_LIMIT_PERCENT)
}
impl Job {
fn anchor(&self) -> CacheAnchor {
self.anchor_cell
.get()
.filter(|a| a.mono >= self.floor.mono)
.unwrap_or(self.floor)
}
fn elapsed_since(anchor: CacheAnchor) -> Duration {
let mono = Instant::now().saturating_duration_since(anchor.mono);
let wall = SystemTime::now()
.duration_since(anchor.wall)
.unwrap_or_default();
mono.max(wall)
}
fn elapsed(&self) -> Duration {
Self::elapsed_since(self.anchor())
}
fn ttl(&self) -> CacheTtl {
honoured_ttl(
self.configured_ttl,
TtlEvidence {
downgraded: self
.options
.ttl_downgrade_notified
.load(std::sync::atomic::Ordering::Relaxed),
saw_1h: self
.options
.saw_1h_honored
.load(std::sync::atomic::Ordering::Relaxed),
..Default::default()
},
)
}
fn interval(&self, ttl: CacheTtl) -> Duration {
self.interval_override
.unwrap_or_else(|| ping_interval(ttl))
}
async fn wait_due(&self, c: &Controls) -> bool {
loop {
if c.stop.is_cancelled() || c.abort.is_cancelled() {
return false;
}
let wake = if self.signal.cached_tokens() > 0 {
let anchor = self.anchor();
let interval = self.interval(self.ttl());
if Self::elapsed_since(anchor) >= interval {
return true;
}
(anchor.mono + interval).min(Instant::now() + WALL_POLL)
} else {
Instant::now() + WALL_POLL
};
tokio::select! {
_ = c.stop.cancelled() => return false,
_ = c.abort.cancelled() => return false,
_ = self.signal.changed.notified() => {}
_ = tokio::time::sleep_until(wake) => {}
}
}
}
fn book_unmeasured(&self, bill: &super::keepwarm::PingBill, prefix_tokens: u64) {
if bill.booking != PingBooking::WorstCase {
return;
}
let (input, output, read) = (PING_INPUT_ESTIMATE, self.max_tokens, prefix_tokens);
self.usage
.record_priced(&self.model, input, output, read, 0, None, None);
let _ = self.tx.send(StreamEvent::Session(SessionEvent::Usage {
input_tokens: input,
output_tokens: output,
cache_read_input_tokens: read,
cache_creation_input_tokens: 0,
cache_creation_5m: None,
cache_creation_1h: None,
model: Some(self.model.clone()),
}));
tracing::info!(
event = "tool_round_keepwarm_unmeasured",
model = %self.model,
cost_usd = bill.cost_usd,
"tool-round keep-warm ping dispatched without usage; booked at its worst case"
);
}
fn ping_cost(&self, ttl: CacheTtl, prefix_tokens: u64, output: u64) -> f64 {
request_cost_usd(
&self.model,
ttl,
prefix_tokens,
0,
PING_INPUT_ESTIMATE,
output,
)
}
async fn run(mut self, controls: Controls) -> Stop {
let stop = self.pings(&controls).await;
if stop != Stop::Done {
tracing::info!(
event = "tool_round_keepwarm_stop",
model = %self.model,
reason = %stop,
"tool-round keep-warm stopped"
);
}
stop
}
async fn pings(&mut self, c: &Controls) -> Stop {
let mut measured: Option<f64> = None;
let mut ledger = 0.0;
let mut retries = 0usize;
let mut pings = 0u32;
loop {
if !self.wait_due(c).await {
return Stop::Done;
}
let ttl = self.ttl();
if self.elapsed() + LATE_GUARD >= governing_ttl(ttl) {
return Stop::Expired;
}
let prefix_tokens = self.signal.cached_tokens();
let rewrite = rewrite_cost_usd(&self.model, ttl, prefix_tokens);
let worst = self.ping_cost(ttl, prefix_tokens, self.max_tokens);
let expected = measured.unwrap_or_else(|| {
self.ping_cost(ttl, prefix_tokens, ping_output_estimate(self.max_tokens))
});
if ledger + expected > rewrite {
return Stop::Bound;
}
let mid_stream = !self.signal.response_in();
let unbooked = if mid_stream { rewrite } else { 0.0 };
if !turn_admits(self.max_cost_usd, self.usage.cost_usd(), unbooked, worst) {
return Stop::TurnCost;
}
if let Err(e) = super::auth::AuthMethods::refresh_if_needed(
Arc::clone(&self.auth),
&self.client,
&self.options.credential_source,
&self.options.token_cache,
Some(&self.model),
super::auth::RefreshPoint::KeepWarm,
)
.await
{
return Stop::Failed(format!("auth refresh: {e}"));
}
if c.stop.is_cancelled() || c.abort.is_cancelled() {
return Stop::Done;
}
let tap = PingTap::default();
let sent_at = CacheAnchor::now();
c.in_flight.store(true, Ordering::Release);
let result = self.ping(&c.abort, &tap).await;
c.in_flight.store(false, Ordering::Release);
let bill = tap.bill(&self.model, worst);
ledger += bill.cost_usd;
self.book_unmeasured(&bill, prefix_tokens);
match result {
Ok(()) => {
pings += 1;
retries = 0;
let u = &bill.usage;
tracing::info!(
event = "tool_round_keepwarm_ping",
model = %self.model,
ping = pings,
mid_stream,
cache_read = u.cache_read_tokens,
cache_write = u.cache_write_tokens,
input = u.input_tokens,
output = u.output_tokens,
cost_usd = bill.cost_usd,
ledger_usd = ledger,
rewrite_usd = rewrite,
"tool-round keep-warm ping"
);
if u.cache_read_tokens == 0
|| over_share(u.cache_write_tokens, prefix_tokens)
|| over_share(u.input_tokens, prefix_tokens)
{
return Stop::Miss;
}
measured = Some(bill.cost_usd);
self.floor = sent_at;
}
Err(PingFailure::Canceled) => {
tracing::info!(
event = "tool_round_keepwarm_aborted",
booking = ?bill.booking,
cost_usd = bill.cost_usd,
"tool-round keep-warm ping aborted in flight"
);
return Stop::Done;
}
Err(PingFailure::Transient(why)) if retries < BACKOFF.len() => {
tracing::debug!(%why, retry = retries + 1, "tool-round keep-warm ping: transient failure");
let delay = BACKOFF[retries];
retries += 1;
tokio::select! {
_ = c.stop.cancelled() => return Stop::Done,
_ = c.abort.cancelled() => return Stop::Done,
_ = tokio::time::sleep(delay) => {}
}
}
Err(failure) => return Stop::Failed(failure.to_string()),
}
}
}
async fn ping(&self, cancel: &CancellationToken, tap: &PingTap) -> Result<(), PingFailure> {
let options = ApiOptions {
keepwarm: Some(KeepWarmRequest {
mode: KeepWarmMode::Replay,
status: Arc::clone(&tap.status),
sent: Arc::clone(&tap.sent),
connect_failed: Arc::clone(&tap.connect_failed),
}),
..self.options.clone()
};
let (tx, mut rx) = mpsc::unbounded_channel();
let call = ApiMethods::call_api_stream_inner(
&self.auth,
&self.client,
&self.model,
&self.tools,
&self.system_prompt,
self.thinking_budget,
self.reasoning_level,
&self.messages,
tx,
cancel,
0,
0,
&options,
self.telemetry_level,
);
let (result, _notices) =
super::keepwarm::drive_ping(call, &mut rx, tap, Some(&self.tx)).await;
match result {
Ok(value) if value.get("keepwarm_prefix").is_none() => Ok(()),
Ok(_) => Err(PingFailure::Fatal("ping was not sent".into())),
Err(e) => Err(super::keepwarm::classify_failure(
&e,
tap.status.load(std::sync::atomic::Ordering::Relaxed),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn the_reported_prefix_reaches_the_task() {
let signal = RoundSignal::default();
assert_eq!(signal.cached_tokens(), 0, "nothing reported: nothing to keep warm");
assert!(!signal.response_in());
signal.message_started(120_000);
assert_eq!(signal.cached_tokens(), 120_000);
assert!(!signal.response_in(), "still streaming");
tokio::time::timeout(Duration::from_secs(5), signal.changed.notified())
.await
.expect("the wake-up is kept for a task that starts waiting later");
signal.message_started(120_500);
assert_eq!(signal.cached_tokens(), 120_500, "the retry's own prefix");
}
#[test]
fn the_response_being_in_settles_the_round() {
let signal = RoundSignal::default();
signal.message_started(120_000);
assert!(signal.tools_running(999), "a prefix to keep warm");
assert!(signal.response_in());
assert_eq!(signal.cached_tokens(), 120_000, "the reported prefix wins");
let unreported = RoundSignal::default();
assert!(unreported.tools_running(80_000));
assert_eq!(unreported.cached_tokens(), 80_000, "the turn's counters");
let uncached = RoundSignal::default();
uncached.message_started(0);
assert!(!uncached.tools_running(0), "nothing cached: nothing to keep");
assert_eq!(uncached.cached_tokens(), 0);
}
#[test]
fn the_cost_cap_counts_the_unbooked_round() {
assert!(turn_admits(None, 9.0, 9.0, 9.0));
assert!(turn_admits(Some(f64::INFINITY), 9.0, 9.0, 9.0));
assert!(turn_admits(Some(f64::NAN), 9.0, 9.0, 9.0));
assert!(turn_admits(Some(1.0), 0.70, 0.0, 0.25));
assert!(!turn_admits(Some(1.0), 0.70, 0.0, 0.35));
assert!(turn_admits(Some(1.0), 0.10, 0.60, 0.25));
assert!(!turn_admits(Some(1.0), 0.20, 0.60, 0.25));
}
}