1use std::time::{Duration, SystemTime};
49
50use tokio::time::Instant;
51use tokio_util::sync::CancellationToken;
52
53use super::actor::SessionActor;
54use super::types::ClientKind;
55use crate::core::config::{CacheKeepWarm, CacheTtl};
56use crate::runtime::keepwarm::{
57 CacheAnchor, KeepWarmProbe, KeepWarmUsage, PingBill, PingFailure, PingOutcome, PingReport,
58 PingTap,
59};
60
61pub const LATE_GUARD: Duration = Duration::from_secs(15);
64
65pub const BACKOFF: [Duration; 3] = [
67 Duration::from_secs(5),
68 Duration::from_secs(15),
69 Duration::from_secs(30),
70];
71
72pub const WALL_POLL: Duration = Duration::from_secs(15);
75
76pub const SHARE_LIMIT_PERCENT: u64 = 10;
82
83pub const PING_INPUT_ESTIMATE: u64 = 64;
86
87const BOUND_EPSILON: f64 = 1e-9;
89
90fn over_share(tokens: u64, prefix: u64) -> bool {
91 tokens.saturating_mul(100) > prefix.saturating_mul(SHARE_LIMIT_PERCENT)
92}
93
94pub fn governing_ttl(ttl: CacheTtl) -> Duration {
99 match ttl {
100 CacheTtl::OneHour => Duration::from_secs(3600),
101 CacheTtl::FiveMinutes | CacheTtl::Hybrid => Duration::from_secs(300),
102 }
103}
104
105pub fn safety_margin(ttl: CacheTtl) -> Duration {
107 match ttl {
108 CacheTtl::OneHour => Duration::from_secs(300),
109 CacheTtl::FiveMinutes | CacheTtl::Hybrid => Duration::from_secs(30),
110 }
111}
112
113pub fn ping_interval(ttl: CacheTtl) -> Duration {
115 governing_ttl(ttl) - safety_margin(ttl)
116}
117
118#[cfg(any(test, feature = "testing"))]
121static INTERVAL_OVERRIDE_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
122
123pub fn interval_override() -> Option<Duration> {
133 #[cfg(any(test, feature = "testing"))]
134 {
135 let ms = INTERVAL_OVERRIDE_MS.load(std::sync::atomic::Ordering::Relaxed);
136 if ms > 0 {
137 return Some(Duration::from_millis(ms));
138 }
139 }
140 None
141}
142
143#[cfg(any(test, feature = "testing"))]
145pub mod testing {
146 use std::sync::atomic::Ordering;
147 use std::time::Duration;
148
149 #[must_use = "the override is cleared when the guard drops"]
152 pub struct IntervalOverride(());
153
154 impl IntervalOverride {
155 pub fn set(interval: Duration) -> Self {
156 let ms = u64::try_from(interval.as_millis())
157 .unwrap_or(u64::MAX)
158 .max(1);
159 super::INTERVAL_OVERRIDE_MS.store(ms, Ordering::Relaxed);
160 Self(())
161 }
162 }
163
164 impl Drop for IntervalOverride {
165 fn drop(&mut self) {
166 super::INTERVAL_OVERRIDE_MS.store(0, Ordering::Relaxed);
167 }
168 }
169}
170
171#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
173pub struct TtlEvidence {
174 pub downgraded: bool,
176 pub saw_1h: bool,
178 pub write_1h: u64,
180 pub write_5m: u64,
181}
182
183pub fn honoured_ttl(configured: CacheTtl, e: TtlEvidence) -> CacheTtl {
187 if configured != CacheTtl::OneHour {
188 return configured;
189 }
190 if e.downgraded {
191 return CacheTtl::FiveMinutes;
192 }
193 if e.write_1h > 0 {
194 return CacheTtl::OneHour;
195 }
196 if e.write_5m > 0 {
197 return CacheTtl::FiveMinutes;
198 }
199 if e.saw_1h {
200 CacheTtl::OneHour
201 } else {
202 CacheTtl::FiveMinutes
203 }
204}
205
206pub fn request_cost_usd(
209 model: &str,
210 ttl: CacheTtl,
211 read: u64,
212 write: u64,
213 input: u64,
214 output: u64,
215) -> f64 {
216 match ttl {
217 CacheTtl::OneHour => {
218 crate::pricing::calculate_cost_split(model, input, output, read, 0, write)
219 }
220 CacheTtl::FiveMinutes | CacheTtl::Hybrid => {
221 crate::pricing::calculate_cost_split(model, input, output, read, write, 0)
222 }
223 }
224}
225
226pub fn rewrite_cost_usd(model: &str, ttl: CacheTtl, prefix_tokens: u64) -> f64 {
229 request_cost_usd(model, ttl, 0, prefix_tokens, 0, 0)
230}
231
232#[derive(Debug, Clone, PartialEq)]
235pub enum PeriodEnd {
236 NothingCached,
239 UncachedInput {
241 input: u64,
242 prefix: u64,
243 },
244 BoundReached {
247 ledger_usd: f64,
248 bound_usd: f64,
249 },
250 PingTooExpensive {
253 worst_usd: f64,
254 bound_usd: f64,
255 },
256 HoursReached(u32),
258 Expired {
261 age_secs: u64,
262 },
263 PrefixChanged,
265 ContextRollover(String),
268 CacheMiss {
270 written: u64,
271 },
272 PartialMiss {
274 written: u64,
275 prefix: u64,
276 },
277 Rejected(String),
279 TransientExhausted(String),
281 CostCap {
284 cap: f64,
285 },
286 TurnStarted,
287 Compaction,
288 Disabled,
290 ParkedByRequest,
292 SessionEnding,
293 Blocked(&'static str),
295}
296
297impl std::fmt::Display for PeriodEnd {
298 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299 match self {
300 PeriodEnd::NothingCached => f.write_str("nothing cached"),
301 PeriodEnd::UncachedInput { input, prefix } => write!(
302 f,
303 "ping paid for {input} uncached input tokens (prefix {prefix})"
304 ),
305 PeriodEnd::BoundReached {
306 ledger_usd,
307 bound_usd,
308 } => write!(
309 f,
310 "ski-rental bound reached (${ledger_usd:.4} spent of ${bound_usd:.4})"
311 ),
312 PeriodEnd::PingTooExpensive {
313 worst_usd,
314 bound_usd,
315 } => write!(
316 f,
317 "worst-case cache-hit ping ${worst_usd:.4} exceeds the ${bound_usd:.4} rewrite it would save"
318 ),
319 PeriodEnd::HoursReached(h) => write!(f, "{h}h window reached"),
320 PeriodEnd::Expired { age_secs } => write!(
321 f,
322 "cache expired or about to ({age_secs}s since last request; slept?)"
323 ),
324 PeriodEnd::PrefixChanged => f.write_str("prefix changed"),
325 PeriodEnd::ContextRollover(why) => write!(f, "{why}"),
326 PeriodEnd::CacheMiss { written } => {
327 write!(f, "ping missed the cache (wrote {written} tokens)")
328 }
329 PeriodEnd::PartialMiss { written, prefix } => write!(
330 f,
331 "ping rewrote {written} of {prefix} prefix tokens (history no longer cached)"
332 ),
333 PeriodEnd::Rejected(m) => write!(f, "ping rejected: {m}"),
334 PeriodEnd::TransientExhausted(m) => write!(f, "transient errors: {m}"),
335 PeriodEnd::CostCap { cap } => write!(f, "max_session_cost ${cap:.4} reached"),
336 PeriodEnd::TurnStarted => f.write_str("turn started"),
337 PeriodEnd::Compaction => f.write_str("compaction"),
338 PeriodEnd::Disabled => f.write_str("disabled"),
339 PeriodEnd::ParkedByRequest => f.write_str("parked by request"),
340 PeriodEnd::SessionEnding => f.write_str("session ending"),
341 PeriodEnd::Blocked(why) => write!(f, "{why}"),
342 }
343 }
344}
345
346#[derive(Debug, Clone)]
348pub struct PeriodConfig {
349 pub mode: CacheKeepWarm,
350 pub ttl: CacheTtl,
352 pub model: String,
353 pub max_tokens: u64,
355 pub interval_override: Option<Duration>,
357 pub auto_max_hours: u32,
360 pub reply_tokens: Option<u64>,
365}
366
367#[derive(Debug, Clone, Copy, PartialEq)]
374pub struct EpochCarry {
375 pub ledger_usd: f64,
376 pub pings: u32,
377 pub prefix_tokens: u64,
378 pub ping_input: u64,
379 pub idle_since: Instant,
380 pub idle_wall: SystemTime,
381}
382
383#[derive(Debug, Clone, PartialEq)]
385pub enum Fire {
386 NotYet,
387 Ping,
388 End(PeriodEnd),
389}
390
391#[derive(Debug, Clone)]
393pub struct Period {
394 pub id: u64,
395 cfg: PeriodConfig,
396 ttl: CacheTtl,
398 evidence: TtlEvidence,
399 idle_since: Instant,
400 idle_wall: SystemTime,
401 anchor: CacheAnchor,
402 fingerprint: u64,
403 prefix_tokens: u64,
404 first_write: u64,
406 bound_usd: f64,
407 ledger_usd: f64,
408 worst_next_usd: f64,
411 exposure_next_usd: f64,
414 ping_input: u64,
416 pings: u32,
417 retries: usize,
418 next_at: Instant,
419 next_wall: SystemTime,
420 in_flight: bool,
421 fired_age: Duration,
423}
424
425fn age(anchor: &CacheAnchor, now: Instant, wall: SystemTime) -> Duration {
429 let mono = now.saturating_duration_since(anchor.mono);
430 let wall = wall.duration_since(anchor.wall).unwrap_or_default();
431 mono.max(wall)
432}
433
434impl Period {
435 #[allow(clippy::too_many_arguments)]
439 pub fn start(
440 id: u64,
441 cfg: PeriodConfig,
442 fingerprint: u64,
443 last: &KeepWarmUsage,
444 evidence: TtlEvidence,
445 anchor: CacheAnchor,
446 now: Instant,
447 wall: SystemTime,
448 ) -> Result<Period, PeriodEnd> {
449 Self::start_in_epoch(
450 id,
451 cfg,
452 fingerprint,
453 last,
454 evidence,
455 anchor,
456 now,
457 wall,
458 None,
459 )
460 }
461
462 #[allow(clippy::too_many_arguments)]
465 pub fn start_in_epoch(
466 id: u64,
467 cfg: PeriodConfig,
468 fingerprint: u64,
469 last: &KeepWarmUsage,
470 evidence: TtlEvidence,
471 anchor: CacheAnchor,
472 now: Instant,
473 wall: SystemTime,
474 carry: Option<&EpochCarry>,
475 ) -> Result<Period, PeriodEnd> {
476 let cached = last.cache_read_tokens + last.cache_write_tokens;
477 if cached == 0 {
478 return Err(PeriodEnd::NothingCached);
479 }
480 let ttl = honoured_ttl(cfg.ttl, evidence);
481 let age_now = age(&anchor, now, wall);
482 if age_now + LATE_GUARD >= governing_ttl(ttl) {
483 return Err(PeriodEnd::Expired {
484 age_secs: age_now.as_secs(),
485 });
486 }
487 let reply = cfg
492 .reply_tokens
493 .map_or(last.output_tokens, |r| r.min(last.output_tokens));
494 let first_write = last.input_tokens + reply;
495 let prefix_tokens = cached + first_write;
496 let mut p = Period {
497 id,
498 cfg,
499 ttl,
500 evidence,
501 idle_since: now,
502 idle_wall: wall,
503 anchor,
504 fingerprint,
505 prefix_tokens,
506 first_write,
507 bound_usd: 0.0,
508 ledger_usd: 0.0,
509 worst_next_usd: 0.0,
510 exposure_next_usd: 0.0,
511 ping_input: PING_INPUT_ESTIMATE,
512 pings: 0,
513 retries: 0,
514 next_at: now,
515 next_wall: wall,
516 in_flight: false,
517 fired_age: Duration::ZERO,
518 };
519 if let Some(c) = carry {
520 p.ledger_usd = c.ledger_usd;
523 p.pings = c.pings;
524 p.idle_since = c.idle_since;
525 p.idle_wall = c.idle_wall;
526 if c.pings > 0 {
527 p.prefix_tokens = c.prefix_tokens;
528 p.ping_input = c.ping_input;
529 }
530 }
531 p.reprice();
532 let interval = p.interval();
534 p.next_at = (anchor.mono + interval).max(now);
535 p.next_wall = (anchor.wall + interval).max(wall);
536 if p.worst_next_usd > p.bound_usd * (1.0 + BOUND_EPSILON) {
537 return Err(PeriodEnd::PingTooExpensive {
538 worst_usd: p.worst_next_usd,
539 bound_usd: p.bound_usd,
540 });
541 }
542 if let Some(r) = p
543 .past_window(p.next_at, p.next_wall)
544 .or_else(|| p.past_bound())
545 {
546 return Err(r);
547 }
548 Ok(p)
549 }
550
551 pub fn epoch_carry(&self) -> EpochCarry {
553 EpochCarry {
554 ledger_usd: self.ledger_usd,
555 pings: self.pings,
556 prefix_tokens: self.prefix_tokens,
557 ping_input: self.ping_input,
558 idle_since: self.idle_since,
559 idle_wall: self.idle_wall,
560 }
561 }
562
563 fn reprice(&mut self) {
566 let model = self.cfg.model.as_str();
567 self.bound_usd = rewrite_cost_usd(model, self.ttl, self.prefix_tokens);
568 let write = if self.pings == 0 { self.first_write } else { 0 };
569 self.worst_next_usd = request_cost_usd(
570 model,
571 self.ttl,
572 self.prefix_tokens.saturating_sub(write),
573 write,
574 self.ping_input,
575 self.cfg.max_tokens,
576 );
577 let miss = |ttl| {
581 request_cost_usd(
582 model,
583 ttl,
584 0,
585 self.prefix_tokens,
586 self.ping_input,
587 self.cfg.max_tokens,
588 )
589 };
590 let mut miss_usd = miss(self.ttl);
591 if matches!(self.cfg.ttl, CacheTtl::OneHour | CacheTtl::Hybrid) {
592 miss_usd = miss_usd.max(miss(CacheTtl::OneHour));
593 }
594 self.exposure_next_usd = self.worst_next_usd.max(miss_usd);
595 }
596
597 fn interval(&self) -> Duration {
598 self.cfg
599 .interval_override
600 .unwrap_or_else(|| ping_interval(self.ttl))
601 }
602
603 pub fn fingerprint(&self) -> u64 {
604 self.fingerprint
605 }
606 pub fn pings(&self) -> u32 {
607 self.pings
608 }
609 pub fn ledger_usd(&self) -> f64 {
610 self.ledger_usd
611 }
612 pub fn bound_usd(&self) -> f64 {
613 self.bound_usd
614 }
615 pub fn worst_next_usd(&self) -> f64 {
619 self.worst_next_usd
620 }
621 pub fn exposure_next_usd(&self) -> f64 {
625 self.exposure_next_usd
626 }
627 pub fn prefix_tokens(&self) -> u64 {
628 self.prefix_tokens
629 }
630 pub fn next_at(&self) -> Instant {
631 self.next_at
632 }
633 pub fn mode(&self) -> CacheKeepWarm {
634 self.cfg.mode
635 }
636 pub fn ttl(&self) -> CacheTtl {
638 self.ttl
639 }
640 pub fn fired_age(&self) -> Duration {
641 self.fired_age
642 }
643 pub fn in_flight(&self) -> bool {
644 self.in_flight
645 }
646
647 pub fn set_mode(&mut self, mode: CacheKeepWarm) {
649 self.cfg.mode = mode;
650 }
651
652 pub fn deadline(&self, now: Instant, wall: SystemTime) -> Option<Instant> {
656 if self.in_flight {
657 return None;
658 }
659 let wall_left = self.next_wall.duration_since(wall).unwrap_or_default();
660 Some(self.next_at.min(now + wall_left))
661 }
662
663 fn past_window(&self, at: Instant, wall: SystemTime) -> Option<PeriodEnd> {
665 let h = match self.cfg.mode {
666 CacheKeepWarm::Hours(h) => h,
667 CacheKeepWarm::Auto if self.cfg.auto_max_hours > 0 => self.cfg.auto_max_hours,
668 _ => return None,
669 };
670 let window = Duration::from_secs(u64::from(h) * 3600);
671 let walled = wall.duration_since(self.idle_wall).unwrap_or_default();
672 (at.saturating_duration_since(self.idle_since) >= window || walled >= window)
673 .then_some(PeriodEnd::HoursReached(h))
674 }
675
676 fn past_bound(&self) -> Option<PeriodEnd> {
677 (self.ledger_usd + self.worst_next_usd > self.bound_usd * (1.0 + BOUND_EPSILON)).then_some(
678 PeriodEnd::BoundReached {
679 ledger_usd: self.ledger_usd,
680 bound_usd: self.bound_usd,
681 },
682 )
683 }
684
685 pub fn fire(
687 &mut self,
688 now: Instant,
689 wall: SystemTime,
690 session_cost: f64,
691 max_session_cost: Option<f64>,
692 ) -> Fire {
693 if self.in_flight || (now < self.next_at && wall < self.next_wall) {
694 return Fire::NotYet;
695 }
696 let age_now = age(&self.anchor, now, wall);
697 if age_now + LATE_GUARD >= governing_ttl(self.ttl) {
698 return Fire::End(PeriodEnd::Expired {
699 age_secs: age_now.as_secs(),
700 });
701 }
702 if let Some(r) = self.past_window(now, wall).or_else(|| self.past_bound()) {
703 return Fire::End(r);
704 }
705 if let Some(cap) = max_session_cost.filter(|c| c.is_finite()) {
706 if session_cost + self.exposure_next_usd >= cap {
708 return Fire::End(PeriodEnd::CostCap { cap });
709 }
710 }
711 self.in_flight = true;
712 self.fired_age = age_now;
713 Fire::Ping
714 }
715
716 pub fn charge(&mut self, cost_usd: f64) {
718 self.ledger_usd += cost_usd;
719 }
720
721 pub fn ping_sent(
726 &mut self,
727 usage: &KeepWarmUsage,
728 anchor: Option<CacheAnchor>,
729 downgraded: bool,
730 ) -> Result<(), PeriodEnd> {
731 self.in_flight = false;
732 self.retries = 0;
733 self.pings += 1;
734 let (read, write) = (usage.cache_read_tokens, usage.cache_write_tokens);
735 if read == 0 && write == 0 {
736 return Err(PeriodEnd::NothingCached);
737 }
738 if read == 0 {
739 return Err(PeriodEnd::CacheMiss { written: write });
740 }
741 if over_share(usage.input_tokens, self.prefix_tokens) {
742 return Err(PeriodEnd::UncachedInput {
743 input: usage.input_tokens,
744 prefix: self.prefix_tokens,
745 });
746 }
747 let allowed = if self.pings == 1 { self.first_write } else { 0 };
748 if over_share(write.saturating_sub(allowed), self.prefix_tokens) {
749 return Err(PeriodEnd::PartialMiss {
750 written: write,
751 prefix: self.prefix_tokens,
752 });
753 }
754 self.prefix_tokens = read + write;
756 self.ping_input = usage.input_tokens.max(1);
757 self.evidence.downgraded |= downgraded;
758 self.evidence.write_1h = usage.cache_write_1h.unwrap_or(0);
759 self.evidence.write_5m = usage.cache_write_5m.unwrap_or(0);
760 self.evidence.saw_1h |= self.evidence.write_1h > 0;
761 self.ttl = honoured_ttl(self.cfg.ttl, self.evidence);
762 self.reprice();
763 if let Some(a) = anchor.filter(|a| a.mono >= self.anchor.mono) {
764 self.anchor = a;
765 }
766 let interval = self.interval();
767 self.next_at = self.anchor.mono + interval;
768 self.next_wall = self.anchor.wall + interval;
769 if let Some(r) = self
770 .past_window(self.next_at, self.next_wall)
771 .or_else(|| self.past_bound())
772 {
773 return Err(r);
774 }
775 Ok(())
776 }
777
778 pub fn ping_failed(
782 &mut self,
783 failure: &PingFailure,
784 now: Instant,
785 wall: SystemTime,
786 ) -> Result<Duration, PeriodEnd> {
787 self.in_flight = false;
788 let msg = match failure {
789 PingFailure::Fatal(m) => return Err(PeriodEnd::Rejected(m.clone())),
790 PingFailure::Canceled => return Err(PeriodEnd::Rejected("canceled".into())),
791 PingFailure::Transient(m) => m.clone(),
792 };
793 let Some(delay) = BACKOFF.get(self.retries).copied() else {
794 return Err(PeriodEnd::TransientExhausted(msg));
795 };
796 let age_then = age(&self.anchor, now, wall) + delay;
797 if age_then + LATE_GUARD >= governing_ttl(self.ttl) {
798 return Err(PeriodEnd::TransientExhausted(format!(
799 "{msg}; no retry fits before the cache expires"
800 )));
801 }
802 if self.ledger_usd + self.worst_next_usd > self.bound_usd * (1.0 + BOUND_EPSILON) {
803 return Err(PeriodEnd::BoundReached {
804 ledger_usd: self.ledger_usd,
805 bound_usd: self.bound_usd,
806 });
807 }
808 self.retries += 1;
809 self.next_at = now + delay;
810 self.next_wall = wall + delay;
811 Ok(delay)
812 }
813}
814
815pub(crate) struct PingTask {
817 pub(crate) handle: tokio::task::JoinHandle<PingReport>,
818 cancel: CancellationToken,
819 tap: PingTap,
820 model: String,
822 worst_usd: f64,
825}
826
827impl PingTask {
828 fn bill(&self) -> PingBill {
831 self.tap.bill(&self.model, self.worst_usd)
832 }
833}
834
835#[derive(Default)]
837pub(crate) struct KeepWarmState {
838 configured: CacheKeepWarm,
840 auto_max_hours: u32,
842 epoch: Option<EpochCarry>,
846 pub(crate) override_mode: Option<CacheKeepWarm>,
848 pub(crate) override_slot: OverrideSlot,
851 pub(crate) period: Option<Period>,
852 pub(crate) task: Option<PingTask>,
853 next_period_id: u64,
854 turn_started_at: Option<Instant>,
856 last_request: Option<KeepWarmUsage>,
858 pub(crate) interactive_seen: bool,
861 last_end: Option<String>,
862 session_pings: u32,
863}
864
865pub type OverrideSlot = std::sync::Arc<std::sync::Mutex<Option<CacheKeepWarm>>>;
867
868impl KeepWarmState {
869 pub(crate) fn new(
870 configured: CacheKeepWarm,
871 auto_max_hours: u32,
872 override_slot: OverrideSlot,
873 ) -> Self {
874 let override_mode = *override_slot
875 .lock()
876 .unwrap_or_else(std::sync::PoisonError::into_inner);
877 Self {
878 configured,
879 auto_max_hours,
880 override_mode,
881 override_slot,
882 ..Self::default()
883 }
884 }
885
886 pub(crate) fn mode(&self) -> CacheKeepWarm {
887 self.override_mode.unwrap_or(self.configured)
888 }
889
890 pub(crate) fn set_override(&mut self, mode: CacheKeepWarm) {
891 self.override_mode = Some(mode);
892 *self
893 .override_slot
894 .lock()
895 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(mode);
896 }
897
898 pub(crate) fn active(&self) -> bool {
899 self.period.is_some()
900 }
901
902 pub(crate) fn deadline(&self) -> Option<Instant> {
906 self.deadline_at(Instant::now(), SystemTime::now())
907 }
908
909 fn deadline_at(&self, now: Instant, wall: SystemTime) -> Option<Instant> {
910 self.period
911 .as_ref()
912 .and_then(|p| p.deadline(now, wall))
913 .map(|d| d.min(now + WALL_POLL))
914 }
915}
916
917pub(crate) async fn keepwarm_timer(deadline: Option<Instant>) {
919 match deadline {
920 Some(t) => tokio::time::sleep_until(t).await,
921 None => std::future::pending().await,
922 }
923}
924
925pub(crate) async fn poll_keepwarm_ping(
927 task: &mut Option<PingTask>,
928) -> std::result::Result<PingReport, tokio::task::JoinError> {
929 match task {
930 Some(t) => (&mut t.handle).await,
931 None => std::future::pending().await,
932 }
933}
934
935impl SessionActor {
936 fn cache_keepwarm_blocker(&self) -> Option<&'static str> {
938 if !self.runtime.is_live() || !self.conv.is_live() || self.is_parked() {
939 return Some("session parked");
940 }
941 if self.streaming {
942 return Some("turn running");
943 }
944 if self.compact.is_some() {
945 return Some("compaction running");
946 }
947 if self.conv.queued_message.is_some() || !self.event_queue.is_empty() {
948 return Some("work queued");
949 }
950 if !self.pending_prompts.is_empty() {
951 return Some("prompt pending");
952 }
953 if self.driver.is_some() || self.driver_pending.is_some() {
954 return Some("session driver armed");
955 }
956 None
957 }
958
959 pub(crate) fn cache_keepwarm_note_attach(&mut self, kind: ClientKind) {
961 if matches!(kind, ClientKind::Tui | ClientKind::Attach) {
962 self.cache_keepwarm.interactive_seen = true;
963 }
964 }
965
966 pub(crate) fn cache_keepwarm_note_usage(&mut self, usage: KeepWarmUsage) {
969 self.cache_keepwarm.last_request = Some(usage);
970 self.cache_keepwarm.epoch = None;
972 }
973
974 pub(crate) fn cache_keepwarm_turn_started(&mut self) {
977 self.cache_keepwarm_end(PeriodEnd::TurnStarted);
978 self.cache_keepwarm.turn_started_at = Some(Instant::now());
979 self.cache_keepwarm.last_request = None;
980 }
981
982 pub(crate) fn cache_keepwarm_compaction(&mut self) {
985 self.cache_keepwarm_end(PeriodEnd::Compaction);
986 self.cache_keepwarm.turn_started_at = None;
987 self.cache_keepwarm.last_request = None;
988 self.cache_keepwarm.epoch = None;
989 }
990
991 pub(crate) fn cache_keepwarm_conversation_replaced(&mut self) {
998 self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
999 self.cache_keepwarm.turn_started_at = None;
1000 self.cache_keepwarm.last_request = None;
1001 self.cache_keepwarm.epoch = None;
1002 }
1003
1004 pub(crate) fn cache_keepwarm_park_requested(&mut self) {
1007 if self.cache_keepwarm.active() && self.can_park_ignoring_keepwarm() {
1008 self.cache_keepwarm_end(PeriodEnd::ParkedByRequest);
1009 }
1010 }
1011
1012 fn cache_keepwarm_book(&mut self, usage: &KeepWarmUsage, cost: f64) {
1017 if (cost <= 0.0 && *usage == KeepWarmUsage::default()) || !self.conv.is_live() {
1018 return;
1019 }
1020 self.conv.session_cost += cost;
1021 self.conv.keepwarm_cost += cost;
1022 self.conv.total_input_tokens += usage.input_tokens;
1023 self.conv.total_output_tokens += usage.output_tokens;
1024 self.conv.total_cache_read_tokens += usage.cache_read_tokens;
1025 self.conv.total_cache_creation_tokens += usage.cache_write_tokens;
1026 let t = &mut self.conv.keepwarm_tokens;
1027 t.input += usage.input_tokens;
1028 t.output += usage.output_tokens;
1029 t.cache_read += usage.cache_read_tokens;
1030 t.cache_creation += usage.cache_write_tokens;
1031 if let Some(p) = self.cache_keepwarm.period.as_mut() {
1032 p.charge(cost);
1033 }
1034 self.emit_conversation();
1035 self.request_save();
1041 }
1042
1043 fn cache_keepwarm_abort_ping(&mut self) {
1047 let Some(task) = self.cache_keepwarm.task.take() else {
1048 return;
1049 };
1050 task.cancel.cancel();
1051 task.handle.abort();
1052 let bill = task.bill();
1053 if bill.cost_usd > 0.0 {
1054 tracing::info!(
1055 session = %self.id,
1056 cost_usd = bill.cost_usd,
1057 booking = ?bill.booking,
1058 "cache keep-warm ping aborted in flight; booked"
1059 );
1060 }
1061 self.cache_keepwarm_book(&bill.usage, bill.cost_usd);
1062 }
1063
1064 pub(crate) fn cache_keepwarm_end(&mut self, reason: PeriodEnd) {
1066 self.cache_keepwarm_abort_ping();
1067 let Some(p) = self.cache_keepwarm.period.take() else {
1068 return;
1069 };
1070 tracing::info!(
1071 session = %self.id,
1072 period = p.id,
1073 pings = p.pings(),
1074 ledger_usd = p.ledger_usd(),
1075 bound_usd = p.bound_usd(),
1076 reason = %reason,
1077 "cache keep-warm period ended"
1078 );
1079 self.cache_keepwarm.last_end = Some(reason.to_string());
1080 self.cache_keepwarm.epoch = Some(p.epoch_carry());
1082 self.rearm_park();
1084 }
1085
1086 pub(crate) async fn cache_keepwarm_on_idle(&mut self) {
1089 let Some(turn_started_at) = self.cache_keepwarm.turn_started_at.take() else {
1090 return;
1091 };
1092 self.cache_keepwarm_try_start(Some(turn_started_at)).await;
1093 }
1094
1095 async fn cache_keepwarm_try_start(&mut self, turn_started_at: Option<Instant>) {
1100 if self.cache_keepwarm.active() {
1101 return;
1102 }
1103 let mode = self.cache_keepwarm.mode();
1104 if mode.is_off() || !self.cache_keepwarm.interactive_seen {
1105 return;
1106 }
1107 let Some(anchor) = self
1109 .runtime
1110 .cache_anchor()
1111 .filter(|a| turn_started_at.map_or(true, |t| a.mono >= t))
1112 else {
1113 return;
1114 };
1115 let Some(last) = self.cache_keepwarm.last_request.clone() else {
1116 return;
1117 };
1118 if let Some(why) = self.cache_keepwarm_blocker() {
1119 self.cache_keepwarm.last_end = Some(format!("not started: {why}"));
1120 return;
1121 }
1122 if let Some(why) = self.runtime.cache_keepwarm_ineligibility() {
1123 self.cache_keepwarm.last_end = Some(format!("not started: {why}"));
1124 return;
1125 }
1126 if !crate::runtime::keepwarm::history_ready(&self.conv.api_messages) {
1127 return;
1128 }
1129 let probe: KeepWarmProbe = match self
1130 .runtime
1131 .cache_keepwarm_probe(&self.conv.api_messages)
1132 .await
1133 {
1134 Ok(p) => p,
1135 Err(e) => {
1136 tracing::info!(session = %self.id, "cache keep-warm not started: {e}");
1137 self.cache_keepwarm.last_end = Some(format!("not started: {e}"));
1138 return;
1139 }
1140 };
1141 if let Some(why) = probe.context_blocks {
1142 let reason = PeriodEnd::ContextRollover(why);
1143 tracing::info!(session = %self.id, reason = %reason, "cache keep-warm not started");
1144 self.cache_keepwarm.last_end = Some(format!("not started: {reason}"));
1145 return;
1146 }
1147 let cfg = PeriodConfig {
1148 mode,
1149 ttl: self.runtime.cache_ttl(),
1150 model: self.runtime.model().to_string(),
1151 max_tokens: probe.max_tokens,
1152 interval_override: interval_override(),
1153 auto_max_hours: self.cache_keepwarm.auto_max_hours,
1154 reply_tokens: Some(crate::runtime::keepwarm::reply_prefix_tokens(
1155 &self.conv.api_messages,
1156 )),
1157 };
1158 let evidence = TtlEvidence {
1159 downgraded: self.runtime.cache_ttl_downgraded(),
1160 saw_1h: self.runtime.cache_1h_honoured(),
1161 write_1h: last.cache_write_1h.unwrap_or(0),
1162 write_5m: last.cache_write_5m.unwrap_or(0),
1163 };
1164 self.cache_keepwarm.next_period_id += 1;
1165 let id = self.cache_keepwarm.next_period_id;
1166 let now = Instant::now();
1167 let carry = self.cache_keepwarm.epoch;
1168 match Period::start_in_epoch(
1169 id,
1170 cfg,
1171 probe.fingerprint,
1172 &last,
1173 evidence,
1174 anchor,
1175 now,
1176 SystemTime::now(),
1177 carry.as_ref(),
1178 ) {
1179 Ok(p) => {
1180 tracing::info!(
1181 session = %self.id,
1182 period = id,
1183 mode = %mode,
1184 ttl_secs = governing_ttl(p.ttl()).as_secs(),
1185 first_ping_in_secs = p.next_at().saturating_duration_since(now).as_secs(),
1186 prefix_tokens = p.prefix_tokens(),
1187 bound_usd = p.bound_usd(),
1188 worst_ping_usd = p.exposure_next_usd(),
1189 "cache keep-warm period started"
1190 );
1191 self.cache_keepwarm.period = Some(p);
1192 }
1193 Err(reason) => {
1194 tracing::info!(session = %self.id, reason = %reason, "cache keep-warm not started");
1195 self.cache_keepwarm.last_end = Some(format!("not started: {reason}"));
1196 }
1197 }
1198 }
1199
1200 pub(crate) async fn on_cache_keepwarm_due(&mut self) {
1202 if self.cache_keepwarm.period.is_none() || self.cache_keepwarm.task.is_some() {
1203 return;
1204 }
1205 let (now, wall) = (Instant::now(), SystemTime::now());
1206 if self
1208 .cache_keepwarm
1209 .period
1210 .as_ref()
1211 .and_then(|p| p.deadline(now, wall))
1212 .is_some_and(|d| d > now)
1213 {
1214 return;
1215 }
1216 if let Some(why) = self.cache_keepwarm_blocker() {
1217 self.cache_keepwarm_end(PeriodEnd::Blocked(why));
1218 return;
1219 }
1220 if !crate::runtime::keepwarm::history_ready(&self.conv.api_messages) {
1221 self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1224 return;
1225 }
1226 match self
1229 .runtime
1230 .cache_keepwarm_probe(&self.conv.api_messages)
1231 .await
1232 {
1233 Ok(probe) => {
1234 if let Some(why) = probe.context_blocks {
1235 self.cache_keepwarm_end(PeriodEnd::ContextRollover(why));
1236 return;
1237 }
1238 }
1239 Err(e) => {
1240 self.cache_keepwarm_end(PeriodEnd::Rejected(e.to_string()));
1241 return;
1242 }
1243 }
1244 let session_cost = self.conv.session_cost;
1245 let cap = self.config.max_session_cost;
1246 let Some(p) = self.cache_keepwarm.period.as_mut() else {
1247 return;
1248 };
1249 match p.fire(Instant::now(), SystemTime::now(), session_cost, cap) {
1250 Fire::NotYet => {}
1251 Fire::End(reason) => self.cache_keepwarm_end(reason),
1252 Fire::Ping => {
1253 let expect = p.fingerprint();
1254 let worst_usd = p.worst_next_usd();
1255 let history = self.conv.api_messages.clone();
1259 let mut runtime = self.runtime.clone();
1260 runtime.share_ttl_latches(&self.runtime);
1261 let cancel = CancellationToken::new();
1262 let token = cancel.clone();
1263 let tap = PingTap::default();
1264 let task_tap = tap.clone();
1265 let handle = tokio::spawn(async move {
1266 runtime
1267 .cache_keepwarm_ping(&history, expect, &token, &task_tap)
1268 .await
1269 });
1270 self.cache_keepwarm.task = Some(PingTask {
1271 handle,
1272 cancel,
1273 tap,
1274 model: self.runtime.model().to_string(),
1275 worst_usd,
1276 });
1277 }
1278 }
1279 }
1280
1281 pub(crate) fn on_cache_keepwarm_done(
1283 &mut self,
1284 result: std::result::Result<PingReport, tokio::task::JoinError>,
1285 ) {
1286 let Some(task) = self.cache_keepwarm.task.take() else {
1287 return;
1288 };
1289 let report = match result {
1290 Ok(r) => r,
1291 Err(e) => PingReport {
1292 result: Err(if e.is_cancelled() {
1293 PingFailure::Canceled
1294 } else {
1295 PingFailure::Fatal(format!("ping task: {e}"))
1296 }),
1297 usage: task.tap.usage(),
1298 notices: Vec::new(),
1299 },
1300 };
1301 let bill = task.bill();
1303 let cost = bill.cost_usd;
1304 self.cache_keepwarm_book(&bill.usage, cost);
1305 for notice in &report.notices {
1306 self.emit(crate::session::SessionEventWire::SystemNotice(
1307 notice.clone(),
1308 ));
1309 }
1310 if self.cache_keepwarm.period.is_none() {
1311 return;
1312 }
1313 match report.result {
1314 Ok(PingOutcome::Sent) => {
1315 let usage = report.usage;
1316 self.cache_keepwarm.session_pings += 1;
1317 let anchor = self.runtime.cache_anchor();
1318 let downgraded = self.runtime.cache_ttl_downgraded();
1319 let Some(p) = self.cache_keepwarm.period.as_mut() else {
1320 return;
1321 };
1322 let result = p.ping_sent(&usage, anchor, downgraded);
1323 tracing::info!(
1324 session = %self.id,
1325 period = p.id,
1326 ping = p.pings(),
1327 age_secs = p.fired_age().as_secs(),
1328 ttl_secs = governing_ttl(p.ttl()).as_secs(),
1329 cache_read = usage.cache_read_tokens,
1330 cache_write = usage.cache_write_tokens,
1331 input = usage.input_tokens,
1332 output = usage.output_tokens,
1333 cost_usd = cost,
1334 ledger_usd = p.ledger_usd(),
1335 bound_usd = p.bound_usd(),
1336 "cache keep-warm ping"
1337 );
1338 if let Err(reason) = result {
1339 if matches!(
1340 reason,
1341 PeriodEnd::PartialMiss { .. }
1342 | PeriodEnd::CacheMiss { .. }
1343 | PeriodEnd::UncachedInput { .. }
1344 | PeriodEnd::NothingCached
1345 ) {
1346 tracing::warn!(
1347 session = %self.id,
1348 reason = %reason,
1349 "cache keep-warm ping did not refresh the cache"
1350 );
1351 }
1352 self.cache_keepwarm_end(reason);
1353 return;
1354 }
1355 if let Some(cap) = self.config.max_session_cost.filter(|c| c.is_finite()) {
1356 if self.conv.session_cost >= cap {
1357 self.cache_keepwarm_end(PeriodEnd::CostCap { cap });
1358 }
1359 }
1360 }
1361 Ok(PingOutcome::PrefixChanged { .. }) => {
1362 self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1363 }
1364 Err(failure) => {
1365 let Some(p) = self.cache_keepwarm.period.as_mut() else {
1366 return;
1367 };
1368 match p.ping_failed(&failure, Instant::now(), SystemTime::now()) {
1369 Ok(delay) => tracing::info!(
1370 session = %self.id,
1371 period = p.id,
1372 retry_in_secs = delay.as_secs(),
1373 "cache keep-warm ping failed ({failure}); retrying"
1374 ),
1375 Err(reason) => self.cache_keepwarm_end(reason),
1376 }
1377 }
1378 }
1379 }
1380
1381 pub(crate) async fn cache_keepwarm_recheck(&mut self) -> Option<bool> {
1385 let expect = self.cache_keepwarm.period.as_ref()?.fingerprint();
1386 if self.cache_keepwarm.period.as_ref()?.in_flight() {
1387 return Some(true);
1388 }
1389 let matches = crate::runtime::keepwarm::history_ready(&self.conv.api_messages)
1390 && self
1391 .runtime
1392 .cache_keepwarm_probe(&self.conv.api_messages)
1393 .await
1394 .is_ok_and(|p| p.fingerprint == expect);
1395 if !matches {
1396 self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1397 }
1398 Some(matches)
1399 }
1400
1401 pub(crate) fn cache_keepwarm_status_json(
1403 &self,
1404 prefix_matches: Option<bool>,
1405 ) -> serde_json::Value {
1406 let kw = &self.cache_keepwarm;
1407 let (now, wall) = (Instant::now(), SystemTime::now());
1408 let period = kw.period.as_ref().map(|p| {
1409 let next = p
1411 .deadline(now, wall)
1412 .map(|d| d.saturating_duration_since(now).as_secs());
1413 serde_json::json!({
1414 "id": p.id,
1415 "ttl_secs": governing_ttl(p.ttl()).as_secs(),
1416 "pings": p.pings(),
1417 "ledger_usd": p.ledger_usd(),
1418 "bound_usd": p.bound_usd(),
1419 "worst_ping_usd": p.exposure_next_usd(),
1420 "prefix_tokens": p.prefix_tokens(),
1421 "next_ping_in_secs": next.unwrap_or(0),
1422 "in_flight": p.in_flight(),
1423 })
1424 });
1425 serde_json::json!({
1426 "mode": kw.mode().to_string(),
1427 "active": kw.active(),
1428 "period": period,
1429 "prefix_matches": prefix_matches,
1430 "session_pings": kw.session_pings,
1431 "session_keepwarm_usd": self.conv.keepwarm_cost,
1432 "last_end": kw.last_end,
1433 })
1434 }
1435
1436 pub(crate) async fn cache_keepwarm_command(&mut self, arg: &str) -> serde_json::Value {
1438 let arg = arg.trim();
1439 if !(arg.is_empty() || arg.eq_ignore_ascii_case("status")) {
1440 let Some(mode) = CacheKeepWarm::parse(arg) else {
1441 return serde_json::json!({
1442 "kind": "error",
1443 "text": format!(
1444 "usage: /keepwarm [status|off|auto|<N>h] (N = 1–{}; unknown: {arg})",
1445 crate::core::config::MAX_CACHE_KEEPWARM_HOURS
1446 ),
1447 });
1448 };
1449 self.cache_keepwarm.set_override(mode);
1450 if mode.is_off() {
1451 self.cache_keepwarm_end(PeriodEnd::Disabled);
1452 } else if let Some(p) = self.cache_keepwarm.period.as_mut() {
1453 p.set_mode(mode);
1454 } else {
1455 self.cache_keepwarm_try_start(None).await;
1457 }
1458 }
1459 let prefix_matches = self.cache_keepwarm_recheck().await;
1460 let status = self.cache_keepwarm_status_json(prefix_matches);
1461 serde_json::json!({
1462 "kind": "output",
1463 "text": render_status(&status),
1464 "keepwarm": status,
1465 })
1466 }
1467}
1468
1469pub(crate) fn render_status(s: &serde_json::Value) -> String {
1471 let mode = s["mode"].as_str().unwrap_or("off");
1472 let spent = s["session_keepwarm_usd"].as_f64().unwrap_or(0.0);
1473 let pings = s["session_pings"].as_u64().unwrap_or(0);
1474 let mut out = format!("cache keep-warm: {mode} — this session: {pings} pings, ${spent:.4}");
1475 let p = &s["period"];
1476 if p.is_object() {
1477 out.push_str(&format!(
1478 "\nactive: ping {} due in {}s (TTL {}s); ${:.4} spent of ${:.4} bound; prefix {}",
1479 p["pings"].as_u64().unwrap_or(0) + 1,
1480 p["next_ping_in_secs"].as_u64().unwrap_or(0),
1481 p["ttl_secs"].as_u64().unwrap_or(0),
1482 p["ledger_usd"].as_f64().unwrap_or(0.0),
1483 p["bound_usd"].as_f64().unwrap_or(0.0),
1484 match s["prefix_matches"].as_bool() {
1485 Some(true) => "unchanged",
1486 Some(false) => "changed",
1487 None => "unchecked",
1488 },
1489 ));
1490 } else {
1491 out.push_str("\nidle period: none");
1492 }
1493 if let Some(last) = s["last_end"].as_str() {
1494 out.push_str(&format!("\nlast: {last}"));
1495 }
1496 out
1497}
1498
1499#[cfg(test)]
1500mod tests {
1501 use super::*;
1502
1503 const MIN: Duration = Duration::from_secs(60);
1504 const OPUS: &str = "claude-opus-4-7";
1505 const SONNET: &str = "claude-sonnet-4-6";
1506 const P: u64 = 300_000;
1507
1508 fn cfg(mode: CacheKeepWarm, ttl: CacheTtl, model: &str) -> PeriodConfig {
1510 cfg_max(mode, ttl, model, 1)
1511 }
1512
1513 fn cfg_max(mode: CacheKeepWarm, ttl: CacheTtl, model: &str, max_tokens: u64) -> PeriodConfig {
1514 PeriodConfig {
1515 mode,
1516 ttl,
1517 model: model.to_string(),
1518 max_tokens,
1519 interval_override: None,
1520 auto_max_hours: 0,
1521 reply_tokens: None,
1522 }
1523 }
1524
1525 fn ev_1h() -> TtlEvidence {
1527 TtlEvidence {
1528 saw_1h: true,
1529 ..TtlEvidence::default()
1530 }
1531 }
1532
1533 fn anchor_at(mono: Instant, wall: SystemTime) -> CacheAnchor {
1534 CacheAnchor { mono, wall }
1535 }
1536
1537 fn usage(model: &str, read: u64, write: u64, input: u64, out: u64) -> KeepWarmUsage {
1538 KeepWarmUsage {
1539 input_tokens: input,
1540 output_tokens: out,
1541 cache_read_tokens: read,
1542 cache_write_tokens: write,
1543 cache_write_5m: None,
1544 cache_write_1h: None,
1545 model: Some(model.to_string()),
1546 }
1547 }
1548
1549 fn last_read(model: &str) -> KeepWarmUsage {
1551 usage(model, P, 0, 0, 0)
1552 }
1553
1554 fn start(c: PeriodConfig, last: &KeepWarmUsage, t0: Instant, w0: SystemTime) -> Period {
1555 Period::start(1, c, 7, last, ev_1h(), anchor_at(t0, w0), t0, w0).expect("starts")
1556 }
1557
1558 fn run_from(
1562 cfg: PeriodConfig,
1563 last: &KeepWarmUsage,
1564 usages: &[KeepWarmUsage],
1565 ) -> (u32, f64, f64, PeriodEnd, Duration) {
1566 let t0 = Instant::now();
1567 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
1568 let model = cfg.model.clone();
1569 let mut p = start(cfg, last, t0, w0);
1570 let mut i = 0;
1571 loop {
1572 let at = p.next_at();
1573 let wall = w0 + at.saturating_duration_since(t0);
1574 match p.fire(at, wall, 0.0, None) {
1575 Fire::Ping => {}
1576 Fire::End(r) => {
1577 return (p.pings(), p.ledger_usd(), p.bound_usd(), r, at - t0);
1578 }
1579 Fire::NotYet => panic!("due but not fired"),
1580 }
1581 let u = &usages[i.min(usages.len() - 1)];
1582 i += 1;
1583 p.charge(u.cost_usd(&model));
1584 if let Err(r) = p.ping_sent(u, Some(anchor_at(at, wall)), false) {
1585 return (p.pings(), p.ledger_usd(), p.bound_usd(), r, at - t0);
1586 }
1587 assert!(i < 1000, "runaway");
1588 }
1589 }
1590
1591 fn run(cfg: PeriodConfig, usages: &[KeepWarmUsage]) -> (u32, f64, f64, PeriodEnd, Duration) {
1592 let last = last_read(&cfg.model);
1593 run_from(cfg, &last, usages)
1594 }
1595
1596 #[test]
1601 fn review_205_possible_cache_miss_must_fit_admission_cap() {
1602 let t0 = Instant::now();
1603 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
1604 let mut p = start(
1605 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1606 &last_read(OPUS),
1607 t0,
1608 w0,
1609 );
1610 let at = p.next_at();
1611 let wall = w0 + at.saturating_duration_since(t0);
1612 let cap = 0.30;
1613 let decision = p.fire(at, wall, 0.0, Some(cap));
1614 let miss = KeepWarmUsage {
1615 cache_write_tokens: P,
1616 cache_write_1h: Some(P),
1617 ..KeepWarmUsage::default()
1618 };
1619 println!(
1620 "REVIEW estimated_worst={} possible_write={} cap={cap} decision={decision:?}",
1621 p.worst_next_usd,
1622 miss.cost_usd(OPUS)
1623 );
1624 if matches!(decision, Fire::Ping) {
1625 assert!(
1626 miss.cost_usd(OPUS) <= cap,
1627 "admitted request can exceed its session cost cap on a cache miss"
1628 );
1629 }
1630 }
1631
1632 #[test]
1636 fn a_cap_with_room_for_a_cache_miss_still_pings() {
1637 let t0 = Instant::now();
1638 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
1639 let mut p = start(
1640 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1641 &last_read(OPUS),
1642 t0,
1643 w0,
1644 );
1645 let exposure = p.exposure_next_usd();
1647 let miss = usage(OPUS, 0, P, PING_INPUT_ESTIMATE, 1);
1648 let miss = KeepWarmUsage {
1649 cache_write_1h: Some(P),
1650 ..miss
1651 };
1652 assert!((exposure - miss.cost_usd(OPUS)).abs() < 1e-9, "{exposure}");
1653 assert!(p.worst_next_usd() < p.bound_usd(), "bound basis is the read");
1654 let at = p.next_at();
1655 let wall = w0 + at.saturating_duration_since(t0);
1656 assert_eq!(
1657 p.fire(at, wall, 0.0, Some(3.0)),
1658 Fire::End(PeriodEnd::CostCap { cap: 3.0 })
1659 );
1660 assert_eq!(p.fire(at, wall, 0.0, Some(3.01)), Fire::Ping);
1661 }
1662
1663 #[test]
1664 fn ski_rental_1h_is_nineteen_pure_reads_over_about_17h() {
1665 let (pings, ledger, bound, reason, elapsed) = run(
1669 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1670 &[usage(OPUS, P, 0, 0, 0)],
1671 );
1672 assert!((bound - 3.0).abs() < 1e-9, "{bound}");
1673 assert_eq!(pings, 19);
1674 assert!(ledger <= bound * (1.0 + 1e-9));
1675 assert!(matches!(reason, PeriodEnd::BoundReached { .. }), "{reason}");
1676 assert_eq!(elapsed, 19 * 55 * MIN);
1677 }
1678
1679 #[test]
1680 fn ski_rental_5m_is_twelve_pure_reads_over_about_55_min() {
1681 let (pings, ledger, bound, reason, elapsed) = run(
1683 cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS),
1684 &[usage(OPUS, P, 0, 0, 0)],
1685 );
1686 assert!((bound - 1.875).abs() < 1e-9, "{bound}");
1687 assert_eq!(pings, 12);
1688 assert!((ledger - 1.8).abs() < 1e-9);
1689 assert!(matches!(reason, PeriodEnd::BoundReached { .. }), "{reason}");
1690 assert_eq!(elapsed, 12 * Duration::from_secs(270));
1691 }
1692
1693 #[test]
1694 fn measured_overhead_and_first_write_are_in_the_ledger() {
1695 let last = usage(OPUS, P - 2_000, 0, 0, 2_000);
1699 let first = usage(OPUS, P - 2_000, 2_000, 40, 1);
1700 let steady = usage(OPUS, P, 0, 40, 1);
1701 let (pings, ledger, bound, reason, _) = run_from(
1702 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1703 &last,
1704 &[first.clone(), steady.clone()],
1705 );
1706 assert!(matches!(reason, PeriodEnd::BoundReached { .. }), "{reason}");
1707 let expected_ledger = first.cost_usd(OPUS) + f64::from(pings - 1) * steady.cost_usd(OPUS);
1708 assert!(
1709 (ledger - expected_ledger).abs() < 1e-9,
1710 "ledger is the measured sum"
1711 );
1712 assert!(ledger <= bound * (1.0 + 1e-9), "never past the bound");
1713 assert!(ledger + steady.cost_usd(OPUS) > bound);
1714 assert_eq!(pings, 19);
1715 }
1716
1717 #[test]
1718 fn an_expensive_ping_stops_the_period_early() {
1719 let (heavy, ..) = run(
1722 cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, SONNET, 5_001),
1723 &[usage(SONNET, P, 0, 40, 5_000)],
1724 );
1725 let (light, ..) = run(
1726 cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, SONNET, 31),
1727 &[usage(SONNET, P, 0, 40, 30)],
1728 );
1729 assert_eq!(heavy, 10, "rewrite $1.80 / ($0.09 read + $0.075 thinking)");
1730 assert_eq!(light, 19);
1731 }
1732
1733 #[test]
1734 fn budgeted_thinking_output_is_accounted() {
1735 let u = usage(SONNET, P, 0, 40, 300);
1736 let read_only = usage(SONNET, P, 0, 0, 0);
1737 let thinking_usd = u.cost_usd(SONNET) - read_only.cost_usd(SONNET);
1738 assert!((thinking_usd - (300.0 * 15.0 + 40.0 * 3.0) / 1e6).abs() < 1e-12);
1740 let (pings, ledger, ..) = run(
1741 cfg_max(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, SONNET, 301),
1742 std::slice::from_ref(&u),
1743 );
1744 assert!((ledger - f64::from(pings) * u.cost_usd(SONNET)).abs() < 1e-9);
1745 }
1746
1747 #[test]
1752 fn budgeted_thinking_worst_case_must_fit_the_bound() {
1753 let t0 = Instant::now();
1754 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000);
1755 let small = usage(SONNET, 20_000, 0, 0, 0);
1756 let err = Period::start(
1757 1,
1758 cfg_max(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, SONNET, 32_769),
1759 7,
1760 &small,
1761 TtlEvidence::default(),
1762 anchor_at(t0, w0),
1763 t0,
1764 w0,
1765 )
1766 .unwrap_err();
1767 match err {
1768 PeriodEnd::PingTooExpensive {
1769 worst_usd,
1770 bound_usd,
1771 } => {
1772 assert!((bound_usd - 0.075).abs() < 1e-9, "{bound_usd}");
1773 assert!(worst_usd > 0.49, "{worst_usd}");
1774 }
1775 other => panic!("{other}"),
1776 }
1777 let p = start(
1780 cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, SONNET, 32_769),
1781 &usage(SONNET, P, 0, 0, 0),
1782 t0,
1783 w0,
1784 );
1785 assert!(p.worst_next_usd() > 0.58 && p.worst_next_usd() < p.bound_usd());
1786 }
1787
1788 #[test]
1789 fn max_session_cost_is_checked_against_the_worst_case() {
1790 let t0 = Instant::now();
1791 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(100_000);
1792 let mut p = start(
1795 cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS, 10_001),
1796 &last_read(OPUS),
1797 t0,
1798 w0,
1799 );
1800 let at = p.next_at();
1801 let wat = w0 + (at - t0);
1802 assert_eq!(
1804 p.fire(at, wat, 4.70, Some(5.0)),
1805 Fire::End(PeriodEnd::CostCap { cap: 5.0 })
1806 );
1807 assert_eq!(
1810 p.fire(at, wat, 4.50, Some(5.0)),
1811 Fire::End(PeriodEnd::CostCap { cap: 5.0 })
1812 );
1813 assert_eq!(p.fire(at, wat, 1.70, Some(5.0)), Fire::Ping);
1815 }
1816
1817 #[test]
1822 fn small_prompt_below_the_cache_minimum_never_starts() {
1823 let t0 = Instant::now();
1824 let w0 = SystemTime::UNIX_EPOCH;
1825 let below_minimum = usage(OPUS, 0, 0, 800, 20);
1826 assert_eq!(
1827 Period::start(
1828 1,
1829 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1830 7,
1831 &below_minimum,
1832 ev_1h(),
1833 anchor_at(t0, w0),
1834 t0,
1835 w0
1836 )
1837 .unwrap_err(),
1838 PeriodEnd::NothingCached
1839 );
1840 }
1841
1842 fn fired(c: PeriodConfig) -> (Period, Instant, SystemTime) {
1843 let t0 = Instant::now();
1844 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(90_000);
1845 let mut p = start(c, &last_read(OPUS), t0, w0);
1846 let at = p.next_at();
1847 let wat = w0 + (at - t0);
1848 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
1849 (p, at, wat)
1850 }
1851
1852 #[test]
1853 fn a_ping_that_cached_nothing_ends_the_period() {
1854 let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1855 assert_eq!(
1856 p.ping_sent(&usage(OPUS, 0, 0, P, 1), None, false),
1857 Err(PeriodEnd::NothingCached)
1858 );
1859 }
1860
1861 #[test]
1862 fn a_ping_paying_for_a_large_uncached_share_ends_the_period() {
1863 let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1865 assert_eq!(
1866 p.ping_sent(&usage(OPUS, P - 60_000, 0, 60_000, 1), None, false),
1867 Err(PeriodEnd::UncachedInput {
1868 input: 60_000,
1869 prefix: P
1870 })
1871 );
1872 let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1874 assert_eq!(p.ping_sent(&usage(OPUS, P, 0, 40, 1), None, false), Ok(()));
1875 }
1876
1877 #[test]
1878 fn a_ping_that_writes_instead_of_reading_ends_the_period() {
1879 let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1880 assert_eq!(
1881 p.ping_sent(&usage(OPUS, 0, P, 40, 1), None, false),
1882 Err(PeriodEnd::CacheMiss { written: P })
1883 );
1884 }
1885
1886 #[test]
1889 fn a_partial_miss_after_ping_one_ends_the_period() {
1890 let (mut p, at, wat) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
1891 p.ping_sent(&usage(OPUS, P, 0, 40, 1), Some(anchor_at(at, wat)), false)
1892 .unwrap();
1893 let at = p.next_at();
1894 let wat = wat + (at - p.anchor.mono);
1895 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
1896 assert_eq!(
1897 p.ping_sent(&usage(OPUS, P - 50_000, 50_000, 40, 1), None, false),
1898 Err(PeriodEnd::PartialMiss {
1899 written: 50_000,
1900 prefix: P
1901 })
1902 );
1903 }
1904
1905 #[test]
1906 fn ping_one_may_write_the_last_reply() {
1907 let t0 = Instant::now();
1908 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(3_000);
1909 let last = usage(OPUS, 250_000, 0, 0, 50_000);
1911 let mut p = start(
1912 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1913 &last,
1914 t0,
1915 w0,
1916 );
1917 assert_eq!(p.prefix_tokens(), P);
1918 let at = p.next_at();
1919 assert_eq!(p.fire(at, w0 + (at - t0), 0.0, None), Fire::Ping);
1920 assert_eq!(
1921 p.ping_sent(&usage(OPUS, 250_000, 50_000, 40, 1), None, false),
1922 Ok(()),
1923 "the reply's write is expected on ping 1"
1924 );
1925 }
1926
1927 #[test]
1930 fn honoured_ttl_needs_evidence_for_1h() {
1931 use CacheTtl::*;
1932 let none = TtlEvidence::default();
1933 assert_eq!(honoured_ttl(FiveMinutes, ev_1h()), FiveMinutes);
1934 assert_eq!(honoured_ttl(Hybrid, ev_1h()), Hybrid);
1935 assert_eq!(honoured_ttl(OneHour, none), FiveMinutes, "no evidence");
1936 assert_eq!(honoured_ttl(OneHour, ev_1h()), OneHour);
1937 let wrote_1h = TtlEvidence {
1938 write_1h: 10,
1939 ..none
1940 };
1941 assert_eq!(honoured_ttl(OneHour, wrote_1h), OneHour);
1942 let wrote_5m = TtlEvidence {
1943 saw_1h: true,
1944 write_5m: 10,
1945 ..none
1946 };
1947 assert_eq!(
1948 honoured_ttl(OneHour, wrote_5m),
1949 FiveMinutes,
1950 "5m-only write"
1951 );
1952 let downgraded = TtlEvidence {
1953 downgraded: true,
1954 write_1h: 10,
1955 saw_1h: true,
1956 ..none
1957 };
1958 assert_eq!(honoured_ttl(OneHour, downgraded), FiveMinutes, "latch wins");
1959 }
1960
1961 #[test]
1965 fn a_silent_downgrade_governs_by_5m() {
1966 let t0 = Instant::now();
1967 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(4_000);
1968 let downgraded = TtlEvidence {
1969 downgraded: true,
1970 ..TtlEvidence::default()
1971 };
1972 let p = Period::start(
1973 1,
1974 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1975 7,
1976 &last_read(OPUS),
1977 downgraded,
1978 anchor_at(t0, w0),
1979 t0,
1980 w0,
1981 )
1982 .unwrap();
1983 assert_eq!(p.ttl(), CacheTtl::FiveMinutes);
1984 assert_eq!(p.next_at(), t0 + Duration::from_secs(270));
1985 assert!((p.bound_usd() - 1.875).abs() < 1e-9);
1986
1987 let mut p = Period::start(
1990 2,
1991 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1992 7,
1993 &usage(OPUS, P - 1_000, 0, 0, 1_000),
1994 TtlEvidence::default(),
1995 anchor_at(t0, w0),
1996 t0,
1997 w0,
1998 )
1999 .unwrap();
2000 assert_eq!(p.ttl(), CacheTtl::FiveMinutes);
2001 let at = p.next_at();
2002 let wat = w0 + (at - t0);
2003 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2004 let mut first = usage(OPUS, P - 1_000, 1_000, 40, 1);
2005 first.cache_write_1h = Some(1_000);
2006 first.cache_write_5m = Some(0);
2007 p.ping_sent(&first, Some(anchor_at(at, wat)), false)
2008 .unwrap();
2009 assert_eq!(p.ttl(), CacheTtl::OneHour);
2010 assert_eq!(p.next_at(), at + 55 * MIN);
2011 let at = p.next_at();
2012 let wat = wat + 55 * MIN;
2013 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2014 p.ping_sent(&usage(OPUS, P, 0, 40, 1), Some(anchor_at(at, wat)), true)
2015 .unwrap();
2016 assert_eq!(p.ttl(), CacheTtl::FiveMinutes);
2017 assert_eq!(p.next_at(), at + Duration::from_secs(270));
2018 }
2019
2020 #[test]
2023 fn five_minute_pings_are_scheduled_from_the_anchor() {
2024 let t0 = Instant::now();
2025 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(5_000);
2026 let c = cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS);
2027 let idle = t0 + MIN;
2029 let mut p = Period::start(
2030 1,
2031 c,
2032 7,
2033 &last_read(OPUS),
2034 ev_1h(),
2035 anchor_at(t0, w0),
2036 idle,
2037 w0 + MIN,
2038 )
2039 .unwrap();
2040 assert_eq!(
2041 p.deadline(idle, w0 + MIN),
2042 Some(t0 + Duration::from_secs(270))
2043 );
2044 assert_eq!(
2045 p.fire(
2046 t0 + Duration::from_secs(200),
2047 w0 + Duration::from_secs(200),
2048 0.0,
2049 None
2050 ),
2051 Fire::NotYet
2052 );
2053 let at = t0 + Duration::from_secs(270);
2054 let wat = w0 + Duration::from_secs(270);
2055 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2056 assert_eq!(p.deadline(at, wat), None, "no second ping while in flight");
2057 assert_eq!(p.fire(at, wat, 0.0, None), Fire::NotYet);
2058 let sent = anchor_at(at + Duration::from_secs(2), wat + Duration::from_secs(2));
2060 p.ping_sent(&usage(OPUS, P, 0, 0, 1), Some(sent), false)
2061 .unwrap();
2062 assert_eq!(
2063 p.deadline(sent.mono, sent.wall),
2064 Some(sent.mono + Duration::from_secs(270))
2065 );
2066 }
2067
2068 #[test]
2069 fn hybrid_is_governed_by_the_5m_history_marker() {
2070 assert_eq!(governing_ttl(CacheTtl::Hybrid), Duration::from_secs(300));
2071 assert_eq!(governing_ttl(CacheTtl::OneHour), Duration::from_secs(3600));
2072 assert_eq!(ping_interval(CacheTtl::Hybrid), Duration::from_secs(270));
2073 assert!((rewrite_cost_usd(OPUS, CacheTtl::Hybrid, P) - 1.875).abs() < 1e-9);
2075 let (pings, ..) = run(
2076 cfg(CacheKeepWarm::Auto, CacheTtl::Hybrid, OPUS),
2077 &[usage(OPUS, P, 0, 0, 0)],
2078 );
2079 assert_eq!(pings, 12);
2080 }
2081
2082 #[test]
2083 fn a_turn_that_streamed_past_the_interval_pings_at_once_or_not_at_all() {
2084 let t0 = Instant::now();
2085 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(9_000);
2086 let c = || cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS);
2087 let at = |s: u64| (t0 + Duration::from_secs(s), w0 + Duration::from_secs(s));
2088 let (idle, widle) = at(280);
2089 let p = Period::start(
2090 1,
2091 c(),
2092 7,
2093 &last_read(OPUS),
2094 ev_1h(),
2095 anchor_at(t0, w0),
2096 idle,
2097 widle,
2098 )
2099 .unwrap();
2100 assert_eq!(p.deadline(idle, widle), Some(idle), "due immediately");
2101 let (late, wlate) = at(290);
2102 let err = Period::start(
2103 1,
2104 c(),
2105 7,
2106 &last_read(OPUS),
2107 ev_1h(),
2108 anchor_at(t0, w0),
2109 late,
2110 wlate,
2111 )
2112 .unwrap_err();
2113 assert!(matches!(err, PeriodEnd::Expired { age_secs: 290 }), "{err}");
2114 }
2115
2116 #[test]
2117 fn sleep_past_expiry_ends_the_period_without_pinging() {
2118 let t0 = Instant::now();
2119 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(50_000);
2120 for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour] {
2121 let c = cfg(CacheKeepWarm::Auto, ttl, OPUS);
2122 let mut p = start(c, &last_read(OPUS), t0, w0);
2123 let due = p.next_at();
2124 let wall = w0 + Duration::from_secs(3 * 3600);
2127 match p.fire(due, wall, 0.0, None) {
2128 Fire::End(PeriodEnd::Expired { age_secs }) => assert_eq!(age_secs, 3 * 3600),
2129 other => panic!("{ttl:?}: {other:?}"),
2130 }
2131 }
2132 }
2133
2134 #[test]
2139 fn a_suspend_pulls_the_deadline_forward_by_the_wall_clock() {
2140 let t0 = Instant::now();
2141 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(60_000);
2142 let mut p = start(
2143 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2144 &last_read(OPUS),
2145 t0,
2146 w0,
2147 );
2148 let (now, wall) = (t0 + 10 * MIN, w0 + 40 * MIN);
2150 assert_eq!(p.deadline(now, wall), Some(now + 15 * MIN));
2151 assert_eq!(p.fire(now, wall, 0.0, None), Fire::NotYet);
2152 let (now, wall) = (now + 15 * MIN, wall + 15 * MIN);
2155 assert_eq!(p.fire(now, wall, 0.0, None), Fire::Ping);
2156 assert_eq!(p.fired_age(), 55 * MIN);
2157 }
2158
2159 #[test]
2160 fn the_actor_polls_the_wall_clock_while_a_period_is_active() {
2161 assert!(WALL_POLL < safety_margin(CacheTtl::FiveMinutes));
2162 let t0 = Instant::now();
2163 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(61_000);
2164 let mut kw = KeepWarmState::default();
2165 assert_eq!(kw.deadline_at(t0, w0), None, "no period: no timer");
2166 kw.period = Some(start(
2168 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2169 &last_read(OPUS),
2170 t0,
2171 w0,
2172 ));
2173 assert_eq!(kw.deadline_at(t0, w0), Some(t0 + WALL_POLL));
2174 let (near, wnear) = (
2176 t0 + 55 * MIN - Duration::from_secs(3),
2177 w0 + 55 * MIN - Duration::from_secs(3),
2178 );
2179 assert_eq!(kw.deadline_at(near, wnear), Some(t0 + 55 * MIN));
2180 let p = kw.period.as_mut().unwrap();
2182 assert_eq!(p.fire(t0 + 55 * MIN, w0 + 55 * MIN, 0.0, None), Fire::Ping);
2183 assert_eq!(kw.deadline_at(t0 + 55 * MIN, w0 + 55 * MIN), None);
2184 }
2185
2186 #[test]
2189 fn transient_errors_back_off_within_the_ttl_then_end() {
2190 let t0 = Instant::now();
2191 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(70_000);
2192 let mut p = start(
2193 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2194 &last_read(OPUS),
2195 t0,
2196 w0,
2197 );
2198 let mut now = p.next_at();
2199 let transient = PingFailure::Transient("HTTP 529".into());
2200 for want in BACKOFF {
2201 let wall = w0 + now.saturating_duration_since(t0);
2202 assert_eq!(p.fire(now, wall, 0.0, None), Fire::Ping);
2203 assert_eq!(p.ping_failed(&transient, now, wall), Ok(want));
2204 assert_eq!(p.deadline(now, wall), Some(now + want));
2205 now += want;
2206 }
2207 let wall = w0 + now.saturating_duration_since(t0);
2208 assert_eq!(p.fire(now, wall, 0.0, None), Fire::Ping);
2209 assert!(matches!(
2210 p.ping_failed(&transient, now, wall),
2211 Err(PeriodEnd::TransientExhausted(_))
2212 ));
2213
2214 let mut p = start(
2216 cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS),
2217 &last_read(OPUS),
2218 t0,
2219 w0,
2220 );
2221 let at = p.next_at();
2222 let wat = w0 + Duration::from_secs(270);
2223 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2224 assert_eq!(p.ping_failed(&transient, at, wat), Ok(BACKOFF[0]));
2225 let at = at + BACKOFF[0];
2226 let wat = wat + BACKOFF[0];
2227 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2228 assert!(matches!(
2229 p.ping_failed(&transient, at, wat),
2230 Err(PeriodEnd::TransientExhausted(_))
2231 ));
2232 }
2233
2234 #[test]
2236 fn failed_ping_spend_is_in_the_ledger() {
2237 let (mut p, at, wat) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
2238 p.charge(1.80);
2239 assert!(matches!(
2240 p.ping_failed(&PingFailure::Transient("x".into()), at, wat),
2241 Err(PeriodEnd::BoundReached { .. })
2242 ));
2243 }
2244
2245 #[test]
2246 fn a_4xx_ends_the_period() {
2247 let (mut p, at, wat) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS));
2248 let err = p
2249 .ping_failed(&PingFailure::Fatal("HTTP 400".into()), at, wat)
2250 .unwrap_err();
2251 assert_eq!(err, PeriodEnd::Rejected("HTTP 400".into()));
2252 }
2253
2254 #[test]
2255 fn hours_window_and_bound_whichever_first() {
2256 let (pings, _, _, reason, _) = run(
2258 cfg(CacheKeepWarm::Hours(1), CacheTtl::OneHour, OPUS),
2259 &[usage(OPUS, P, 0, 0, 0)],
2260 );
2261 assert_eq!((pings, reason), (1, PeriodEnd::HoursReached(1)));
2262 let (pings, _, _, reason, _) = run(
2264 cfg(CacheKeepWarm::Hours(2), CacheTtl::FiveMinutes, OPUS),
2265 &[usage(OPUS, P, 0, 0, 0)],
2266 );
2267 assert_eq!(pings, 12);
2268 assert!(matches!(reason, PeriodEnd::BoundReached { .. }));
2269 let t0 = Instant::now();
2271 let w0 = SystemTime::UNIX_EPOCH;
2272 let mut c = cfg(CacheKeepWarm::Hours(1), CacheTtl::OneHour, OPUS);
2273 c.interval_override = Some(Duration::from_secs(2 * 3600));
2274 assert_eq!(
2275 Period::start(
2276 1,
2277 c,
2278 7,
2279 &last_read(OPUS),
2280 ev_1h(),
2281 anchor_at(t0, w0),
2282 t0,
2283 w0
2284 )
2285 .unwrap_err(),
2286 PeriodEnd::HoursReached(1)
2287 );
2288 }
2289
2290 #[test]
2296 #[serial_test::serial(synaps_keepwarm_interval)]
2297 fn the_interval_override_is_set_only_by_code_never_by_the_environment() {
2298 let _env = crate::test_env::EnvVarGuard::set("SYNAPS_CACHE_KEEPWARM_INTERVAL_MS", "250");
2299 assert_eq!(interval_override(), None, "the old env var does nothing");
2300 {
2301 let _g = testing::IntervalOverride::set(Duration::from_millis(250));
2302 assert_eq!(interval_override(), Some(Duration::from_millis(250)));
2303 }
2304 assert_eq!(interval_override(), None, "cleared with the guard");
2305 }
2306
2307 #[test]
2312 fn a_re_enabled_period_continues_the_exhausted_ledger() {
2313 let t0 = Instant::now();
2314 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(80_000);
2315 let c = || cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS);
2316 let mut p = start(c(), &last_read(OPUS), t0, w0);
2317 let read = usage(OPUS, P, 0, 0, 0);
2318 let end = loop {
2319 let at = p.next_at();
2320 let wat = w0 + (at - t0);
2321 match p.fire(at, wat, 0.0, None) {
2322 Fire::Ping => {}
2323 Fire::End(r) => break r,
2324 Fire::NotYet => unreachable!(),
2325 }
2326 p.charge(read.cost_usd(OPUS));
2327 if let Err(r) = p.ping_sent(&read, Some(anchor_at(at, wat)), false) {
2328 break r;
2329 }
2330 };
2331 assert!(matches!(end, PeriodEnd::BoundReached { .. }), "{end}");
2332 let carry = p.epoch_carry();
2333 assert_eq!(carry.pings, 19);
2334 let anchor = p.anchor;
2336 let err = Period::start_in_epoch(
2337 2,
2338 c(),
2339 7,
2340 &last_read(OPUS),
2341 ev_1h(),
2342 anchor,
2343 anchor.mono,
2344 anchor.wall,
2345 Some(&carry),
2346 )
2347 .unwrap_err();
2348 assert!(matches!(err, PeriodEnd::BoundReached { .. }), "{err}");
2349 assert!(Period::start_in_epoch(
2351 3,
2352 c(),
2353 7,
2354 &last_read(OPUS),
2355 ev_1h(),
2356 anchor,
2357 anchor.mono,
2358 anchor.wall,
2359 None
2360 )
2361 .is_ok());
2362 }
2363
2364 #[test]
2367 fn ping_one_write_allowance_is_never_re_granted_in_an_epoch() {
2368 let t0 = Instant::now();
2369 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(81_000);
2370 let c = || cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS);
2371 let last = usage(OPUS, 250_000, 0, 0, 50_000);
2372 let mut p = start(c(), &last, t0, w0);
2373 let at = p.next_at();
2374 let wat = w0 + (at - t0);
2375 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2376 let first = usage(OPUS, 250_000, 50_000, 40, 1);
2377 p.charge(first.cost_usd(OPUS));
2378 p.ping_sent(&first, Some(anchor_at(at, wat)), false)
2379 .unwrap();
2380 let carry = p.epoch_carry();
2381 let mut q = Period::start_in_epoch(
2383 2,
2384 c(),
2385 7,
2386 &last,
2387 ev_1h(),
2388 anchor_at(at, wat),
2389 at,
2390 wat,
2391 Some(&carry),
2392 )
2393 .unwrap();
2394 assert_eq!(q.pings(), 1);
2395 assert!((q.ledger_usd() - first.cost_usd(OPUS)).abs() < 1e-12);
2396 let at2 = q.next_at();
2397 let wat2 = wat + (at2 - at);
2398 assert_eq!(q.fire(at2, wat2, 0.0, None), Fire::Ping);
2399 assert_eq!(
2401 q.ping_sent(&usage(OPUS, 250_000, 50_000, 40, 1), None, false),
2402 Err(PeriodEnd::PartialMiss {
2403 written: 50_000,
2404 prefix: P
2405 })
2406 );
2407 }
2408
2409 #[test]
2415 fn ping_counts_per_multiplier_and_the_auto_cap() {
2416 for (model, uncapped, capped) in [
2417 ("claude-opus-4-7", 19, 19),
2418 ("claude-opus-5-5", 39, 26),
2419 ("claude-fable-5-1", 79, 26),
2420 ] {
2421 let mut c = cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, model);
2422 let (pings, _, _, reason, elapsed) = run(c.clone(), &[usage(model, P, 0, 0, 0)]);
2423 assert_eq!(pings, uncapped, "{model}");
2424 assert!(
2425 matches!(reason, PeriodEnd::BoundReached { .. }),
2426 "{model}: {reason}"
2427 );
2428 assert_eq!(elapsed, uncapped * 55 * MIN, "{model}");
2429 c.auto_max_hours = 24;
2430 let (pings, _, _, reason, _) = run(c, &[usage(model, P, 0, 0, 0)]);
2431 assert_eq!(pings, capped, "{model}");
2432 if capped < uncapped {
2433 assert_eq!(reason, PeriodEnd::HoursReached(24), "{model}");
2434 }
2435 }
2436 for (model, want) in [
2438 ("claude-opus-4-7", 12),
2439 ("claude-opus-5-5", 24),
2440 ("claude-fable-5-1", 49),
2441 ] {
2442 let mut c = cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, model);
2443 c.auto_max_hours = 24;
2444 let (pings, ..) = run(c, &[usage(model, P, 0, 0, 0)]);
2445 assert_eq!(pings, want, "{model} 5m");
2446 }
2447 let mut c = cfg(
2449 CacheKeepWarm::Hours(48),
2450 CacheTtl::OneHour,
2451 "claude-fable-5-1",
2452 );
2453 c.auto_max_hours = 24;
2454 let (pings, ..) = run(c, &[usage("claude-fable-5-1", P, 0, 0, 0)]);
2455 assert_eq!(pings, 52, "48h window: 52 pings at 55 min");
2456 }
2457
2458 #[test]
2461 fn first_write_counts_only_what_the_prefix_will_hold() {
2462 let t0 = Instant::now();
2463 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(82_000);
2464 let last = usage(OPUS, 250_000, 0, 1_000, 40_000);
2468 let mut c = cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS);
2469 let billed = start(c.clone(), &last, t0, w0);
2470 assert_eq!(billed.prefix_tokens(), 291_000, "without the estimate");
2471 c.reply_tokens = Some(2_000);
2472 let p = start(c.clone(), &last, t0, w0);
2473 assert_eq!(p.prefix_tokens(), 253_000);
2474 c.reply_tokens = Some(90_000);
2476 assert_eq!(start(c, &last, t0, w0).prefix_tokens(), 291_000);
2477 }
2478
2479 #[test]
2480 fn status_renders_spend_and_period() {
2481 let s = serde_json::json!({
2482 "mode": "auto",
2483 "active": true,
2484 "period": {"id": 3, "ttl_secs": 300, "pings": 2, "ledger_usd": 0.3,
2485 "bound_usd": 1.875, "prefix_tokens": P, "next_ping_in_secs": 120,
2486 "in_flight": false},
2487 "prefix_matches": true,
2488 "session_pings": 5,
2489 "session_keepwarm_usd": 0.75,
2490 "last_end": "prefix changed",
2491 });
2492 let text = render_status(&s);
2493 assert!(text.contains("auto"), "{text}");
2494 assert!(text.contains("5 pings, $0.7500"), "{text}");
2495 assert!(text.contains("ping 3 due in 120s"), "{text}");
2496 assert!(text.contains("$0.3000 spent of $1.8750 bound"), "{text}");
2497 assert!(text.contains("prefix unchanged"), "{text}");
2498 assert!(text.contains("last: prefix changed"), "{text}");
2499 }
2500}