1use std::sync::atomic::{AtomicU16, Ordering};
42use std::sync::Arc;
43
44use serde_json::{json, Value};
45use tokio::sync::mpsc;
46use tokio_util::sync::CancellationToken;
47
48use super::api::{ApiMethods, ApiOptions, KeepWarmMode, KeepWarmRequest};
49use super::helpers::HelperMethods;
50use super::{Runtime, SessionEvent, StreamEvent};
51use crate::{Result, RuntimeError, SharedMessage};
52
53pub const KEEPWARM_OUTPUT_TOKENS: u64 = 1;
56
57pub const KEEPWARM_PING_TEXT: &str = "[automated prompt-cache keep-warm ping, not from the user] \
61 This needs no reasoning. Reply with exactly: ok";
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct CacheAnchor {
69 pub mono: tokio::time::Instant,
71 pub wall: std::time::SystemTime,
74}
75
76impl CacheAnchor {
77 pub fn now() -> Self {
78 Self {
79 mono: tokio::time::Instant::now(),
80 wall: std::time::SystemTime::now(),
81 }
82 }
83}
84
85#[derive(Debug, Default)]
89pub struct CacheAnchorCell(std::sync::Mutex<Option<CacheAnchor>>);
90
91impl CacheAnchorCell {
92 pub fn stamp(&self, at: CacheAnchor) {
93 let mut slot = self
94 .0
95 .lock()
96 .unwrap_or_else(std::sync::PoisonError::into_inner);
97 if slot.map_or(true, |prev| prev.mono <= at.mono) {
98 *slot = Some(at);
99 }
100 }
101
102 pub fn get(&self) -> Option<CacheAnchor> {
103 *self
104 .0
105 .lock()
106 .unwrap_or_else(std::sync::PoisonError::into_inner)
107 }
108}
109
110#[derive(Debug, Clone, Default, PartialEq)]
112pub struct KeepWarmUsage {
113 pub input_tokens: u64,
114 pub output_tokens: u64,
115 pub cache_read_tokens: u64,
116 pub cache_write_tokens: u64,
117 pub cache_write_5m: Option<u64>,
118 pub cache_write_1h: Option<u64>,
119 pub model: Option<String>,
121}
122
123impl KeepWarmUsage {
124 pub(crate) fn add(&mut self, event: &StreamEvent) {
125 if let StreamEvent::Session(SessionEvent::Usage {
126 input_tokens,
127 output_tokens,
128 cache_read_input_tokens,
129 cache_creation_input_tokens,
130 cache_creation_5m,
131 cache_creation_1h,
132 model,
133 }) = event
134 {
135 self.input_tokens += input_tokens;
136 self.output_tokens += output_tokens;
137 self.cache_read_tokens += cache_read_input_tokens;
138 self.cache_write_tokens += cache_creation_input_tokens;
139 if let Some(n) = cache_creation_5m {
140 *self.cache_write_5m.get_or_insert(0) += n;
141 }
142 if let Some(n) = cache_creation_1h {
143 *self.cache_write_1h.get_or_insert(0) += n;
144 }
145 if model.is_some() {
146 self.model = model.clone();
147 }
148 }
149 }
150
151 pub fn cost_usd(&self, fallback_model: &str) -> f64 {
154 crate::pricing::calculate_cost_optional_split(
155 self.model.as_deref().unwrap_or(fallback_model),
156 self.input_tokens,
157 self.output_tokens,
158 self.cache_read_tokens,
159 self.cache_write_tokens,
160 self.cache_write_5m,
161 self.cache_write_1h,
162 )
163 }
164}
165
166#[derive(Debug, Clone, PartialEq)]
168pub enum PingOutcome {
169 Sent,
171 PrefixChanged { expected: u64, actual: u64 },
174}
175
176#[derive(Debug, Clone, PartialEq)]
180pub struct PingReport {
181 pub result: std::result::Result<PingOutcome, PingFailure>,
182 pub usage: KeepWarmUsage,
183 pub notices: Vec<String>,
187}
188
189#[derive(Debug, Clone, Default)]
197pub struct PingTap {
198 pub sent: Arc<std::sync::atomic::AtomicBool>,
199 pub connect_failed: Arc<std::sync::atomic::AtomicBool>,
200 pub status: Arc<AtomicU16>,
201 pub usage: Arc<std::sync::Mutex<KeepWarmUsage>>,
202}
203
204impl PingTap {
205 pub fn was_sent(&self) -> bool {
206 self.sent.load(Ordering::Relaxed)
207 }
208
209 pub fn usage(&self) -> KeepWarmUsage {
210 self.usage
211 .lock()
212 .unwrap_or_else(std::sync::PoisonError::into_inner)
213 .clone()
214 }
215
216 pub fn bill(&self, model: &str, worst_usd: f64) -> PingBill {
221 let usage = self.usage();
222 let booking = ping_booking(
223 usage != KeepWarmUsage::default(),
224 self.was_sent(),
225 self.connect_failed.load(Ordering::Relaxed),
226 self.status.load(Ordering::Relaxed),
227 );
228 let cost_usd = match booking {
229 PingBooking::Measured => usage.cost_usd(model),
230 PingBooking::WorstCase => worst_usd,
231 PingBooking::Nothing => 0.0,
232 };
233 PingBill {
234 booking,
235 usage,
236 cost_usd,
237 }
238 }
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum PingBooking {
244 Measured,
246 WorstCase,
249 Nothing,
255}
256
257pub fn ping_booking(
262 usage_arrived: bool,
263 sent: bool,
264 connect_failed: bool,
265 http_status: u16,
266) -> PingBooking {
267 if usage_arrived {
268 PingBooking::Measured
269 } else if http_status >= 400 {
270 PingBooking::Nothing
271 } else if sent && !(connect_failed && http_status == 0) {
272 PingBooking::WorstCase
273 } else {
274 PingBooking::Nothing
275 }
276}
277
278#[derive(Debug, Clone, PartialEq)]
280pub struct PingBill {
281 pub booking: PingBooking,
282 pub usage: KeepWarmUsage,
283 pub cost_usd: f64,
284}
285
286#[derive(Debug, Clone, PartialEq)]
288pub struct KeepWarmProbe {
289 pub fingerprint: u64,
291 pub max_tokens: u64,
294 pub context_blocks: Option<String>,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq)]
303pub enum PingFailure {
304 Transient(String),
306 Fatal(String),
308 Canceled,
310}
311
312impl std::fmt::Display for PingFailure {
313 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 match self {
315 PingFailure::Transient(m) => write!(f, "transient: {m}"),
316 PingFailure::Fatal(m) => write!(f, "{m}"),
317 PingFailure::Canceled => f.write_str("canceled"),
318 }
319 }
320}
321
322pub(crate) fn classify_failure(err: &RuntimeError, status: u16) -> PingFailure {
325 if matches!(err, RuntimeError::Canceled) {
326 return PingFailure::Canceled;
327 }
328 if (400..500).contains(&status) && status != 429 {
329 return PingFailure::Fatal(format!("HTTP {status}"));
330 }
331 match err {
332 RuntimeError::Api(_) | RuntimeError::ApiStatus(_) | RuntimeError::Timeout => {
333 PingFailure::Transient(if status == 0 {
334 "network".to_string()
335 } else {
336 format!("HTTP {status}")
337 })
338 }
339 other => PingFailure::Fatal(other.to_string()),
340 }
341}
342
343fn tail_is_markable(last: &Value) -> bool {
347 if last["role"] != "assistant" {
348 return false;
349 }
350 match last["content"].as_array() {
351 Some(blocks) => blocks.last().is_some_and(|b| {
352 b["type"] == "text" && b["text"].as_str().is_some_and(|t| !t.is_empty())
353 }),
354 None => last["content"].as_str().is_some_and(|t| !t.is_empty()),
355 }
356}
357
358pub fn history_ready(history: &[SharedMessage]) -> bool {
361 history.last().is_some_and(|m| tail_is_markable(m))
362}
363
364pub fn reply_prefix_tokens(history: &[SharedMessage]) -> u64 {
371 let Some(last) = history.last() else {
372 return 0;
373 };
374 let kept = match last["content"].as_array() {
375 Some(blocks) => Value::Array(
376 blocks
377 .iter()
378 .filter(|b| !matches!(b["type"].as_str(), Some("thinking" | "redacted_thinking")))
379 .cloned()
380 .collect(),
381 ),
382 None => last["content"].clone(),
383 };
384 super::context::conservative_token_estimate(&kept.to_string())
385}
386
387impl Runtime {
388 pub fn cache_keepwarm_ineligibility(&self) -> Option<String> {
392 if !super::auth::model_is_anthropic(&self.model) {
393 return Some(format!(
394 "model {} is not on the native Anthropic transport",
395 self.model
396 ));
397 }
398 None
399 }
400
401 pub fn cache_anchor(&self) -> Option<CacheAnchor> {
403 self.cache_anchor.get()
404 }
405
406 pub fn cache_ttl_downgraded(&self) -> bool {
409 self.ttl_downgrade_notified.load(Ordering::Relaxed)
410 }
411
412 pub fn cache_1h_honoured(&self) -> bool {
414 self.saw_1h_honored.load(Ordering::Relaxed)
415 }
416
417 pub async fn cache_keepwarm_probe(&self, history: &[SharedMessage]) -> Result<KeepWarmProbe> {
423 let cancel = CancellationToken::new();
424 let tap = PingTap::default();
425 let (value, _) = self
426 .keepwarm_request(history, &cancel, KeepWarmMode::Probe, &tap)
427 .await;
428 let value = value?;
429 let fingerprint = value["keepwarm_prefix"].as_u64().ok_or_else(|| {
430 RuntimeError::Session("keep-warm probe produced no fingerprint".into())
431 })?;
432 Ok(KeepWarmProbe {
433 fingerprint,
434 max_tokens: value["keepwarm_max_tokens"].as_u64().unwrap_or(0),
435 context_blocks: value["keepwarm_context_blocks"]
436 .as_str()
437 .map(str::to_string),
438 })
439 }
440
441 pub async fn cache_keepwarm_ping(
447 &self,
448 history: &[SharedMessage],
449 expect: u64,
450 cancel: &CancellationToken,
451 tap: &PingTap,
452 ) -> PingReport {
453 let (value, notices) = self
454 .keepwarm_request(history, cancel, KeepWarmMode::Send { expect }, tap)
455 .await;
456 let result = match value {
457 Ok(value) => match value.get("keepwarm_prefix").and_then(Value::as_u64) {
458 Some(actual) => Ok(PingOutcome::PrefixChanged {
459 expected: expect,
460 actual,
461 }),
462 None => Ok(PingOutcome::Sent),
463 },
464 Err(e) => Err(classify_failure(&e, tap.status.load(Ordering::Relaxed))),
465 };
466 PingReport {
467 result,
468 usage: tap.usage(),
469 notices,
470 }
471 }
472
473 async fn keepwarm_request(
474 &self,
475 history: &[SharedMessage],
476 cancel: &CancellationToken,
477 mode: KeepWarmMode,
478 tap: &PingTap,
479 ) -> (Result<Value>, Vec<String>) {
480 match self
481 .keepwarm_request_inner(history, cancel, mode, tap)
482 .await
483 {
484 Ok(r) => r,
485 Err(e) => (Err(e), Vec::new()),
486 }
487 }
488
489 async fn keepwarm_request_inner(
492 &self,
493 history: &[SharedMessage],
494 cancel: &CancellationToken,
495 mode: KeepWarmMode,
496 tap: &PingTap,
497 ) -> Result<(Result<Value>, Vec<String>)> {
498 if let Some(reason) = self.cache_keepwarm_ineligibility() {
499 return Err(RuntimeError::Config(format!(
500 "cache keep-warm skipped: {reason}"
501 )));
502 }
503 if !history_ready(history) {
504 return Err(RuntimeError::Config(
505 "cache keep-warm skipped: history does not end with a finished assistant reply"
506 .into(),
507 ));
508 }
509 let (durability_blocked, context_enabled) = {
510 let state = self
511 .continuation
512 .lock()
513 .unwrap_or_else(std::sync::PoisonError::into_inner);
514 (state.durability_blocked, state.enabled())
515 };
516 if durability_blocked {
517 return Err(RuntimeError::Session(
518 "cache keep-warm skipped: context head save is unresolved".into(),
519 ));
520 }
521
522 self.validate_request_preflight().await?;
525 let anthropic_execution_plan = self.authorized_anthropic_plan().await?;
526 if matches!(mode, KeepWarmMode::Send { .. }) {
527 self.refresh_if_needed().await?;
528 }
529
530 let system_prompt = self.effective_system_prompt().await;
532 let system_prompt = if context_enabled {
533 Some(format!(
534 "{}\n\n{}",
535 system_prompt.as_deref().unwrap_or_default(),
536 super::continuation::GUIDANCE
537 ))
538 } else {
539 system_prompt
540 };
541 let session_injection = match self.session_id.as_deref() {
542 Some(id) => self.hook_bus.session_injection_for(id).await,
543 None => None,
544 };
545 let system_prompt = match session_injection {
546 Some(content) => Some(super::stream::wrap_extension_context(
547 system_prompt.as_deref().unwrap_or_default(),
548 &content,
549 )),
550 None => system_prompt,
551 };
552
553 let mut request: Vec<SharedMessage> = history.to_vec();
556 request.push(Arc::new(
557 json!({"role": "user", "content": KEEPWARM_PING_TEXT}),
558 ));
559 let capped;
560 let request_messages: &[SharedMessage] = match super::stream::cap_history_image_bytes(
561 &request,
562 super::stream::HISTORY_IMAGE_BYTE_CAP,
563 ) {
564 Some(c) => {
565 capped = c;
566 &capped
567 }
568 None => &request,
569 };
570
571 let tools_snapshot = self.tools.read().await.clone();
576 let (request_tools_schema, anthropic_tool_split) = if self.progressive_tool_disclosure {
577 let fresh = super::continuation::context_tool_set(
578 self.host_tool_session.clone(),
579 tools_snapshot.catalog(),
580 true,
581 context_enabled,
582 );
583 let retained = self
586 .retained_tool_set
587 .lock()
588 .unwrap_or_else(std::sync::PoisonError::into_inner)
589 .clone();
590 let existing = retained.as_ref().map(|shared| {
591 shared
592 .read()
593 .unwrap_or_else(std::sync::PoisonError::into_inner)
594 });
595 let (set, _, _) = super::stream::select_turn_start_tool_set(
596 existing.as_deref(),
597 fresh,
598 tools_snapshot.catalog(),
599 true,
600 );
601 drop(existing);
602 let schema = tools_snapshot.session_tools_schema(&set).schema;
603 let split = self.deferred_tool_loading.then(|| {
604 let referenced = HelperMethods::referenced_tool_ids(request_messages);
605 Arc::new(tools_snapshot.deferred_tool_split(&set, &referenced))
606 });
607 (Some(Arc::new(schema)), split)
608 } else {
609 (None, None)
610 };
611
612 let options = ApiOptions {
613 memory_backend: None,
614 use_1m_context: self.context_window_override == Some(1_000_000),
615 cache_ttl: self.cache_ttl,
616 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
620 saw_1h_honored: self.saw_1h_honored.clone(),
621 credential_source: self.credential_source.clone(),
622 token_cache: self.token_cache.clone(),
623 anthropic_base_url: None,
624 anthropic_execution_plan,
625 codex_request_role: self.codex_request_role(),
626 trace: self.trace_ctx.clone(),
628 request_correlation: None,
629 suppress_stream_deltas: true,
630 telemetry: self.telemetry_writer.clone(),
631 tool_session_id: Some(self.host_tool_session.clone()),
632 session_tool_set: None,
633 request_tools_schema: request_tools_schema.clone(),
634 anthropic_tool_split,
635 usage_counters: None,
636 cache_anchor: Some(Arc::clone(&self.cache_anchor)),
639 keepwarm: Some(KeepWarmRequest {
640 mode,
641 status: Arc::clone(&tap.status),
642 sent: Arc::clone(&tap.sent),
643 connect_failed: Arc::clone(&tap.connect_failed),
644 }),
645 };
646
647 let (tx, mut rx) = mpsc::unbounded_channel();
648 let call = ApiMethods::call_api_stream_inner(
649 &self.auth,
650 &self.client,
651 &self.model,
652 &tools_snapshot,
653 &system_prompt,
654 self.thinking_budget,
655 self.reasoning_level(),
656 request_messages,
657 tx,
658 cancel,
659 0,
660 0,
661 &options,
662 self.telemetry_level,
663 );
664 tokio::pin!(call);
665 let mut notices = Vec::new();
668 let mut on_event = |event: StreamEvent| {
669 if let StreamEvent::Session(SessionEvent::Notice(n)) = &event {
670 notices.push(n.clone());
671 }
672 tap.usage
673 .lock()
674 .unwrap_or_else(std::sync::PoisonError::into_inner)
675 .add(&event);
676 };
677 let mut result = loop {
678 tokio::select! {
679 r = &mut call => break r,
680 Some(event) = rx.recv() => on_event(event),
681 }
682 };
683 while let Ok(event) = rx.try_recv() {
684 on_event(event);
685 }
686 if let (Ok(value), KeepWarmMode::Probe) = (&mut result, mode) {
688 if context_enabled {
689 if let Some(why) = self.keepwarm_context_blocks(
690 history,
691 system_prompt.as_deref(),
692 request_tools_schema.as_deref().map(|s| s.as_slice()),
693 &tools_snapshot,
694 ) {
695 value["keepwarm_context_blocks"] = Value::String(why);
696 }
697 }
698 }
699 Ok((result, notices))
700 }
701
702 fn keepwarm_context_blocks(
710 &self,
711 history: &[SharedMessage],
712 system_prompt: Option<&str>,
713 projected_schema: Option<&[Value]>,
714 tools_snapshot: &crate::ToolRegistry,
715 ) -> Option<String> {
716 use agent_core::core::context_policy::{assess_context, ContextAction};
717 let mut next: Vec<SharedMessage> = history.to_vec();
718 next.push(Arc::new(json!({"role": "user", "content": ""})));
719 let capped =
720 super::stream::cap_history_image_bytes(&next, super::stream::HISTORY_IMAGE_BYTE_CAP);
721 let request_messages: &[SharedMessage] = capped.as_deref().unwrap_or(&next[..]);
722 let fallback_schema;
723 let schema = match projected_schema {
724 Some(s) => s,
725 None => {
726 fallback_schema = tools_snapshot.tools_schema();
727 fallback_schema.as_slice()
728 }
729 };
730 let (assessment, budget) = super::stream::pre_request_context_budget(
731 &self.model,
732 self.context_window(),
733 system_prompt,
734 schema,
735 request_messages,
736 self.thinking_budget as u64,
737 self.max_tool_output as u64,
738 );
739 let state = self
740 .continuation
741 .lock()
742 .unwrap_or_else(std::sync::PoisonError::into_inner);
743 let decision = assess_context(&state.config, &state.policy, budget);
744 match decision.action {
745 ContextAction::Rollover => Some(format!(
746 "the next turn would roll the context over ({} tokens used)",
747 assessment.used_tokens()
748 )),
749 ContextAction::HardStop => Some(format!(
750 "the next turn would hit the context hard limit ({} tokens used)",
751 assessment.used_tokens()
752 )),
753 _ => None,
754 }
755 }
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761 use crate::core::config::CacheTtl;
762 use crate::extensions::hooks::events::{HookEvent, HookKind, HookResult};
763 use crate::extensions::permissions::{Permission, PermissionSet};
764 use agent_core::reasoning::ReasoningLevel;
765 use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Router};
766 use futures::StreamExt;
767 use std::sync::atomic::AtomicUsize;
768 use std::sync::Mutex;
769
770 const SSE_HI: &str = concat!(
771 "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_kw\",\"type\":\"message\",",
772 "\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-4-7\",\"stop_reason\":null,",
773 "\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0,",
774 "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}}\n\n",
775 "data: {\"type\":\"content_block_start\",\"index\":0,",
776 "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
777 "data: {\"type\":\"content_block_delta\",\"index\":0,",
778 "\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n",
779 "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
780 "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",",
781 "\"stop_sequence\":null},\"usage\":{\"input_tokens\":10,\"output_tokens\":1,",
782 "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}\n\n",
783 "data: {\"type\":\"message_stop\"}\n\n",
784 );
785
786 const SSE_USAGE_THEN_ERROR: &str = concat!(
788 "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_kw\",\"type\":\"message\",",
789 "\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-4-7\",\"stop_reason\":null,",
790 "\"stop_sequence\":null,\"usage\":{\"input_tokens\":12,\"output_tokens\":0,",
791 "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5000}}}\n\n",
792 "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":null,",
793 "\"stop_sequence\":null},\"usage\":{\"input_tokens\":12,\"output_tokens\":1,",
794 "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5000}}\n\n",
795 "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",",
796 "\"message\":\"synthetic\"}}\n\n",
797 );
798
799 #[derive(Clone)]
800 struct Mock {
801 bodies: Arc<Mutex<Vec<String>>>,
802 status: u16,
804 sse: &'static str,
805 }
806
807 async fn spawn_mock() -> (String, Mock) {
808 spawn_mock_full(200, SSE_HI).await
809 }
810
811 async fn spawn_mock_with(status: u16) -> (String, Mock) {
812 spawn_mock_full(status, SSE_HI).await
813 }
814
815 async fn spawn_mock_full(status: u16, sse: &'static str) -> (String, Mock) {
816 let mock = Mock {
817 bodies: Arc::default(),
818 status,
819 sse,
820 };
821 let app = Router::new()
822 .route(
823 "/v1/messages",
824 post(|State(m): State<Mock>, body: String| async move {
825 m.bodies.lock().unwrap().push(body);
826 if m.status == 200 {
827 (
828 StatusCode::OK,
829 [("content-type", "text/event-stream")],
830 m.sse.to_string(),
831 )
832 .into_response()
833 } else {
834 (
835 StatusCode::from_u16(m.status).unwrap(),
836 [("content-type", "application/json")],
837 r#"{"type":"error","error":{"type":"api_error","message":"synthetic"}}"#
838 .to_string(),
839 )
840 .into_response()
841 }
842 }),
843 )
844 .layer(axum::extract::DefaultBodyLimit::max(64 * 1024 * 1024))
845 .with_state(mock.clone());
846 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
847 let addr = listener.local_addr().unwrap();
848 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
849 (format!("http://{addr}"), mock)
850 }
851
852 struct CountingInject(Arc<AtomicUsize>);
854 #[async_trait::async_trait]
855 impl crate::extensions::runtime::ExtensionHandler for CountingInject {
856 fn id(&self) -> &str {
857 "keepwarm-counting-inject"
858 }
859 async fn handle(&self, _event: &HookEvent) -> HookResult {
860 self.0.fetch_add(1, Ordering::SeqCst);
861 HookResult::Inject {
862 content: "PER-TURN-INJECTED-CONTEXT".into(),
863 }
864 }
865 async fn shutdown(&self) {}
866 }
867
868 async fn real_turn(rt: &Runtime, messages: Vec<SharedMessage>) -> Vec<SharedMessage> {
870 let mut stream = rt
871 .run_stream_with_messages(messages, CancellationToken::new(), None, None, false)
872 .await;
873 let mut history = None;
874 while let Some(event) = stream.next().await {
875 match event {
876 StreamEvent::Session(SessionEvent::MessageHistory(h)) => history = Some(h),
877 StreamEvent::Session(SessionEvent::Error(e)) => panic!("turn failed: {e:?}"),
878 _ => {}
879 }
880 }
881 history.expect("turn published its history")
882 }
883
884 fn user(text: &str) -> SharedMessage {
885 Arc::new(json!({"role": "user", "content": text}))
886 }
887
888 fn canon(v: &Value) -> String {
889 serde_json::to_string(v).unwrap()
890 }
891
892 fn strip_cache_control(mut msg: Value) -> Value {
893 if let Some(blocks) = msg["content"].as_array_mut() {
894 for b in blocks {
895 if let Some(obj) = b.as_object_mut() {
896 obj.remove("cache_control");
897 }
898 }
899 }
900 msg
901 }
902
903 fn marker_count(v: &Value) -> usize {
904 match v {
905 Value::Object(map) => {
906 usize::from(map.contains_key("cache_control"))
907 + map.values().map(marker_count).sum::<usize>()
908 }
909 Value::Array(items) => items.iter().map(marker_count).sum(),
910 _ => 0,
911 }
912 }
913
914 struct Observed {
916 ping: Value,
917 real: Value,
918 raw_ping: String,
919 history_len: usize,
920 usage: KeepWarmUsage,
921 hook_calls: (usize, usize, usize),
923 }
924
925 async fn ping_then_next_turn(rt: &Runtime) -> Observed {
930 let (url, mock) = spawn_mock().await;
931 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
932 let calls = Arc::new(AtomicUsize::new(0));
933 let mut perms = PermissionSet::new();
934 perms.grant(Permission::LlmContent);
935 rt.hook_bus()
936 .subscribe(
937 HookKind::BeforeMessage,
938 Arc::new(CountingInject(Arc::clone(&calls))),
939 None,
940 None,
941 perms,
942 )
943 .await
944 .unwrap();
945
946 assert_eq!(rt.cache_anchor(), None, "no request has run yet");
947 let history = real_turn(rt, vec![user("hello")]).await;
949 assert_eq!(history.last().unwrap()["role"], "assistant");
950 let turn_anchor = rt
951 .cache_anchor()
952 .expect("an accepted turn request stamps the anchor");
953 let before_ping = calls.load(Ordering::SeqCst);
954 let snapshot = history.clone();
955
956 let fingerprint = rt
957 .cache_keepwarm_probe(&history)
958 .await
959 .expect("probe")
960 .fingerprint;
961 assert_eq!(
962 mock.bodies.lock().unwrap().len(),
963 1,
964 "a probe sends nothing"
965 );
966 assert_eq!(
967 rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
968 fingerprint,
969 "the fingerprint is deterministic"
970 );
971
972 let tap = PingTap::default();
973 let report = rt
974 .cache_keepwarm_ping(&history, fingerprint, &CancellationToken::new(), &tap)
975 .await;
976 assert_eq!(report.result, Ok(PingOutcome::Sent), "{report:?}");
977 assert!(tap.was_sent(), "the tap saw the request leave");
978 assert_eq!(tap.usage(), report.usage, "the tap saw the usage");
979 let usage = report.usage;
980 assert!(
981 usage.output_tokens >= 1,
982 "usage comes back from the private channel"
983 );
984 let ping_anchor = rt.cache_anchor().expect("anchor");
985 assert!(
986 ping_anchor.mono >= turn_anchor.mono,
987 "the ping moved the anchor"
988 );
989 let after_ping = calls.load(Ordering::SeqCst);
990 assert_eq!(history, snapshot, "the ping must not mutate history");
991
992 let mut next = history.clone();
994 next.push(user("next question"));
995 real_turn(rt, next).await;
996 let after_next = calls.load(Ordering::SeqCst);
997
998 let bodies = mock.bodies.lock().unwrap().clone();
999 assert_eq!(bodies.len(), 3, "previous turn, ping, next turn");
1000 Observed {
1001 ping: serde_json::from_str(&bodies[1]).unwrap(),
1002 real: serde_json::from_str(&bodies[2]).unwrap(),
1003 raw_ping: bodies[1].clone(),
1004 history_len: history.len(),
1005 usage,
1006 hook_calls: (before_ping, after_ping, after_next),
1007 }
1008 }
1009
1010 fn assert_prefix_identical(
1011 ping: &Value,
1012 real: &Value,
1013 n: usize,
1014 history_marker: &Value,
1015 ping_max_tokens: u64,
1016 ) {
1017 for key in [
1019 "model",
1020 "tools",
1021 "system",
1022 "thinking",
1023 "output_config",
1024 "stream",
1025 ] {
1026 assert_eq!(
1027 canon(&ping[key]),
1028 canon(&real[key]),
1029 "`{key}` differs between ping and the real next turn"
1030 );
1031 }
1032 let pm = ping["messages"].as_array().unwrap();
1033 let rm = real["messages"].as_array().unwrap();
1034 assert_eq!(pm.len(), n + 1, "history + one trailing ping message");
1035 assert!(rm.len() > n);
1036 for i in 0..n - 1 {
1038 assert_eq!(canon(&pm[i]), canon(&rm[i]), "message {i} differs");
1039 }
1040 let last = &pm[n - 1];
1043 let marker = &last["content"].as_array().unwrap().last().unwrap()["cache_control"];
1044 assert_eq!(marker, history_marker);
1045 assert_eq!(
1046 canon(&strip_cache_control(last.clone())),
1047 canon(&rm[n - 1]),
1048 "last history message differs beyond the cache marker"
1049 );
1050 assert_eq!(marker_count(&pm[n]), 0, "ping message must be unmarked");
1052 assert_eq!(pm[n]["role"], "user");
1053 assert_eq!(pm[n]["content"], KEEPWARM_PING_TEXT);
1054 assert_eq!(ping["max_tokens"], json!(ping_max_tokens));
1056 assert!(real["max_tokens"].as_u64().unwrap() > ping_max_tokens);
1057 assert!(
1058 marker_count(ping) <= 4,
1059 "Anthropic allows at most 4 breakpoints"
1060 );
1061 }
1062
1063 fn marker_1h() -> Value {
1064 json!({"type": "ephemeral", "ttl": "1h"})
1065 }
1066
1067 fn marker_5m() -> Value {
1068 json!({"type": "ephemeral"})
1069 }
1070
1071 fn opus(ttl: CacheTtl) -> Runtime {
1072 let mut rt = Runtime::new_headless();
1073 rt.set_model("claude-opus-4-7".to_string());
1074 assert_eq!(rt.model(), "claude-opus-4-7");
1075 rt.set_reasoning_level_explicit(ReasoningLevel::High);
1076 rt.set_cache_ttl(ttl);
1077 rt.set_system_prompt("You are a keep-warm test.".into());
1078 assert_eq!(rt.cache_keepwarm_ineligibility(), None);
1079 rt
1080 }
1081
1082 #[tokio::test]
1086 #[serial_test::serial(synaps_base_dir)]
1087 async fn ping_prefix_matches_next_turn_with_adaptive_thinking() {
1088 let _base = crate::test_env::BaseDirGuard::new();
1089 let rt = opus(CacheTtl::OneHour);
1090
1091 let o = ping_then_next_turn(&rt).await;
1092 assert!(
1093 !o.ping["thinking"].is_null(),
1094 "thinking must stay on: {}",
1095 o.ping
1096 );
1097 assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_1h(), 1);
1098
1099 let (before, after_ping, after_next) = o.hook_calls;
1101 assert!(
1102 before >= 1,
1103 "control: the previous turn fired before_message"
1104 );
1105 assert_eq!(after_ping, before, "the ping fired a hook");
1106 assert!(
1107 after_next > after_ping,
1108 "control: the next turn fired before_message"
1109 );
1110 assert!(!o.raw_ping.contains("PER-TURN-INJECTED-CONTEXT"));
1111 assert!(!o.raw_ping.contains("Extension context"));
1112 assert!(canon(&o.real).contains("PER-TURN-INJECTED-CONTEXT"));
1114 }
1115
1116 #[tokio::test]
1118 #[serial_test::serial(synaps_base_dir)]
1119 async fn ping_prefix_matches_next_turn_with_5m_ttl() {
1120 let _base = crate::test_env::BaseDirGuard::new();
1121 let rt = opus(CacheTtl::FiveMinutes);
1122 let o = ping_then_next_turn(&rt).await;
1123 assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_5m(), 1);
1124 }
1125
1126 #[tokio::test]
1129 #[serial_test::serial(synaps_base_dir)]
1130 async fn ping_prefix_matches_next_turn_with_hybrid_ttl() {
1131 let _base = crate::test_env::BaseDirGuard::new();
1132 let rt = opus(CacheTtl::Hybrid);
1133 let o = ping_then_next_turn(&rt).await;
1134 assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_5m(), 1);
1135 assert!(
1136 canon(&o.ping["system"]).contains("\"ttl\":\"1h\""),
1137 "hybrid keeps the 1h stable-prefix marker: {}",
1138 o.ping["system"]
1139 );
1140 }
1141
1142 #[tokio::test]
1144 #[serial_test::serial(synaps_base_dir)]
1145 async fn ping_prefix_matches_next_turn_with_thinking_off() {
1146 let _base = crate::test_env::BaseDirGuard::new();
1147 let mut rt = Runtime::new_headless();
1148 rt.set_model("claude-sonnet-4-6".to_string());
1149 rt.set_reasoning_level_explicit(ReasoningLevel::Off);
1150 rt.set_cache_ttl(CacheTtl::OneHour);
1151 assert_eq!(rt.cache_keepwarm_ineligibility(), None);
1152
1153 let o = ping_then_next_turn(&rt).await;
1154 assert!(o.ping.get("thinking").is_none(), "thinking off is omitted");
1155 assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_1h(), 1);
1156 }
1157
1158 #[tokio::test]
1163 #[serial_test::serial(synaps_base_dir)]
1164 async fn budgeted_thinking_ping_keeps_budget_and_is_costed() {
1165 let _base = crate::test_env::BaseDirGuard::new();
1166 let mut rt = Runtime::new_headless();
1167 rt.set_model("claude-sonnet-4-6".to_string());
1168 rt.set_reasoning_level_explicit(ReasoningLevel::High);
1169 rt.set_cache_ttl(CacheTtl::FiveMinutes);
1170 assert_eq!(
1171 rt.cache_keepwarm_ineligibility(),
1172 None,
1173 "budgeted thinking is eligible"
1174 );
1175
1176 let o = ping_then_next_turn(&rt).await;
1177 assert_eq!(o.ping["thinking"]["type"], "enabled", "{}", o.ping);
1178 let budget = o.ping["thinking"]["budget_tokens"]
1179 .as_u64()
1180 .expect("budget");
1181 assert_eq!(canon(&o.ping["thinking"]), canon(&o.real["thinking"]));
1182 assert_prefix_identical(
1183 &o.ping,
1184 &o.real,
1185 o.history_len,
1186 &marker_5m(),
1187 budget + KEEPWARM_OUTPUT_TOKENS,
1188 );
1189 assert!(
1190 o.usage.cost_usd("claude-sonnet-4-6") > 0.0,
1191 "the ping is costed"
1192 );
1193 }
1194
1195 #[tokio::test]
1199 #[serial_test::serial(synaps_base_dir)]
1200 async fn changed_prefix_is_refused_before_the_network() {
1201 let _base = crate::test_env::BaseDirGuard::new();
1202 let (url, mock) = spawn_mock().await;
1203 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1204 let mut rt = opus(CacheTtl::FiveMinutes);
1205 let history = real_turn(&rt, vec![user("hello")]).await;
1206 let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1207 let sent = mock.bodies.lock().unwrap().len();
1208
1209 let mut longer = history.clone();
1211 longer.push(user("more"));
1212 longer.push(Arc::new(
1213 json!({"role": "assistant", "content": [{"type": "text", "text": "x"}]}),
1214 ));
1215 let changed = |r: PingReport| matches!(r.result, Ok(PingOutcome::PrefixChanged { .. }));
1216 let tap = PingTap::default();
1217 assert!(changed(
1218 rt.cache_keepwarm_ping(&longer, fp, &CancellationToken::new(), &tap)
1219 .await
1220 ));
1221
1222 rt.set_system_prompt("A different system prompt.".into());
1223 assert_ne!(
1224 rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1225 fp
1226 );
1227 assert!(changed(
1228 rt.cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &tap)
1229 .await
1230 ));
1231 rt.set_system_prompt("You are a keep-warm test.".into());
1232 assert_eq!(
1233 rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1234 fp,
1235 "restored"
1236 );
1237
1238 rt.set_reasoning_level_explicit(ReasoningLevel::Low);
1239 assert_ne!(
1240 rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1241 fp,
1242 "effort is in the key"
1243 );
1244 rt.set_reasoning_level_explicit(ReasoningLevel::High);
1245
1246 rt.set_model("claude-opus-4-6".to_string());
1247 assert_eq!(rt.model(), "claude-opus-4-6");
1248 assert_ne!(
1249 rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1250 fp,
1251 "model is in the key"
1252 );
1253
1254 assert_eq!(
1255 mock.bodies.lock().unwrap().len(),
1256 sent,
1257 "no refused ping reached the provider"
1258 );
1259 assert!(!tap.was_sent(), "a refused ping never leaves");
1260 }
1261
1262 #[tokio::test]
1263 #[serial_test::serial(synaps_base_dir)]
1264 async fn http_4xx_is_fatal_and_5xx_is_transient() {
1265 let _base = crate::test_env::BaseDirGuard::new();
1266 let history = vec![
1267 user("hello"),
1268 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1269 ];
1270 for (status, fatal) in [(400u16, true), (404, true), (500, false), (429, false)] {
1271 let (url, mock) = spawn_mock_with(status).await;
1272 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1273 let rt = opus(CacheTtl::FiveMinutes);
1274 let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1275 let err = rt
1276 .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &PingTap::default())
1277 .await
1278 .result
1279 .expect_err("non-2xx fails");
1280 assert_eq!(
1281 matches!(err, PingFailure::Fatal(_)),
1282 fatal,
1283 "HTTP {status}: {err:?}"
1284 );
1285 assert_eq!(
1286 mock.bodies.lock().unwrap().len(),
1287 1,
1288 "HTTP {status}: no retry"
1289 );
1290 assert_eq!(
1291 rt.cache_anchor(),
1292 None,
1293 "a rejected request refreshes nothing"
1294 );
1295 }
1296 }
1297
1298 async fn assert_identity_1h(rt: &Runtime) -> Observed {
1302 let o = ping_then_next_turn(rt).await;
1303 assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_1h(), 1);
1304 o
1305 }
1306
1307 #[tokio::test]
1311 #[serial_test::serial(synaps_base_dir)]
1312 async fn ping_prefix_matches_next_turn_with_progressive_disclosure() {
1313 let _base = crate::test_env::BaseDirGuard::new();
1314 let mut rt = opus(CacheTtl::OneHour);
1315 rt.progressive_tool_disclosure = true;
1316 assert_identity_1h(&rt).await;
1317 }
1318
1319 #[tokio::test]
1322 #[serial_test::serial(synaps_base_dir)]
1323 async fn ping_prefix_matches_next_turn_with_deferred_tools() {
1324 let _base = crate::test_env::BaseDirGuard::new();
1325 let mut rt = opus(CacheTtl::OneHour);
1326 rt.progressive_tool_disclosure = true;
1327 rt.deferred_tool_loading = true;
1328 assert_identity_1h(&rt).await;
1329 }
1330
1331 #[tokio::test]
1334 #[serial_test::serial(synaps_base_dir)]
1335 async fn ping_prefix_matches_next_turn_with_context_management_guidance() {
1336 let _base = crate::test_env::BaseDirGuard::new();
1337 let rt = opus(CacheTtl::OneHour);
1338 rt.continuation.lock().unwrap().config.mode =
1339 crate::core::config::ContextManagementMode::Auto;
1340 let o = assert_identity_1h(&rt).await;
1341 assert!(
1342 canon(&o.ping["system"]).contains("Work normally."),
1343 "guidance present: {}",
1344 o.ping["system"]
1345 );
1346 }
1347
1348 #[tokio::test]
1351 #[serial_test::serial(synaps_base_dir)]
1352 async fn ping_prefix_matches_next_turn_with_session_injection() {
1353 let _base = crate::test_env::BaseDirGuard::new();
1354 let mut rt = opus(CacheTtl::OneHour);
1355 rt.set_session_id(Some("kw-session".into()));
1356 rt.hook_bus()
1357 .set_session_injection_for("kw-session", "SESSION-SCOPED-INJECTION".into())
1358 .await;
1359 let o = assert_identity_1h(&rt).await;
1360 assert!(canon(&o.ping["system"]).contains("SESSION-SCOPED-INJECTION"));
1361 }
1362
1363 #[tokio::test]
1366 #[serial_test::serial(synaps_base_dir)]
1367 async fn ping_prefix_matches_next_turn_with_oauth_system_blocks() {
1368 let _base = crate::test_env::BaseDirGuard::new();
1369 let mut rt = opus(CacheTtl::OneHour);
1370 rt.auth = Arc::new(tokio::sync::RwLock::new(super::super::types::AuthState {
1371 auth_token: "oauth-test-token".to_string(),
1372 auth_type: "oauth".to_string(),
1373 refresh_token: None,
1374 token_expires: Some(crate::epoch_millis() + 3_600_000),
1375 }));
1376 let o = assert_identity_1h(&rt).await;
1377 assert!(
1378 canon(&o.ping["system"]).contains("You are Claude Code"),
1379 "{}",
1380 o.ping["system"]
1381 );
1382 }
1383
1384 #[tokio::test]
1388 #[serial_test::serial(synaps_base_dir)]
1389 async fn probe_reports_a_context_rollover_ahead() {
1390 let _base = crate::test_env::BaseDirGuard::new();
1391 let (url, _mock) = spawn_mock().await;
1392 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1393 let rt = opus(CacheTtl::OneHour);
1394 rt.continuation.lock().unwrap().config.mode =
1395 crate::core::config::ContextManagementMode::Auto;
1396 let history = real_turn(&rt, vec![user("hello")]).await;
1397 let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1398 assert_eq!(probe.context_blocks, None, "plenty of room");
1399 let before = rt.continuation.lock().unwrap().policy;
1400 rt.continuation.lock().unwrap().config.reserve_tokens = 10_000_000;
1402 let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1403 assert!(probe.context_blocks.is_some(), "{probe:?}");
1404 assert_eq!(
1405 rt.continuation.lock().unwrap().policy,
1406 before,
1407 "the probe never commits policy state"
1408 );
1409 rt.continuation.lock().unwrap().config.mode =
1411 crate::core::config::ContextManagementMode::Off;
1412 assert_eq!(
1413 rt.cache_keepwarm_probe(&history)
1414 .await
1415 .unwrap()
1416 .context_blocks,
1417 None
1418 );
1419 }
1420
1421 #[tokio::test]
1423 #[serial_test::serial(synaps_base_dir)]
1424 async fn probe_reports_max_tokens() {
1425 let _base = crate::test_env::BaseDirGuard::new();
1426 let history = vec![
1427 user("hello"),
1428 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1429 ];
1430 let rt = opus(CacheTtl::OneHour);
1431 assert_eq!(
1432 rt.cache_keepwarm_probe(&history).await.unwrap().max_tokens,
1433 1
1434 );
1435 let mut rt = Runtime::new_headless();
1436 rt.set_model("claude-sonnet-4-6".to_string());
1437 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
1438 let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1439 let budget = u64::from(rt.thinking_budget);
1440 assert!(budget >= 16_384, "budgeted thinking at xhigh: {budget}");
1441 assert_eq!(
1442 probe.max_tokens,
1443 budget + KEEPWARM_OUTPUT_TOKENS,
1444 "{probe:?}"
1445 );
1446 }
1447
1448 #[tokio::test]
1451 #[serial_test::serial(synaps_base_dir)]
1452 async fn ping_usage_is_returned_on_error() {
1453 let _base = crate::test_env::BaseDirGuard::new();
1454 let (url, mock) = spawn_mock_full(200, SSE_USAGE_THEN_ERROR).await;
1455 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1456 let history = vec![
1457 user("hello"),
1458 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1459 ];
1460 let rt = opus(CacheTtl::OneHour);
1461 let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1462 let tap = PingTap::default();
1463 let report = rt
1464 .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &tap)
1465 .await;
1466 assert!(
1467 matches!(report.result, Err(PingFailure::Transient(_))),
1468 "{report:?}"
1469 );
1470 assert_eq!(report.usage.cache_read_tokens, 5_000, "{report:?}");
1471 assert!(report.usage.cost_usd("claude-opus-4-7") > 0.0);
1472 assert_eq!(tap.usage(), report.usage);
1473 assert!(tap.was_sent());
1474 assert_eq!(mock.bodies.lock().unwrap().len(), 1, "no transport retry");
1475 }
1476
1477 #[test]
1480 fn ping_shares_the_session_ttl_latch() {
1481 let rt = opus(CacheTtl::OneHour);
1482 assert!(!rt.cache_ttl_downgraded());
1483 let mut ping_rt = rt.clone();
1484 ping_rt.share_ttl_latches(&rt);
1485 ping_rt
1486 .ttl_downgrade_notified
1487 .store(true, Ordering::Relaxed);
1488 assert!(rt.cache_ttl_downgraded(), "the latch is shared");
1489 }
1490
1491 #[test]
1492 fn failure_classification() {
1493 let api = || RuntimeError::ApiStatus("x".into());
1494 assert_eq!(
1495 classify_failure(&RuntimeError::Canceled, 0),
1496 PingFailure::Canceled
1497 );
1498 assert!(matches!(
1499 classify_failure(&api(), 400),
1500 PingFailure::Fatal(_)
1501 ));
1502 assert!(matches!(
1503 classify_failure(&api(), 401),
1504 PingFailure::Fatal(_)
1505 ));
1506 assert!(matches!(
1507 classify_failure(&api(), 429),
1508 PingFailure::Transient(_)
1509 ));
1510 assert!(matches!(
1511 classify_failure(&api(), 529),
1512 PingFailure::Transient(_)
1513 ));
1514 assert!(matches!(
1515 classify_failure(&api(), 0),
1516 PingFailure::Transient(_)
1517 ));
1518 assert!(matches!(
1520 classify_failure(&api(), 200),
1521 PingFailure::Transient(_)
1522 ));
1523 assert!(matches!(
1524 classify_failure(&RuntimeError::Config("x".into()), 0),
1525 PingFailure::Fatal(_)
1526 ));
1527 }
1528
1529 #[test]
1530 fn every_ttl_and_thinking_mode_is_eligible() {
1531 for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour, CacheTtl::Hybrid] {
1532 for (model, level) in [
1533 ("claude-opus-4-7", ReasoningLevel::High),
1534 ("claude-sonnet-4-6", ReasoningLevel::High),
1535 ("claude-sonnet-4-6", ReasoningLevel::Off),
1536 ] {
1537 let mut rt = Runtime::new_headless();
1538 rt.set_model(model.to_string());
1539 rt.set_reasoning_level_explicit(level);
1540 rt.set_cache_ttl(ttl);
1541 assert_eq!(
1542 rt.cache_keepwarm_ineligibility(),
1543 None,
1544 "{model} {level:?} {ttl:?}"
1545 );
1546 }
1547 }
1548 }
1549
1550 #[test]
1551 fn non_anthropic_models_are_ineligible() {
1552 let mut rt = Runtime::new_headless();
1553 rt.set_model("groq/llama-3.3-70b-versatile".to_string());
1554 if rt.model() == "groq/llama-3.3-70b-versatile" {
1555 assert!(rt.cache_keepwarm_ineligibility().is_some());
1556 }
1557 }
1558
1559 #[tokio::test]
1562 async fn ineligible_ping_sends_nothing() {
1563 let mut rt = Runtime::new_headless();
1564 rt.set_model("groq/llama-3.3-70b-versatile".to_string());
1565 if rt.model() != "groq/llama-3.3-70b-versatile" {
1566 return;
1567 }
1568 let history = vec![
1569 user("hello"),
1570 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1571 ];
1572 let tap = PingTap::default();
1573 let err = rt
1574 .cache_keepwarm_ping(&history, 0, &CancellationToken::new(), &tap)
1575 .await
1576 .result
1577 .expect_err("non-Anthropic must refuse");
1578 assert!(!tap.was_sent());
1579 assert!(
1580 matches!(err, PingFailure::Fatal(ref m) if m.contains("keep-warm skipped")),
1581 "{err:?}"
1582 );
1583 }
1584
1585 #[test]
1586 fn anchor_cell_only_moves_forward() {
1587 let cell = CacheAnchorCell::default();
1588 assert_eq!(cell.get(), None);
1589 let a = CacheAnchor::now();
1590 let later = CacheAnchor {
1591 mono: a.mono + std::time::Duration::from_secs(5),
1592 wall: a.wall + std::time::Duration::from_secs(5),
1593 };
1594 cell.stamp(later);
1595 cell.stamp(a);
1596 assert_eq!(
1597 cell.get(),
1598 Some(later),
1599 "a late-arriving older stamp never rewinds"
1600 );
1601 }
1602
1603 #[test]
1604 fn only_a_finished_assistant_text_reply_is_markable() {
1605 assert!(tail_is_markable(
1606 &json!({"role": "assistant", "content": [{"type": "thinking", "thinking": "t"}, {"type": "text", "text": "ok"}]})
1607 ));
1608 assert!(!tail_is_markable(&json!({"role": "user", "content": "q"})));
1609 assert!(!tail_is_markable(
1610 &json!({"role": "assistant", "content": [{"type": "tool_use", "id": "t", "name": "x", "input": {}}]})
1611 ));
1612 assert!(!tail_is_markable(
1613 &json!({"role": "assistant", "content": [{"type": "thinking", "thinking": "t"}]})
1614 ));
1615 }
1616
1617 #[test]
1618 fn usage_is_summed_and_priced() {
1619 let mut u = KeepWarmUsage::default();
1620 u.add(&StreamEvent::Session(SessionEvent::Usage {
1621 input_tokens: 20,
1622 output_tokens: 16,
1623 cache_read_input_tokens: 500_000,
1624 cache_creation_input_tokens: 0,
1625 cache_creation_5m: None,
1626 cache_creation_1h: Some(0),
1627 model: Some("claude-opus-4-7".into()),
1628 }));
1629 assert_eq!(u.cache_read_tokens, 500_000);
1630 assert_eq!(u.output_tokens, 16);
1631 let cost = u.cost_usd("claude-opus-4-7");
1633 assert!((cost - 0.2505).abs() < 0.001, "{cost}");
1634 }
1635
1636 #[test]
1638 fn booking_rule_table() {
1639 use PingBooking::*;
1640 let table = [
1642 ((true, true, false, 200), Measured),
1643 ((true, false, false, 0), Measured),
1644 ((true, true, false, 529), Measured),
1645 ((false, true, false, 200), WorstCase), ((false, true, false, 0), WorstCase), ((false, true, false, 500), Nothing), ((false, true, false, 429), Nothing),
1649 ((false, true, false, 529), Nothing),
1650 ((false, true, true, 0), Nothing), ((false, true, true, 503), Nothing), ((false, false, false, 0), Nothing), ((false, false, true, 0), Nothing),
1654 ];
1655 for ((usage, sent, connect, status), want) in table {
1656 assert_eq!(
1657 ping_booking(usage, sent, connect, status),
1658 want,
1659 "usage={usage} sent={sent} connect_failed={connect} status={status}"
1660 );
1661 }
1662 let tap = PingTap::default();
1664 assert_eq!(tap.bill("claude-opus-4-7", 0.5).cost_usd, 0.0);
1665 tap.sent.store(true, Ordering::Relaxed);
1666 let bill = tap.bill("claude-opus-4-7", 0.5);
1667 assert_eq!((bill.booking, bill.cost_usd), (WorstCase, 0.5));
1668 tap.usage.lock().unwrap().cache_read_tokens = 1_000_000;
1669 let bill = tap.bill("claude-opus-4-7", 0.5);
1670 assert_eq!(bill.booking, Measured);
1671 assert!((bill.cost_usd - 0.5).abs() < 1e-9, "1M reads at $0.50/MTok");
1672 }
1673
1674 fn two_message_history() -> Vec<SharedMessage> {
1675 vec![
1676 user("hello"),
1677 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1678 ]
1679 }
1680
1681 #[tokio::test]
1683 #[serial_test::serial(synaps_base_dir)]
1684 async fn a_refused_connection_is_never_sent_and_books_nothing() {
1685 let _base = crate::test_env::BaseDirGuard::new();
1686 let addr = {
1687 let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1688 l.local_addr().unwrap()
1689 }; let _url = crate::test_env::EnvVarGuard::set(
1691 "SYNAPS_ANTHROPIC_BASE_URL",
1692 &format!("http://{addr}"),
1693 );
1694 let history = two_message_history();
1695 let rt = opus(CacheTtl::OneHour);
1696 let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1697 let tap = PingTap::default();
1698 let report = rt
1699 .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &tap)
1700 .await;
1701 assert!(report.result.is_err(), "{report:?}");
1702 assert!(!tap.was_sent(), "never dispatched");
1703 assert!(tap.connect_failed.load(Ordering::Relaxed));
1704 let bill = tap.bill("claude-opus-4-7", 0.75);
1705 assert_eq!((bill.booking, bill.cost_usd), (PingBooking::Nothing, 0.0));
1706 }
1707
1708 #[tokio::test]
1711 #[serial_test::serial(synaps_base_dir)]
1712 async fn a_dispatched_ping_without_usage_books_the_worst_case() {
1713 use tokio::io::AsyncReadExt;
1714 let _base = crate::test_env::BaseDirGuard::new();
1715 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1716 let addr = listener.local_addr().unwrap();
1717 tokio::spawn(async move {
1719 let (mut sock, _) = listener.accept().await.unwrap();
1720 let mut buf = vec![0u8; 64 * 1024];
1721 while sock.read(&mut buf).await.is_ok_and(|n| n > 0) {}
1722 });
1723 let _url = crate::test_env::EnvVarGuard::set(
1724 "SYNAPS_ANTHROPIC_BASE_URL",
1725 &format!("http://{addr}"),
1726 );
1727 let history = two_message_history();
1728 let rt = opus(CacheTtl::OneHour);
1729 let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1730 let tap = PingTap::default();
1731 let cancel = CancellationToken::new();
1732 let ping = {
1733 let (rt, history, tap, cancel) =
1734 (rt.clone(), history.clone(), tap.clone(), cancel.clone());
1735 tokio::spawn(async move { rt.cache_keepwarm_ping(&history, fp, &cancel, &tap).await })
1736 };
1737 tokio::time::timeout(std::time::Duration::from_secs(10), async {
1738 while !tap.was_sent() {
1739 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1740 }
1741 })
1742 .await
1743 .expect("the body was pulled over the established connection");
1744 cancel.cancel();
1745 let report = ping.await.unwrap();
1746 assert_eq!(report.result, Err(PingFailure::Canceled));
1747 assert!(!tap.connect_failed.load(Ordering::Relaxed));
1748 let bill = tap.bill("claude-opus-4-7", 0.75);
1749 assert_eq!(
1750 (bill.booking, bill.cost_usd),
1751 (PingBooking::WorstCase, 0.75)
1752 );
1753 }
1754
1755 #[test]
1756 fn reply_prefix_tokens_skip_thinking() {
1757 let text = "a".repeat(250); let with_thinking = vec![Arc::new(json!({"role": "assistant", "content": [
1759 {"type": "thinking", "thinking": "x".repeat(50_000), "signature": "s"},
1760 {"type": "text", "text": text},
1761 ]}))];
1762 let plain = vec![Arc::new(json!({"role": "assistant", "content": [
1763 {"type": "text", "text": text},
1764 ]}))];
1765 let t = reply_prefix_tokens(&with_thinking);
1766 assert_eq!(t, reply_prefix_tokens(&plain), "thinking is not counted");
1767 assert!((100..200).contains(&t), "{t}");
1768 assert_eq!(reply_prefix_tokens(&[]), 0);
1769 }
1770}