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.is_none_or(|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) && !super::api::is_retryable_status(status) {
329 return PingFailure::Fatal(format!("HTTP {status}"));
330 }
331 match err {
332 RuntimeError::Api(_)
335 | RuntimeError::ApiStatus(_)
336 | RuntimeError::Timeout
337 | RuntimeError::NetworkTimeout(_) => PingFailure::Transient(if status == 0 {
338 "network".to_string()
339 } else {
340 format!("HTTP {status}")
341 }),
342 other => PingFailure::Fatal(other.to_string()),
343 }
344}
345
346fn tail_is_markable(last: &Value) -> bool {
350 if last["role"] != "assistant" {
351 return false;
352 }
353 match last["content"].as_array() {
354 Some(blocks) => blocks.last().is_some_and(|b| {
355 b["type"] == "text" && b["text"].as_str().is_some_and(|t| !t.is_empty())
356 }),
357 None => last["content"].as_str().is_some_and(|t| !t.is_empty()),
358 }
359}
360
361pub fn history_ready(history: &[SharedMessage]) -> bool {
364 history.last().is_some_and(|m| tail_is_markable(m))
365}
366
367pub fn reply_prefix_tokens(history: &[SharedMessage]) -> u64 {
374 let Some(last) = history.last() else {
375 return 0;
376 };
377 let kept = match last["content"].as_array() {
378 Some(blocks) => Value::Array(
379 blocks
380 .iter()
381 .filter(|b| !matches!(b["type"].as_str(), Some("thinking" | "redacted_thinking")))
382 .cloned()
383 .collect(),
384 ),
385 None => last["content"].clone(),
386 };
387 super::context::conservative_token_estimate(&kept.to_string())
388}
389
390pub(super) async fn drive_ping<F>(
396 call: F,
397 rx: &mut mpsc::UnboundedReceiver<StreamEvent>,
398 tap: &PingTap,
399 forward: Option<&mpsc::UnboundedSender<StreamEvent>>,
400) -> (Result<Value>, Vec<String>)
401where
402 F: std::future::Future<Output = Result<Value>>,
403{
404 tokio::pin!(call);
405 let mut notices = Vec::new();
406 let mut on_event = |event: StreamEvent| {
407 if let StreamEvent::Session(SessionEvent::Notice(n) | SessionEvent::Retry(n)) = &event {
408 notices.push(n.clone());
409 }
410 tap.usage
411 .lock()
412 .unwrap_or_else(std::sync::PoisonError::into_inner)
413 .add(&event);
414 if let Some(tx) = forward {
415 if matches!(
416 event,
417 StreamEvent::Session(SessionEvent::Usage { .. } | SessionEvent::Notice(_))
418 ) {
419 let _ = tx.send(event);
420 }
421 }
422 };
423 let result = loop {
424 tokio::select! {
425 r = &mut call => break r,
426 Some(event) = rx.recv() => on_event(event),
427 }
428 };
429 while let Ok(event) = rx.try_recv() {
430 on_event(event);
431 }
432 (result, notices)
433}
434
435impl Runtime {
436 pub fn cache_keepwarm_ineligibility(&self) -> Option<String> {
440 if !super::auth::model_is_anthropic(&self.model) {
441 return Some(format!(
442 "model {} is not on the native Anthropic transport",
443 self.model
444 ));
445 }
446 None
447 }
448
449 pub fn cache_anchor(&self) -> Option<CacheAnchor> {
451 self.cache_anchor.get()
452 }
453
454 pub fn cache_ttl_downgraded(&self) -> bool {
457 self.ttl_downgrade_notified.load(Ordering::Relaxed)
458 }
459
460 pub fn cache_1h_honoured(&self) -> bool {
462 self.saw_1h_honored.load(Ordering::Relaxed)
463 }
464
465 pub async fn cache_keepwarm_probe(&self, history: &[SharedMessage]) -> Result<KeepWarmProbe> {
471 let cancel = CancellationToken::new();
472 let tap = PingTap::default();
473 let (value, _) = self
474 .keepwarm_request(history, &cancel, KeepWarmMode::Probe, &tap)
475 .await;
476 let value = value?;
477 let fingerprint = value["keepwarm_prefix"].as_u64().ok_or_else(|| {
478 RuntimeError::Session("keep-warm probe produced no fingerprint".into())
479 })?;
480 Ok(KeepWarmProbe {
481 fingerprint,
482 max_tokens: value["keepwarm_max_tokens"].as_u64().unwrap_or(0),
483 context_blocks: value["keepwarm_context_blocks"]
484 .as_str()
485 .map(str::to_string),
486 })
487 }
488
489 pub async fn cache_keepwarm_ping(
495 &self,
496 history: &[SharedMessage],
497 expect: u64,
498 cancel: &CancellationToken,
499 tap: &PingTap,
500 ) -> PingReport {
501 let (value, notices) = self
502 .keepwarm_request(history, cancel, KeepWarmMode::Send { expect }, tap)
503 .await;
504 let result = match value {
505 Ok(value) => match value.get("keepwarm_prefix").and_then(Value::as_u64) {
506 Some(actual) => Ok(PingOutcome::PrefixChanged {
507 expected: expect,
508 actual,
509 }),
510 None => Ok(PingOutcome::Sent),
511 },
512 Err(e) => Err(classify_failure(&e, tap.status.load(Ordering::Relaxed))),
513 };
514 PingReport {
515 result,
516 usage: tap.usage(),
517 notices,
518 }
519 }
520
521 async fn keepwarm_request(
522 &self,
523 history: &[SharedMessage],
524 cancel: &CancellationToken,
525 mode: KeepWarmMode,
526 tap: &PingTap,
527 ) -> (Result<Value>, Vec<String>) {
528 match self
529 .keepwarm_request_inner(history, cancel, mode, tap)
530 .await
531 {
532 Ok(r) => r,
533 Err(e) => (Err(e), Vec::new()),
534 }
535 }
536
537 async fn keepwarm_request_inner(
540 &self,
541 history: &[SharedMessage],
542 cancel: &CancellationToken,
543 mode: KeepWarmMode,
544 tap: &PingTap,
545 ) -> Result<(Result<Value>, Vec<String>)> {
546 if let Some(reason) = self.cache_keepwarm_ineligibility() {
547 return Err(RuntimeError::Config(format!(
548 "cache keep-warm skipped: {reason}"
549 )));
550 }
551 if !history_ready(history) {
552 return Err(RuntimeError::Config(
553 "cache keep-warm skipped: history does not end with a finished assistant reply"
554 .into(),
555 ));
556 }
557 let (durability_blocked, context_enabled) = {
558 let state = self
559 .continuation
560 .lock()
561 .unwrap_or_else(std::sync::PoisonError::into_inner);
562 (state.durability_blocked, state.enabled())
563 };
564 if durability_blocked {
565 return Err(RuntimeError::Session(
566 "cache keep-warm skipped: context head save is unresolved".into(),
567 ));
568 }
569
570 self.validate_request_preflight().await?;
573 let anthropic_execution_plan = self.authorized_anthropic_plan().await?;
574 if matches!(mode, KeepWarmMode::Send { .. }) {
575 self.refresh_for_keepwarm().await?;
578 }
579
580 let system_prompt = self.effective_system_prompt().await;
582 let system_prompt = if context_enabled {
583 Some(format!(
584 "{}\n\n{}",
585 system_prompt.as_deref().unwrap_or_default(),
586 super::continuation::GUIDANCE
587 ))
588 } else {
589 system_prompt
590 };
591 let session_injection = match self.session_id.as_deref() {
592 Some(id) => self.hook_bus.session_injection_for(id).await,
593 None => None,
594 };
595 let system_prompt = match session_injection {
596 Some(content) => Some(super::stream::wrap_extension_context(
597 system_prompt.as_deref().unwrap_or_default(),
598 &content,
599 )),
600 None => system_prompt,
601 };
602
603 let mut request: Vec<SharedMessage> = history.to_vec();
608 request.push(Arc::new(
609 json!({"role": "user", "content": KEEPWARM_PING_TEXT}),
610 ));
611 let round_images =
612 super::image_retention::round_images(&self.model, self.codex_request_role(), &request)
613 .map_err(RuntimeError::Config)?;
614 let (request_messages, _) = round_images.views(&request);
615
616 let tools_snapshot = self.tools.read().await.clone();
622 let (request_tools_schema, anthropic_tool_split) = if self.progressive_tool_disclosure {
623 let fresh = super::chat_tools::chat_tool_set(
624 self.host_tool_session.clone(),
625 tools_snapshot.catalog(),
626 true,
627 context_enabled,
628 &self.tools_always_loaded,
629 );
630 let retained = self
633 .retained_tool_set
634 .lock()
635 .unwrap_or_else(std::sync::PoisonError::into_inner)
636 .clone();
637 let existing = retained.as_ref().map(|shared| {
638 shared
639 .read()
640 .unwrap_or_else(std::sync::PoisonError::into_inner)
641 });
642 let (mut set, _, _) = super::stream::select_turn_start_tool_set(
643 existing.as_deref(),
644 fresh,
645 tools_snapshot.catalog(),
646 true,
647 );
648 drop(existing);
649 let pending = self
654 .pending_tool_restore
655 .lock()
656 .unwrap_or_else(std::sync::PoisonError::into_inner)
657 .clone();
658 if let Some(carried) = pending {
659 if crate::tools::activation::carry_forward_enabled() {
660 let _ = super::stream::restore_pending_activations(
661 &mut set,
662 &carried,
663 tools_snapshot.catalog(),
664 self.activation_confirm,
665 );
666 }
667 }
668 let schema = tools_snapshot.session_tools_schema(&set).schema;
669 let split = self.deferred_tool_loading.then(|| {
670 let referenced = HelperMethods::referenced_tool_ids(request_messages);
671 Arc::new(tools_snapshot.deferred_tool_split(&set, &referenced))
672 });
673 (Some(Arc::new(schema)), split)
674 } else {
675 let withheld = super::chat_tools::withheld_tools(context_enabled);
676 (tools_snapshot.tools_schema_without(&withheld), None)
677 };
678
679 let options = ApiOptions {
680 tool_context: None,
682 use_1m_context: self.context_window_override == Some(1_000_000),
683 cache_ttl: self.cache_ttl,
684 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
688 saw_1h_honored: self.saw_1h_honored.clone(),
689 credential_source: self.credential_source.clone(),
690 token_cache: self.token_cache.clone(),
691 anthropic_base_url: None,
692 anthropic_stream_client: self.anthropic_stream_client.clone(),
693 request_compression: self.request_compression.clone(),
694 anthropic_execution_plan,
695 codex_request_role: self.codex_request_role(),
696 thinking_display: self.wire_thinking_display(),
698 retention_frontier: round_images.applied_frontier(),
699 trace: self.trace_ctx.clone(),
701 request_correlation: None,
702 suppress_stream_deltas: true,
703 telemetry: self.request_telemetry(),
704 tool_session_id: Some(self.host_tool_session.clone()),
705 session_tool_set: None,
706 request_tools_schema: request_tools_schema.clone(),
707 anthropic_tool_split,
708 usage_counters: None,
709 cache_anchor: Some(Arc::clone(&self.cache_anchor)),
712 keepwarm: Some(KeepWarmRequest {
713 mode,
714 status: Arc::clone(&tap.status),
715 sent: Arc::clone(&tap.sent),
716 connect_failed: Arc::clone(&tap.connect_failed),
717 }),
718 cache_diagnosis: self.cache_diagnosis(),
721 context_rollover: None,
722 round_signal: None,
723 };
724
725 let (tx, mut rx) = mpsc::unbounded_channel();
726 let call = ApiMethods::call_api_stream_inner(
727 &self.auth,
728 &self.client,
729 &self.model,
730 &tools_snapshot,
731 &system_prompt,
732 self.thinking_budget,
733 self.reasoning_level(),
734 request_messages,
735 tx,
736 cancel,
737 0,
738 0,
739 &options,
740 self.telemetry_level,
741 );
742 let (mut result, notices) = drive_ping(call, &mut rx, tap, None).await;
743 if let (Ok(value), KeepWarmMode::Probe) = (&mut result, mode) {
745 if context_enabled {
746 if let Some(why) = self.keepwarm_context_blocks(
747 history,
748 system_prompt.as_deref(),
749 request_tools_schema.as_deref().map(|s| s.as_slice()),
750 &tools_snapshot,
751 ) {
752 value["keepwarm_context_blocks"] = Value::String(why);
753 }
754 }
755 }
756 Ok((result, notices))
757 }
758
759 fn keepwarm_context_blocks(
767 &self,
768 history: &[SharedMessage],
769 system_prompt: Option<&str>,
770 projected_schema: Option<&[Value]>,
771 tools_snapshot: &crate::ToolRegistry,
772 ) -> Option<String> {
773 use agent_core::core::context_policy::{assess_context, ContextAction};
774 let mut next: Vec<SharedMessage> = history.to_vec();
775 next.push(Arc::new(json!({"role": "user", "content": ""})));
776 let round_images =
778 super::image_retention::round_images(&self.model, self.codex_request_role(), &next)
779 .ok();
780 let (_, request_messages) = match &round_images {
781 Some(round) => round.views(&next),
782 None => (&next[..], &next[..]),
783 };
784 let fallback_schema;
785 let schema = match projected_schema {
786 Some(s) => s,
787 None => {
788 fallback_schema = tools_snapshot.tools_schema();
789 fallback_schema.as_slice()
790 }
791 };
792 let anchor = self
793 .continuation
794 .lock()
795 .unwrap_or_else(std::sync::PoisonError::into_inner)
796 .usage_anchor_for(&self.model)
797 .cloned();
798 let (assessment, budget) = super::stream::pre_request_context_budget(
799 &self.model,
800 self.context_window(),
801 system_prompt,
802 schema,
803 request_messages,
804 self.thinking_budget as u64,
805 self.max_tool_output as u64,
806 anchor.as_ref(),
807 );
808 let state = self
809 .continuation
810 .lock()
811 .unwrap_or_else(std::sync::PoisonError::into_inner);
812 let decision = assess_context(&state.config, &state.policy, budget);
813 match decision.action {
814 ContextAction::Rollover => Some(format!(
815 "the next turn would roll the context over ({} tokens used)",
816 assessment.used_tokens()
817 )),
818 ContextAction::HardStop => Some(format!(
819 "the next turn would hit the context hard limit ({} tokens used)",
820 assessment.used_tokens()
821 )),
822 _ => None,
823 }
824 }
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830 use crate::core::config::CacheTtl;
831 use crate::extensions::hooks::events::{HookEvent, HookKind, HookResult};
832 use crate::extensions::permissions::{Permission, PermissionSet};
833 use agent_core::reasoning::ReasoningLevel;
834 use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Router};
835 use futures::StreamExt;
836 use std::sync::atomic::AtomicUsize;
837 use std::sync::Mutex;
838
839 const SSE_HI: &str = concat!(
840 "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_kw\",\"type\":\"message\",",
841 "\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-4-7\",\"stop_reason\":null,",
842 "\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0,",
843 "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}}\n\n",
844 "data: {\"type\":\"content_block_start\",\"index\":0,",
845 "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
846 "data: {\"type\":\"content_block_delta\",\"index\":0,",
847 "\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n",
848 "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
849 "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",",
850 "\"stop_sequence\":null},\"usage\":{\"input_tokens\":10,\"output_tokens\":1,",
851 "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}\n\n",
852 "data: {\"type\":\"message_stop\"}\n\n",
853 );
854
855 const SSE_USAGE_THEN_ERROR: &str = concat!(
857 "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_kw\",\"type\":\"message\",",
858 "\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-4-7\",\"stop_reason\":null,",
859 "\"stop_sequence\":null,\"usage\":{\"input_tokens\":12,\"output_tokens\":0,",
860 "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5000}}}\n\n",
861 "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":null,",
862 "\"stop_sequence\":null},\"usage\":{\"input_tokens\":12,\"output_tokens\":1,",
863 "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5000}}\n\n",
864 "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",",
865 "\"message\":\"synthetic\"}}\n\n",
866 );
867
868 #[derive(Clone)]
869 struct Mock {
870 bodies: Arc<Mutex<Vec<String>>>,
871 status: u16,
873 sse: &'static str,
874 }
875
876 async fn spawn_mock() -> (String, Mock) {
877 spawn_mock_full(200, SSE_HI).await
878 }
879
880 async fn spawn_mock_with(status: u16) -> (String, Mock) {
881 spawn_mock_full(status, SSE_HI).await
882 }
883
884 async fn spawn_mock_full(status: u16, sse: &'static str) -> (String, Mock) {
885 let mock = Mock {
886 bodies: Arc::default(),
887 status,
888 sse,
889 };
890 let app = Router::new()
891 .route(
892 "/v1/messages",
893 post(|State(m): State<Mock>, body: String| async move {
894 m.bodies.lock().unwrap().push(body);
895 if m.status == 200 {
896 (
897 StatusCode::OK,
898 [("content-type", "text/event-stream")],
899 m.sse.to_string(),
900 )
901 .into_response()
902 } else {
903 (
904 StatusCode::from_u16(m.status).unwrap(),
905 [("content-type", "application/json")],
906 r#"{"type":"error","error":{"type":"api_error","message":"synthetic"}}"#
907 .to_string(),
908 )
909 .into_response()
910 }
911 }),
912 )
913 .layer(axum::extract::DefaultBodyLimit::max(64 * 1024 * 1024))
914 .with_state(mock.clone());
915 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
916 let addr = listener.local_addr().unwrap();
917 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
918 (format!("http://{addr}"), mock)
919 }
920
921 struct CountingInject(Arc<AtomicUsize>);
923 #[async_trait::async_trait]
924 impl crate::extensions::runtime::ExtensionHandler for CountingInject {
925 fn id(&self) -> &str {
926 "keepwarm-counting-inject"
927 }
928 async fn handle(&self, _event: &HookEvent) -> HookResult {
929 self.0.fetch_add(1, Ordering::SeqCst);
930 HookResult::Inject {
931 content: "PER-TURN-INJECTED-CONTEXT".into(),
932 }
933 }
934 async fn shutdown(&self) {}
935 }
936
937 async fn real_turn(rt: &Runtime, messages: Vec<SharedMessage>) -> Vec<SharedMessage> {
939 let mut stream = rt
940 .run_stream_with_messages(messages, CancellationToken::new(), None, None, false)
941 .await;
942 let mut history = None;
943 while let Some(event) = stream.next().await {
944 match event {
945 StreamEvent::Session(SessionEvent::MessageHistory(h)) => history = Some(h),
946 StreamEvent::Session(SessionEvent::Error(e)) => panic!("turn failed: {e:?}"),
947 _ => {}
948 }
949 }
950 history.expect("turn published its history")
951 }
952
953 fn user(text: &str) -> SharedMessage {
954 Arc::new(json!({"role": "user", "content": text}))
955 }
956
957 fn canon(v: &Value) -> String {
958 serde_json::to_string(v).unwrap()
959 }
960
961 fn strip_cache_control(mut msg: Value) -> Value {
962 if let Some(blocks) = msg["content"].as_array_mut() {
963 for b in blocks {
964 if let Some(obj) = b.as_object_mut() {
965 obj.remove("cache_control");
966 }
967 }
968 }
969 msg
970 }
971
972 fn marker_count(v: &Value) -> usize {
973 match v {
974 Value::Object(map) => {
975 usize::from(map.contains_key("cache_control"))
976 + map.values().map(marker_count).sum::<usize>()
977 }
978 Value::Array(items) => items.iter().map(marker_count).sum(),
979 _ => 0,
980 }
981 }
982
983 struct Observed {
985 ping: Value,
986 real: Value,
987 raw_ping: String,
988 history_len: usize,
989 usage: KeepWarmUsage,
990 hook_calls: (usize, usize, usize),
992 }
993
994 async fn ping_then_next_turn(rt: &Runtime) -> Observed {
999 let (url, mock) = spawn_mock().await;
1000 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1001 let calls = Arc::new(AtomicUsize::new(0));
1002 let mut perms = PermissionSet::new();
1003 perms.grant(Permission::LlmContent);
1004 rt.hook_bus()
1005 .subscribe(
1006 HookKind::BeforeMessage,
1007 Arc::new(CountingInject(Arc::clone(&calls))),
1008 None,
1009 None,
1010 perms,
1011 )
1012 .await
1013 .unwrap();
1014
1015 assert_eq!(rt.cache_anchor(), None, "no request has run yet");
1016 let history = real_turn(rt, vec![user("hello")]).await;
1018 assert_eq!(history.last().unwrap()["role"], "assistant");
1019 let turn_anchor = rt
1020 .cache_anchor()
1021 .expect("an accepted turn request stamps the anchor");
1022 let before_ping = calls.load(Ordering::SeqCst);
1023 let snapshot = history.clone();
1024
1025 let fingerprint = rt
1026 .cache_keepwarm_probe(&history)
1027 .await
1028 .expect("probe")
1029 .fingerprint;
1030 assert_eq!(
1031 mock.bodies.lock().unwrap().len(),
1032 1,
1033 "a probe sends nothing"
1034 );
1035 assert_eq!(
1036 rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1037 fingerprint,
1038 "the fingerprint is deterministic"
1039 );
1040
1041 let tap = PingTap::default();
1042 let report = rt
1043 .cache_keepwarm_ping(&history, fingerprint, &CancellationToken::new(), &tap)
1044 .await;
1045 assert_eq!(report.result, Ok(PingOutcome::Sent), "{report:?}");
1046 assert!(tap.was_sent(), "the tap saw the request leave");
1047 assert_eq!(tap.usage(), report.usage, "the tap saw the usage");
1048 let usage = report.usage;
1049 assert!(
1050 usage.output_tokens >= 1,
1051 "usage comes back from the private channel"
1052 );
1053 let ping_anchor = rt.cache_anchor().expect("anchor");
1054 assert!(
1055 ping_anchor.mono >= turn_anchor.mono,
1056 "the ping moved the anchor"
1057 );
1058 let after_ping = calls.load(Ordering::SeqCst);
1059 assert_eq!(history, snapshot, "the ping must not mutate history");
1060
1061 let mut next = history.clone();
1063 next.push(user("next question"));
1064 real_turn(rt, next).await;
1065 let after_next = calls.load(Ordering::SeqCst);
1066
1067 let bodies = mock.bodies.lock().unwrap().clone();
1068 assert_eq!(bodies.len(), 3, "previous turn, ping, next turn");
1069 Observed {
1070 ping: serde_json::from_str(&bodies[1]).unwrap(),
1071 real: serde_json::from_str(&bodies[2]).unwrap(),
1072 raw_ping: bodies[1].clone(),
1073 history_len: history.len(),
1074 usage,
1075 hook_calls: (before_ping, after_ping, after_next),
1076 }
1077 }
1078
1079 fn assert_prefix_identical(
1080 ping: &Value,
1081 real: &Value,
1082 n: usize,
1083 history_marker: &Value,
1084 ping_max_tokens: u64,
1085 ) {
1086 for key in [
1088 "model",
1089 "tools",
1090 "system",
1091 "thinking",
1092 "output_config",
1093 "stream",
1094 ] {
1095 assert_eq!(
1096 canon(&ping[key]),
1097 canon(&real[key]),
1098 "`{key}` differs between ping and the real next turn"
1099 );
1100 }
1101 let pm = ping["messages"].as_array().unwrap();
1102 let rm = real["messages"].as_array().unwrap();
1103 assert_eq!(pm.len(), n + 1, "history + one trailing ping message");
1104 assert!(rm.len() > n);
1105 for i in 0..n - 1 {
1107 assert_eq!(canon(&pm[i]), canon(&rm[i]), "message {i} differs");
1108 }
1109 let last = &pm[n - 1];
1112 let marker = &last["content"].as_array().unwrap().last().unwrap()["cache_control"];
1113 assert_eq!(marker, history_marker);
1114 assert_eq!(
1115 canon(&strip_cache_control(last.clone())),
1116 canon(&rm[n - 1]),
1117 "last history message differs beyond the cache marker"
1118 );
1119 assert_eq!(marker_count(&pm[n]), 0, "ping message must be unmarked");
1121 assert_eq!(pm[n]["role"], "user");
1122 assert_eq!(pm[n]["content"], KEEPWARM_PING_TEXT);
1123 assert_eq!(ping["max_tokens"], json!(ping_max_tokens));
1125 assert!(real["max_tokens"].as_u64().unwrap() > ping_max_tokens);
1126 assert!(
1127 marker_count(ping) <= 4,
1128 "Anthropic allows at most 4 breakpoints"
1129 );
1130 }
1131
1132 fn marker_1h() -> Value {
1133 json!({"type": "ephemeral", "ttl": "1h"})
1134 }
1135
1136 fn marker_5m() -> Value {
1137 json!({"type": "ephemeral"})
1138 }
1139
1140 fn opus(ttl: CacheTtl) -> Runtime {
1141 let mut rt = Runtime::new_headless();
1142 rt.set_model("claude-opus-4-7".to_string());
1143 assert_eq!(rt.model(), "claude-opus-4-7");
1144 rt.set_reasoning_level_explicit(ReasoningLevel::High);
1145 rt.set_cache_ttl(ttl);
1146 rt.set_system_prompt("You are a keep-warm test.".into());
1147 assert_eq!(rt.cache_keepwarm_ineligibility(), None);
1148 rt
1149 }
1150
1151 #[tokio::test]
1155 #[serial_test::serial(synaps_base_dir)]
1156 async fn ping_prefix_matches_next_turn_with_adaptive_thinking() {
1157 let _base = crate::test_env::BaseDirGuard::new();
1158 let rt = opus(CacheTtl::OneHour);
1159
1160 let o = ping_then_next_turn(&rt).await;
1161 assert!(
1162 !o.ping["thinking"].is_null(),
1163 "thinking must stay on: {}",
1164 o.ping
1165 );
1166 assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_1h(), 1);
1167
1168 let (before, after_ping, after_next) = o.hook_calls;
1170 assert!(
1171 before >= 1,
1172 "control: the previous turn fired before_message"
1173 );
1174 assert_eq!(after_ping, before, "the ping fired a hook");
1175 assert!(
1176 after_next > after_ping,
1177 "control: the next turn fired before_message"
1178 );
1179 assert!(!o.raw_ping.contains("PER-TURN-INJECTED-CONTEXT"));
1180 assert!(!o.raw_ping.contains("Extension context"));
1181 assert!(canon(&o.real).contains("PER-TURN-INJECTED-CONTEXT"));
1183 }
1184
1185 #[tokio::test]
1187 #[serial_test::serial(synaps_base_dir)]
1188 async fn ping_prefix_matches_next_turn_with_5m_ttl() {
1189 let _base = crate::test_env::BaseDirGuard::new();
1190 let rt = opus(CacheTtl::FiveMinutes);
1191 let o = ping_then_next_turn(&rt).await;
1192 assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_5m(), 1);
1193 }
1194
1195 #[tokio::test]
1198 #[serial_test::serial(synaps_base_dir)]
1199 async fn ping_prefix_matches_next_turn_with_hybrid_ttl() {
1200 let _base = crate::test_env::BaseDirGuard::new();
1201 let rt = opus(CacheTtl::Hybrid);
1202 let o = ping_then_next_turn(&rt).await;
1203 assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_5m(), 1);
1204 assert!(
1205 canon(&o.ping["system"]).contains("\"ttl\":\"1h\""),
1206 "hybrid keeps the 1h stable-prefix marker: {}",
1207 o.ping["system"]
1208 );
1209 }
1210
1211 #[tokio::test]
1213 #[serial_test::serial(synaps_base_dir)]
1214 async fn ping_prefix_matches_next_turn_with_thinking_off() {
1215 let _base = crate::test_env::BaseDirGuard::new();
1216 let mut rt = Runtime::new_headless();
1217 rt.set_model("claude-sonnet-4-6".to_string());
1218 rt.set_reasoning_level_explicit(ReasoningLevel::Off);
1219 rt.set_cache_ttl(CacheTtl::OneHour);
1220 assert_eq!(rt.cache_keepwarm_ineligibility(), None);
1221
1222 let o = ping_then_next_turn(&rt).await;
1223 assert!(o.ping.get("thinking").is_none(), "thinking off is omitted");
1224 assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_1h(), 1);
1225 }
1226
1227 #[tokio::test]
1232 #[serial_test::serial(synaps_base_dir)]
1233 async fn budgeted_thinking_ping_keeps_budget_and_is_costed() {
1234 let _base = crate::test_env::BaseDirGuard::new();
1235 let mut rt = Runtime::new_headless();
1236 rt.set_model("claude-sonnet-4-6".to_string());
1237 rt.set_reasoning_level_explicit(ReasoningLevel::High);
1238 rt.set_cache_ttl(CacheTtl::FiveMinutes);
1239 assert_eq!(
1240 rt.cache_keepwarm_ineligibility(),
1241 None,
1242 "budgeted thinking is eligible"
1243 );
1244
1245 let o = ping_then_next_turn(&rt).await;
1246 assert_eq!(o.ping["thinking"]["type"], "enabled", "{}", o.ping);
1247 let budget = o.ping["thinking"]["budget_tokens"]
1248 .as_u64()
1249 .expect("budget");
1250 assert_eq!(canon(&o.ping["thinking"]), canon(&o.real["thinking"]));
1251 assert_prefix_identical(
1252 &o.ping,
1253 &o.real,
1254 o.history_len,
1255 &marker_5m(),
1256 budget + KEEPWARM_OUTPUT_TOKENS,
1257 );
1258 assert!(
1259 o.usage.cost_usd("claude-sonnet-4-6") > 0.0,
1260 "the ping is costed"
1261 );
1262 }
1263
1264 #[tokio::test]
1268 #[serial_test::serial(synaps_base_dir)]
1269 async fn changed_prefix_is_refused_before_the_network() {
1270 let _base = crate::test_env::BaseDirGuard::new();
1271 let (url, mock) = spawn_mock().await;
1272 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1273 let mut rt = opus(CacheTtl::FiveMinutes);
1274 let history = real_turn(&rt, vec![user("hello")]).await;
1275 let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1276 let sent = mock.bodies.lock().unwrap().len();
1277
1278 let mut longer = history.clone();
1280 longer.push(user("more"));
1281 longer.push(Arc::new(
1282 json!({"role": "assistant", "content": [{"type": "text", "text": "x"}]}),
1283 ));
1284 let changed = |r: PingReport| matches!(r.result, Ok(PingOutcome::PrefixChanged { .. }));
1285 let tap = PingTap::default();
1286 assert!(changed(
1287 rt.cache_keepwarm_ping(&longer, fp, &CancellationToken::new(), &tap)
1288 .await
1289 ));
1290
1291 rt.set_system_prompt("A different system prompt.".into());
1292 assert_ne!(
1293 rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1294 fp
1295 );
1296 assert!(changed(
1297 rt.cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &tap)
1298 .await
1299 ));
1300 rt.set_system_prompt("You are a keep-warm test.".into());
1301 assert_eq!(
1302 rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1303 fp,
1304 "restored"
1305 );
1306
1307 rt.set_reasoning_level_explicit(ReasoningLevel::Low);
1308 assert_ne!(
1309 rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1310 fp,
1311 "effort is in the key"
1312 );
1313 rt.set_reasoning_level_explicit(ReasoningLevel::High);
1314
1315 rt.set_model("claude-opus-4-6".to_string());
1316 assert_eq!(rt.model(), "claude-opus-4-6");
1317 assert_ne!(
1318 rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1319 fp,
1320 "model is in the key"
1321 );
1322
1323 assert_eq!(
1324 mock.bodies.lock().unwrap().len(),
1325 sent,
1326 "no refused ping reached the provider"
1327 );
1328 assert!(!tap.was_sent(), "a refused ping never leaves");
1329 }
1330
1331 #[tokio::test]
1332 #[serial_test::serial(synaps_base_dir)]
1333 async fn http_4xx_is_fatal_and_5xx_is_transient() {
1334 let _base = crate::test_env::BaseDirGuard::new();
1335 let history = vec![
1336 user("hello"),
1337 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1338 ];
1339 for (status, fatal) in [(400u16, true), (404, true), (500, false), (429, false)] {
1340 let (url, mock) = spawn_mock_with(status).await;
1341 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1342 let rt = opus(CacheTtl::FiveMinutes);
1343 let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1344 let err = rt
1345 .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &PingTap::default())
1346 .await
1347 .result
1348 .expect_err("non-2xx fails");
1349 assert_eq!(
1350 matches!(err, PingFailure::Fatal(_)),
1351 fatal,
1352 "HTTP {status}: {err:?}"
1353 );
1354 assert_eq!(
1355 mock.bodies.lock().unwrap().len(),
1356 1,
1357 "HTTP {status}: no retry"
1358 );
1359 assert_eq!(
1360 rt.cache_anchor(),
1361 None,
1362 "a rejected request refreshes nothing"
1363 );
1364 }
1365 }
1366
1367 async fn assert_identity_1h(rt: &Runtime) -> Observed {
1371 let o = ping_then_next_turn(rt).await;
1372 assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_1h(), 1);
1373 o
1374 }
1375
1376 #[tokio::test]
1380 #[serial_test::serial(synaps_base_dir)]
1381 async fn ping_prefix_matches_next_turn_with_progressive_disclosure() {
1382 let _base = crate::test_env::BaseDirGuard::new();
1383 let mut rt = opus(CacheTtl::OneHour);
1384 rt.progressive_tool_disclosure = true;
1385 assert_identity_1h(&rt).await;
1386 }
1387
1388 #[tokio::test]
1391 #[serial_test::serial(synaps_base_dir)]
1392 async fn ping_prefix_matches_next_turn_with_deferred_tools() {
1393 let _base = crate::test_env::BaseDirGuard::new();
1394 let mut rt = opus(CacheTtl::OneHour);
1395 rt.progressive_tool_disclosure = true;
1396 rt.deferred_tool_loading = true;
1397 assert_identity_1h(&rt).await;
1398 }
1399
1400 #[tokio::test]
1406 #[serial_test::serial(synaps_base_dir)]
1407 async fn ping_projects_carried_activations_the_turn_start_restores() {
1408 use crate::tools::activation::{activate_exact_for_user, SessionId, SessionToolSet};
1409 use crate::tools::catalog::ToolId;
1410 let _base = crate::test_env::BaseDirGuard::new();
1411 let (url, mock) = spawn_mock().await;
1412 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1413 let mut rt = opus(CacheTtl::OneHour);
1414 rt.progressive_tool_disclosure = true;
1415 rt.deferred_tool_loading = true;
1416 let carried = {
1417 let registry = rt.tools.read().await;
1418 let mut previous = SessionToolSet::progressive_core_for_catalog(
1419 SessionId::parse("previous-runtime").unwrap(),
1420 registry.catalog(),
1421 );
1422 activate_exact_for_user(
1423 &mut previous,
1424 registry.catalog(),
1425 &ToolId::builtin("subagent_status"),
1426 )
1427 .unwrap();
1428 previous.carried_activations()
1429 };
1430 rt.restore_tool_activations(carried);
1431
1432 let history = vec![
1433 user("hello"),
1434 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1435 ];
1436 let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1437 let report = rt
1438 .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &PingTap::default())
1439 .await;
1440 assert_eq!(report.result, Ok(PingOutcome::Sent), "{report:?}");
1441 let mut next = history.clone();
1442 next.push(user("next"));
1443 real_turn(&rt, next).await;
1444
1445 let bodies: Vec<Value> = mock
1446 .bodies
1447 .lock()
1448 .unwrap()
1449 .iter()
1450 .map(|b| serde_json::from_str(b).unwrap())
1451 .collect();
1452 let (ping, real) = (&bodies[0], &bodies[1]);
1453 let loaded = |b: &Value| -> Vec<Value> {
1457 b["tools"]
1458 .as_array()
1459 .unwrap()
1460 .iter()
1461 .filter(|t| t.get("defer_loading").is_none())
1462 .cloned()
1463 .collect()
1464 };
1465 assert_eq!(
1466 serde_json::to_string(&loaded(ping)).unwrap(),
1467 serde_json::to_string(&loaded(real)).unwrap()
1468 );
1469 assert!(
1470 real["tools"].as_array().unwrap().iter().any(|t| {
1471 t["name"] == "subagent_status" && t.get("defer_loading").is_none()
1472 }),
1473 "the restored activation is a loaded tool: {}",
1474 real["tools"]
1475 );
1476 }
1477
1478 #[tokio::test]
1481 #[serial_test::serial(synaps_base_dir)]
1482 async fn ping_prefix_matches_next_turn_with_context_management_guidance() {
1483 let _base = crate::test_env::BaseDirGuard::new();
1484 let rt = opus(CacheTtl::OneHour);
1485 rt.continuation.lock().unwrap().config.mode =
1486 crate::core::config::ContextManagementMode::Auto;
1487 let o = assert_identity_1h(&rt).await;
1488 assert!(
1489 canon(&o.ping["system"]).contains("Work normally."),
1490 "guidance present: {}",
1491 o.ping["system"]
1492 );
1493 }
1494
1495 #[tokio::test]
1498 #[serial_test::serial(synaps_base_dir)]
1499 async fn ping_prefix_matches_next_turn_with_session_injection() {
1500 let _base = crate::test_env::BaseDirGuard::new();
1501 let mut rt = opus(CacheTtl::OneHour);
1502 rt.set_session_id(Some("kw-session".into()));
1503 rt.hook_bus()
1504 .set_session_injection_for("kw-session", "SESSION-SCOPED-INJECTION".into())
1505 .await;
1506 let o = assert_identity_1h(&rt).await;
1507 assert!(canon(&o.ping["system"]).contains("SESSION-SCOPED-INJECTION"));
1508 }
1509
1510 fn bind_oauth(rt: &mut Runtime) {
1514 rt.auth = Arc::new(tokio::sync::RwLock::new(super::super::types::AuthState {
1515 auth_token: "oauth-test-token".to_string(),
1516 auth_type: "oauth".to_string(),
1517 refresh_token: None,
1518 token_expires: Some(crate::epoch_millis() + 3_600_000),
1519 auth_file_mtime: None,
1520 bound_credential: Some(crate::runtime::auth::anthropic_binding(
1521 &crate::auth::CredentialSource::Local,
1522 &crate::auth::CredentialRef::default_for(crate::auth::OAuthProviderId::Anthropic),
1523 )),
1524 }));
1525 }
1526
1527 #[tokio::test]
1532 #[serial_test::serial(synaps_base_dir)]
1533 async fn an_idle_ping_on_a_cooling_account_sends_nothing() {
1534 let _base = crate::test_env::BaseDirGuard::new();
1535 let (url, mock) = spawn_mock().await;
1536 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1537 let mut rt = opus(CacheTtl::OneHour);
1538 bind_oauth(&mut rt);
1539 let history = real_turn(&rt, vec![user("hello")]).await;
1540 let fingerprint = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1541 let seat = crate::auth::CredentialRef::default_for(crate::auth::OAuthProviderId::Anthropic);
1542 crate::auth::broker_from_source(&rt.credential_source, &rt.token_cache, reqwest::Client::new())
1543 .report_cooldown(&seat, Some(crate::epoch_millis() + 3_600_000), "test")
1544 .await
1545 .unwrap();
1546 let bound = rt.auth.read().await.bound_credential.clone();
1547
1548 let tap = PingTap::default();
1549 let report = rt
1550 .cache_keepwarm_ping(&history, fingerprint, &CancellationToken::new(), &tap)
1551 .await;
1552 match &report.result {
1553 Err(PingFailure::Fatal(why)) => assert!(why.contains("account cooling"), "{why}"),
1554 other => panic!("the ping stops on the cooling account: {other:?}"),
1555 }
1556 assert!(!tap.was_sent());
1557 assert!(report.notices.is_empty(), "{:?}", report.notices);
1558 assert_eq!(mock.bodies.lock().unwrap().len(), 1, "only the turn was sent");
1559 assert_eq!(rt.auth.read().await.bound_credential, bound, "not re-bound");
1560 assert_eq!(rt.auth.read().await.auth_token, "oauth-test-token");
1561 }
1562
1563 fn markers(v: &Value) -> Vec<Value> {
1565 match v {
1566 Value::Object(map) => map
1567 .iter()
1568 .flat_map(|(k, v)| {
1569 if k == "cache_control" {
1570 vec![v.clone()]
1571 } else {
1572 markers(v)
1573 }
1574 })
1575 .collect(),
1576 Value::Array(items) => items.iter().flat_map(markers).collect(),
1577 _ => Vec::new(),
1578 }
1579 }
1580
1581 #[tokio::test]
1587 #[serial_test::serial(synaps_base_dir)]
1588 async fn auto_ttl_wire_markers_follow_the_credential_kind() {
1589 use crate::core::config::CacheTtlSetting;
1590 let cases = [
1591 (CacheTtlSetting::Auto, true, CacheTtl::OneHour),
1592 (CacheTtlSetting::Auto, false, CacheTtl::FiveMinutes),
1593 (CacheTtl::FiveMinutes.into(), true, CacheTtl::FiveMinutes),
1594 (CacheTtl::OneHour.into(), false, CacheTtl::OneHour),
1595 ];
1596 for (setting, oauth, expect) in cases {
1597 let ctx = format!("{setting} on {}", if oauth { "oauth" } else { "api_key" });
1598 let observe = |setting: CacheTtlSetting| async move {
1599 let _base = crate::test_env::BaseDirGuard::new();
1600 let mut rt = opus(CacheTtl::FiveMinutes);
1601 rt.set_cache_ttl(setting);
1602 if oauth {
1603 bind_oauth(&mut rt);
1604 }
1605 let resolved = rt.resolved_cache_ttl().await;
1606 (resolved, ping_then_next_turn(&rt).await)
1607 };
1608 let (resolved, o) = observe(setting).await;
1609 assert_eq!(resolved, expect, "{ctx}");
1610 let want = if expect == CacheTtl::OneHour {
1611 marker_1h()
1612 } else {
1613 marker_5m()
1614 };
1615 assert_prefix_identical(&o.ping, &o.real, o.history_len, &want, 1);
1616 for (name, body) in [("ping", &o.ping), ("next turn", &o.real)] {
1617 let all = markers(body);
1618 assert!(!all.is_empty(), "{ctx}: {name} has no markers");
1619 assert!(
1620 all.iter().all(|m| *m == want),
1621 "{ctx}: {name} markers {all:?}, want all {want}"
1622 );
1623 }
1624 if setting == CacheTtlSetting::Auto {
1625 let (_, fixed) = observe(expect.into()).await;
1626 assert_eq!(canon(&o.ping), canon(&fixed.ping), "{ctx}: ping bytes");
1627 assert_eq!(canon(&o.real), canon(&fixed.real), "{ctx}: turn bytes");
1628 }
1629 }
1630 }
1631
1632 #[tokio::test]
1635 #[serial_test::serial(synaps_base_dir)]
1636 async fn ping_prefix_matches_next_turn_with_oauth_system_blocks() {
1637 let _base = crate::test_env::BaseDirGuard::new();
1638 let mut rt = opus(CacheTtl::OneHour);
1639 bind_oauth(&mut rt);
1640 let o = assert_identity_1h(&rt).await;
1641 assert!(
1642 canon(&o.ping["system"]).contains("You are Claude Code"),
1643 "{}",
1644 o.ping["system"]
1645 );
1646 }
1647
1648 #[tokio::test]
1652 #[serial_test::serial(synaps_base_dir)]
1653 async fn probe_reports_a_context_rollover_ahead() {
1654 let _base = crate::test_env::BaseDirGuard::new();
1655 let (url, _mock) = spawn_mock().await;
1656 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1657 let rt = opus(CacheTtl::OneHour);
1658 rt.continuation.lock().unwrap().config.mode =
1659 crate::core::config::ContextManagementMode::Auto;
1660 let history = real_turn(&rt, vec![user("hello")]).await;
1661 let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1662 assert_eq!(probe.context_blocks, None, "plenty of room");
1663 let before = rt.continuation.lock().unwrap().policy;
1664 rt.continuation.lock().unwrap().config.reserve_tokens = 10_000_000;
1666 let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1667 assert!(probe.context_blocks.is_some(), "{probe:?}");
1668 assert_eq!(
1669 rt.continuation.lock().unwrap().policy,
1670 before,
1671 "the probe never commits policy state"
1672 );
1673 rt.continuation.lock().unwrap().config.mode =
1675 crate::core::config::ContextManagementMode::Off;
1676 assert_eq!(
1677 rt.cache_keepwarm_probe(&history)
1678 .await
1679 .unwrap()
1680 .context_blocks,
1681 None
1682 );
1683 }
1684
1685 #[tokio::test]
1690 #[serial_test::serial(synaps_base_dir)]
1691 async fn probe_warms_an_opus_200k_session_past_the_old_hard_stop() {
1692 let _base = crate::test_env::BaseDirGuard::new();
1693 let (url, _mock) = spawn_mock().await;
1694 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1695 let rt = opus(CacheTtl::OneHour);
1696 assert_eq!(rt.context_window(), 200_000);
1697 rt.continuation.lock().unwrap().config.mode =
1698 crate::core::config::ContextManagementMode::Auto;
1699 let history = real_turn(&rt, vec![user(&"x".repeat(150_000))]).await;
1700 let used = rt.assess_context(&history).await.used_tokens();
1701 assert!((60_000..100_000).contains(&used), "{used}");
1702 let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1703 assert_eq!(probe.context_blocks, None, "{probe:?}");
1704 }
1705
1706 #[tokio::test]
1708 #[serial_test::serial(synaps_base_dir)]
1709 async fn probe_reports_max_tokens() {
1710 let _base = crate::test_env::BaseDirGuard::new();
1711 let history = vec![
1712 user("hello"),
1713 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1714 ];
1715 let rt = opus(CacheTtl::OneHour);
1716 assert_eq!(
1717 rt.cache_keepwarm_probe(&history).await.unwrap().max_tokens,
1718 1
1719 );
1720 let mut rt = Runtime::new_headless();
1721 rt.set_model("claude-sonnet-4-6".to_string());
1722 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
1723 let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1724 let budget = u64::from(rt.thinking_budget);
1725 assert!(budget >= 16_384, "budgeted thinking at xhigh: {budget}");
1726 assert_eq!(
1727 probe.max_tokens,
1728 budget + KEEPWARM_OUTPUT_TOKENS,
1729 "{probe:?}"
1730 );
1731 }
1732
1733 #[tokio::test]
1736 #[serial_test::serial(synaps_base_dir)]
1737 async fn ping_usage_is_returned_on_error() {
1738 let _base = crate::test_env::BaseDirGuard::new();
1739 let (url, mock) = spawn_mock_full(200, SSE_USAGE_THEN_ERROR).await;
1740 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1741 let history = vec![
1742 user("hello"),
1743 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1744 ];
1745 let rt = opus(CacheTtl::OneHour);
1746 let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1747 let tap = PingTap::default();
1748 let report = rt
1749 .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &tap)
1750 .await;
1751 assert!(
1752 matches!(report.result, Err(PingFailure::Transient(_))),
1753 "{report:?}"
1754 );
1755 assert_eq!(report.usage.cache_read_tokens, 5_000, "{report:?}");
1756 assert!(report.usage.cost_usd("claude-opus-4-7") > 0.0);
1757 assert_eq!(tap.usage(), report.usage);
1758 assert!(tap.was_sent());
1759 assert_eq!(mock.bodies.lock().unwrap().len(), 1, "no transport retry");
1760 }
1761
1762 #[tokio::test]
1766 #[serial_test::serial(synaps_base_dir)]
1767 async fn ping_never_carries_or_moves_the_diagnostics_chain() {
1768 let _base = crate::test_env::BaseDirGuard::new();
1769 let (url, mock) = spawn_mock().await;
1770 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1771 let history = vec![
1772 user("hello"),
1773 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1774 ];
1775 let mut rt = opus(CacheTtl::OneHour);
1776 rt.set_cache_diagnostics(true);
1777 rt.cache_diagnosis().unwrap().record("msg_turn");
1778 let mut ping_rt = rt.clone();
1779 ping_rt.share_ttl_latches(&rt);
1780 let fp = ping_rt
1781 .cache_keepwarm_probe(&history)
1782 .await
1783 .unwrap()
1784 .fingerprint;
1785 let report = ping_rt
1786 .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &PingTap::default())
1787 .await;
1788 assert!(report.result.is_ok(), "{report:?}");
1789 let bodies = mock.bodies.lock().unwrap().clone();
1790 assert_eq!(bodies.len(), 1);
1791 assert!(!bodies[0].contains("\"diagnostics\""), "{}", bodies[0]);
1792 assert_eq!(
1793 rt.cache_diagnosis()
1794 .unwrap()
1795 .previous_message_id()
1796 .as_deref(),
1797 Some("msg_turn"),
1798 "the ping's msg_kw never enters the chain"
1799 );
1800 }
1801
1802 #[test]
1806 fn diagnostics_chain_is_shared_like_the_ttl_latches() {
1807 let mut rt = opus(CacheTtl::FiveMinutes);
1808 assert!(rt.cache_diagnosis().is_none(), "off: wire unchanged");
1809 rt.set_cache_diagnostics(true);
1810 rt.cache_diagnosis().unwrap().record("msg_a");
1811 let fresh = rt.clone();
1812 assert_eq!(fresh.cache_diagnosis().unwrap().previous_message_id(), None);
1813 let mut shared = rt.clone();
1814 shared.share_ttl_latches(&rt);
1815 shared.cache_diagnosis().unwrap().record("msg_b");
1816 shared.cache_diagnosis().unwrap().reject(400);
1817 let chain = rt.cache_diagnosis().unwrap();
1818 assert_eq!(chain.previous_message_id().as_deref(), Some("msg_b"));
1819 assert!(!chain.active(), "the rejection latch is session-wide");
1820 let fresh_chain = fresh.cache_diagnosis().unwrap();
1823 assert!(!fresh_chain.active(), "latch shared across Clone");
1824 assert_eq!(fresh_chain.previous_message_id(), None);
1825 assert!(!rt.clone().cache_diagnosis().unwrap().active());
1826 }
1827
1828 #[test]
1831 fn ping_shares_the_session_ttl_latch() {
1832 let rt = opus(CacheTtl::OneHour);
1833 assert!(!rt.cache_ttl_downgraded());
1834 let mut ping_rt = rt.clone();
1835 ping_rt.share_ttl_latches(&rt);
1836 ping_rt
1837 .ttl_downgrade_notified
1838 .store(true, Ordering::Relaxed);
1839 assert!(rt.cache_ttl_downgraded(), "the latch is shared");
1840 }
1841
1842 #[test]
1843 fn failure_classification() {
1844 let api = || RuntimeError::ApiStatus("x".into());
1845 assert_eq!(
1846 classify_failure(&RuntimeError::Canceled, 0),
1847 PingFailure::Canceled
1848 );
1849 assert!(matches!(
1850 classify_failure(&api(), 400),
1851 PingFailure::Fatal(_)
1852 ));
1853 assert!(matches!(
1854 classify_failure(&api(), 401),
1855 PingFailure::Fatal(_)
1856 ));
1857 assert!(matches!(
1858 classify_failure(&api(), 429),
1859 PingFailure::Transient(_)
1860 ));
1861 assert!(matches!(
1862 classify_failure(&api(), 529),
1863 PingFailure::Transient(_)
1864 ));
1865 for status in [408, 504, 520, 524] {
1867 assert!(
1868 matches!(classify_failure(&api(), status), PingFailure::Transient(_)),
1869 "HTTP {status}"
1870 );
1871 }
1872 assert!(matches!(
1873 classify_failure(&api(), 0),
1874 PingFailure::Transient(_)
1875 ));
1876 assert!(matches!(
1878 classify_failure(&api(), 200),
1879 PingFailure::Transient(_)
1880 ));
1881 assert!(matches!(
1882 classify_failure(&RuntimeError::Config("x".into()), 0),
1883 PingFailure::Fatal(_)
1884 ));
1885 }
1886
1887 #[test]
1891 fn network_timeout_is_transient() {
1892 for msg in ["upload/header deadline", "stream idle"] {
1893 assert_eq!(
1894 classify_failure(&RuntimeError::NetworkTimeout(msg), 0),
1895 PingFailure::Transient("network".to_string()),
1896 "{msg}"
1897 );
1898 }
1899 }
1900
1901 #[test]
1902 fn every_ttl_and_thinking_mode_is_eligible() {
1903 for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour, CacheTtl::Hybrid] {
1904 for (model, level) in [
1905 ("claude-opus-4-7", ReasoningLevel::High),
1906 ("claude-sonnet-4-6", ReasoningLevel::High),
1907 ("claude-sonnet-4-6", ReasoningLevel::Off),
1908 ] {
1909 let mut rt = Runtime::new_headless();
1910 rt.set_model(model.to_string());
1911 rt.set_reasoning_level_explicit(level);
1912 rt.set_cache_ttl(ttl);
1913 assert_eq!(
1914 rt.cache_keepwarm_ineligibility(),
1915 None,
1916 "{model} {level:?} {ttl:?}"
1917 );
1918 }
1919 }
1920 }
1921
1922 #[test]
1923 fn non_anthropic_models_are_ineligible() {
1924 let mut rt = Runtime::new_headless();
1925 rt.set_model("groq/llama-3.3-70b-versatile".to_string());
1926 if rt.model() == "groq/llama-3.3-70b-versatile" {
1927 assert!(rt.cache_keepwarm_ineligibility().is_some());
1928 }
1929 }
1930
1931 #[tokio::test]
1934 async fn ineligible_ping_sends_nothing() {
1935 let mut rt = Runtime::new_headless();
1936 rt.set_model("groq/llama-3.3-70b-versatile".to_string());
1937 if rt.model() != "groq/llama-3.3-70b-versatile" {
1938 return;
1939 }
1940 let history = vec![
1941 user("hello"),
1942 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1943 ];
1944 let tap = PingTap::default();
1945 let err = rt
1946 .cache_keepwarm_ping(&history, 0, &CancellationToken::new(), &tap)
1947 .await
1948 .result
1949 .expect_err("non-Anthropic must refuse");
1950 assert!(!tap.was_sent());
1951 assert!(
1952 matches!(err, PingFailure::Fatal(ref m) if m.contains("keep-warm skipped")),
1953 "{err:?}"
1954 );
1955 }
1956
1957 struct CountingProvider(AtomicUsize);
1959 #[async_trait::async_trait]
1960 impl crate::extensions::runtime::ExtensionHandler for CountingProvider {
1961 fn id(&self) -> &str {
1962 "plug"
1963 }
1964 async fn handle(&self, _event: &HookEvent) -> HookResult {
1965 HookResult::Continue
1966 }
1967 async fn shutdown(&self) {}
1968 async fn provider_complete(
1969 &self,
1970 _params: crate::extensions::runtime::process::ProviderCompleteParams,
1971 ) -> std::result::Result<
1972 crate::extensions::runtime::process::ProviderCompleteResult,
1973 String,
1974 > {
1975 self.0.fetch_add(1, Ordering::SeqCst);
1976 Ok(crate::extensions::runtime::process::ProviderCompleteResult {
1977 content: vec![json!({"type": "text", "text": "hi"})],
1978 stop_reason: None,
1979 usage: None,
1980 })
1981 }
1982 }
1983
1984 #[tokio::test]
1993 #[serial_test::serial(synaps_base_dir, extension_routing)]
1994 async fn a_chat_switched_to_an_extension_model_sends_the_extension_no_keepwarm_request() {
1995 use crate::extensions::manager::ExtensionManager;
1996 use crate::extensions::runtime::process::{
1997 RegisteredProviderModelSpec, RegisteredProviderSpec,
1998 };
1999 const EXTENSION_MODEL: &str = "plug:prov:model-x";
2000 let _base = crate::test_env::BaseDirGuard::new();
2001 let (url, mock) = spawn_mock().await;
2002 let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
2003 let mut rt = opus(CacheTtl::OneHour);
2004 let provider = Arc::new(CountingProvider(AtomicUsize::new(0)));
2005 let mut manager = ExtensionManager::new_with_tools(rt.hook_bus.clone(), rt.tools.clone());
2006 manager
2007 .register_provider_handler_for_tests(
2008 "plug",
2009 RegisteredProviderSpec {
2010 id: "prov".into(),
2011 display_name: "Counting".into(),
2012 description: "counts its requests".into(),
2013 models: vec![RegisteredProviderModelSpec {
2014 id: "model-x".into(),
2015 display_name: None,
2016 capabilities: json!({"streaming": false, "tool_use": true}),
2017 context_window: None,
2018 }],
2019 config_schema: None,
2020 },
2021 provider.clone(),
2022 )
2023 .unwrap();
2024 crate::runtime::openai::set_extension_manager_for_routing(Arc::new(
2025 tokio::sync::RwLock::new(manager),
2026 ));
2027 let history = vec![
2028 user("hello"),
2029 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
2030 ];
2031
2032 let fingerprint = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
2034 assert_eq!(provider.0.load(Ordering::SeqCst), 0);
2035 assert!(mock.bodies.lock().unwrap().is_empty());
2036
2037 rt.set_model(EXTENSION_MODEL.to_string());
2038 assert_eq!(rt.model(), EXTENSION_MODEL);
2039 let ineligible = rt.cache_keepwarm_ineligibility();
2040 let probe = rt.cache_keepwarm_probe(&history).await;
2041 let tap = PingTap::default();
2042 let ping = rt
2043 .cache_keepwarm_ping(&history, fingerprint, &CancellationToken::new(), &tap)
2044 .await
2045 .result;
2046 crate::runtime::openai::clear_extension_manager_for_routing();
2047
2048 assert_eq!(
2049 provider.0.load(Ordering::SeqCst),
2050 0,
2051 "the extension was sent a request for a probe or a ping"
2052 );
2053 assert!(mock.bodies.lock().unwrap().is_empty(), "nor was Anthropic");
2054 assert!(
2055 ineligible
2056 .as_deref()
2057 .is_some_and(|why| why.contains("not on the native Anthropic transport")),
2058 "{ineligible:?}"
2059 );
2060 let refused = probe.expect_err("no probe for an extension's model").to_string();
2061 assert!(refused.contains("keep-warm skipped"), "{refused}");
2062 assert!(
2063 matches!(ping, Err(PingFailure::Fatal(ref m)) if m.contains("keep-warm skipped")),
2064 "{ping:?}"
2065 );
2066 assert!(!tap.was_sent());
2067 }
2068
2069 #[test]
2070 fn anchor_cell_only_moves_forward() {
2071 let cell = CacheAnchorCell::default();
2072 assert_eq!(cell.get(), None);
2073 let a = CacheAnchor::now();
2074 let later = CacheAnchor {
2075 mono: a.mono + std::time::Duration::from_secs(5),
2076 wall: a.wall + std::time::Duration::from_secs(5),
2077 };
2078 cell.stamp(later);
2079 cell.stamp(a);
2080 assert_eq!(
2081 cell.get(),
2082 Some(later),
2083 "a late-arriving older stamp never rewinds"
2084 );
2085 }
2086
2087 #[test]
2088 fn only_a_finished_assistant_text_reply_is_markable() {
2089 assert!(tail_is_markable(
2090 &json!({"role": "assistant", "content": [{"type": "thinking", "thinking": "t"}, {"type": "text", "text": "ok"}]})
2091 ));
2092 assert!(!tail_is_markable(&json!({"role": "user", "content": "q"})));
2093 assert!(!tail_is_markable(
2094 &json!({"role": "assistant", "content": [{"type": "tool_use", "id": "t", "name": "x", "input": {}}]})
2095 ));
2096 assert!(!tail_is_markable(
2097 &json!({"role": "assistant", "content": [{"type": "thinking", "thinking": "t"}]})
2098 ));
2099 }
2100
2101 #[test]
2102 fn usage_is_summed_and_priced() {
2103 let mut u = KeepWarmUsage::default();
2104 u.add(&StreamEvent::Session(SessionEvent::Usage {
2105 input_tokens: 20,
2106 output_tokens: 16,
2107 cache_read_input_tokens: 500_000,
2108 cache_creation_input_tokens: 0,
2109 cache_creation_5m: None,
2110 cache_creation_1h: Some(0),
2111 model: Some("claude-opus-4-7".into()),
2112 }));
2113 assert_eq!(u.cache_read_tokens, 500_000);
2114 assert_eq!(u.output_tokens, 16);
2115 let cost = u.cost_usd("claude-opus-4-7");
2117 assert!((cost - 0.2505).abs() < 0.001, "{cost}");
2118 }
2119
2120 #[test]
2122 fn booking_rule_table() {
2123 use PingBooking::*;
2124 let table = [
2126 ((true, true, false, 200), Measured),
2127 ((true, false, false, 0), Measured),
2128 ((true, true, false, 529), Measured),
2129 ((false, true, false, 200), WorstCase), ((false, true, false, 0), WorstCase), ((false, true, false, 500), Nothing), ((false, true, false, 429), Nothing),
2133 ((false, true, false, 529), Nothing),
2134 ((false, true, true, 0), Nothing), ((false, true, true, 503), Nothing), ((false, false, false, 0), Nothing), ((false, false, true, 0), Nothing),
2138 ];
2139 for ((usage, sent, connect, status), want) in table {
2140 assert_eq!(
2141 ping_booking(usage, sent, connect, status),
2142 want,
2143 "usage={usage} sent={sent} connect_failed={connect} status={status}"
2144 );
2145 }
2146 let tap = PingTap::default();
2148 assert_eq!(tap.bill("claude-opus-4-7", 0.5).cost_usd, 0.0);
2149 tap.sent.store(true, Ordering::Relaxed);
2150 let bill = tap.bill("claude-opus-4-7", 0.5);
2151 assert_eq!((bill.booking, bill.cost_usd), (WorstCase, 0.5));
2152 tap.usage.lock().unwrap().cache_read_tokens = 1_000_000;
2153 let bill = tap.bill("claude-opus-4-7", 0.5);
2154 assert_eq!(bill.booking, Measured);
2155 assert!((bill.cost_usd - 0.5).abs() < 1e-9, "1M reads at $0.50/MTok");
2156 }
2157
2158 fn two_message_history() -> Vec<SharedMessage> {
2159 vec![
2160 user("hello"),
2161 Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
2162 ]
2163 }
2164
2165 #[tokio::test]
2167 #[serial_test::serial(synaps_base_dir)]
2168 async fn a_refused_connection_is_never_sent_and_books_nothing() {
2169 let _base = crate::test_env::BaseDirGuard::new();
2170 let addr = {
2171 let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2172 l.local_addr().unwrap()
2173 }; let _url = crate::test_env::EnvVarGuard::set(
2175 "SYNAPS_ANTHROPIC_BASE_URL",
2176 &format!("http://{addr}"),
2177 );
2178 let history = two_message_history();
2179 let rt = opus(CacheTtl::OneHour);
2180 let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
2181 let tap = PingTap::default();
2182 let report = rt
2183 .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &tap)
2184 .await;
2185 assert!(report.result.is_err(), "{report:?}");
2186 assert!(!tap.was_sent(), "never dispatched");
2187 assert!(tap.connect_failed.load(Ordering::Relaxed));
2188 let bill = tap.bill("claude-opus-4-7", 0.75);
2189 assert_eq!((bill.booking, bill.cost_usd), (PingBooking::Nothing, 0.0));
2190 }
2191
2192 #[tokio::test]
2195 #[serial_test::serial(synaps_base_dir)]
2196 async fn a_dispatched_ping_without_usage_books_the_worst_case() {
2197 use tokio::io::AsyncReadExt;
2198 let _base = crate::test_env::BaseDirGuard::new();
2199 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2200 let addr = listener.local_addr().unwrap();
2201 tokio::spawn(async move {
2203 let (mut sock, _) = listener.accept().await.unwrap();
2204 let mut buf = vec![0u8; 64 * 1024];
2205 while sock.read(&mut buf).await.is_ok_and(|n| n > 0) {}
2206 });
2207 let _url = crate::test_env::EnvVarGuard::set(
2208 "SYNAPS_ANTHROPIC_BASE_URL",
2209 &format!("http://{addr}"),
2210 );
2211 let history = two_message_history();
2212 let rt = opus(CacheTtl::OneHour);
2213 let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
2214 let tap = PingTap::default();
2215 let cancel = CancellationToken::new();
2216 let ping = {
2217 let (rt, history, tap, cancel) =
2218 (rt.clone(), history.clone(), tap.clone(), cancel.clone());
2219 tokio::spawn(async move { rt.cache_keepwarm_ping(&history, fp, &cancel, &tap).await })
2220 };
2221 tokio::time::timeout(std::time::Duration::from_secs(10), async {
2222 while !tap.was_sent() {
2223 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2224 }
2225 })
2226 .await
2227 .expect("the body was pulled over the established connection");
2228 cancel.cancel();
2229 let report = ping.await.unwrap();
2230 assert_eq!(report.result, Err(PingFailure::Canceled));
2231 assert!(!tap.connect_failed.load(Ordering::Relaxed));
2232 let bill = tap.bill("claude-opus-4-7", 0.75);
2233 assert_eq!(
2234 (bill.booking, bill.cost_usd),
2235 (PingBooking::WorstCase, 0.75)
2236 );
2237 }
2238
2239 #[test]
2240 fn reply_prefix_tokens_skip_thinking() {
2241 let text = "a".repeat(250); let with_thinking = vec![Arc::new(json!({"role": "assistant", "content": [
2243 {"type": "thinking", "thinking": "x".repeat(50_000), "signature": "s"},
2244 {"type": "text", "text": text},
2245 ]}))];
2246 let plain = vec![Arc::new(json!({"role": "assistant", "content": [
2247 {"type": "text", "text": text},
2248 ]}))];
2249 let t = reply_prefix_tokens(&with_thinking);
2250 assert_eq!(t, reply_prefix_tokens(&plain), "thinking is not counted");
2251 assert!((100..200).contains(&t), "{t}");
2252 assert_eq!(reply_prefix_tokens(&[]), 0);
2253 }
2254}