1use std::time::{Duration, SystemTime};
55
56use tokio::time::Instant;
57use tokio_util::sync::CancellationToken;
58
59use super::actor::SessionActor;
60use super::types::{AttachMode, ClientKind};
61use crate::core::config::{CacheKeepWarm, CacheTtl};
62use crate::runtime::keepwarm::{
63 CacheAnchor, KeepWarmProbe, KeepWarmUsage, PingBill, PingFailure, PingOutcome, PingReport,
64 PingTap,
65};
66
67pub const LATE_GUARD: Duration = Duration::from_secs(15);
70
71pub const BACKOFF: [Duration; 3] = [
73 Duration::from_secs(5),
74 Duration::from_secs(15),
75 Duration::from_secs(30),
76];
77
78pub const WALL_POLL: Duration = Duration::from_secs(15);
81
82pub const SHARE_LIMIT_PERCENT: u64 = 10;
88
89pub const PING_INPUT_ESTIMATE: u64 = 64;
92
93pub const PING_OUTPUT_ESTIMATE: u64 = 256;
101
102pub fn ping_output_estimate(max_tokens: u64) -> u64 {
106 max_tokens.min(PING_OUTPUT_ESTIMATE)
107}
108
109const BOUND_EPSILON: f64 = 1e-9;
111
112fn over_share(tokens: u64, prefix: u64) -> bool {
113 tokens.saturating_mul(100) > prefix.saturating_mul(SHARE_LIMIT_PERCENT)
114}
115
116pub fn governing_ttl(ttl: CacheTtl) -> Duration {
121 match ttl {
122 CacheTtl::OneHour => Duration::from_secs(3600),
123 CacheTtl::FiveMinutes | CacheTtl::Hybrid => Duration::from_secs(300),
124 }
125}
126
127pub fn safety_margin(ttl: CacheTtl) -> Duration {
129 match ttl {
130 CacheTtl::OneHour => Duration::from_secs(300),
131 CacheTtl::FiveMinutes | CacheTtl::Hybrid => Duration::from_secs(30),
132 }
133}
134
135pub fn ping_interval(ttl: CacheTtl) -> Duration {
137 governing_ttl(ttl) - safety_margin(ttl)
138}
139
140#[cfg(any(test, feature = "testing"))]
143static INTERVAL_OVERRIDE_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
144
145pub fn interval_override() -> Option<Duration> {
155 #[cfg(any(test, feature = "testing"))]
156 {
157 let ms = INTERVAL_OVERRIDE_MS.load(std::sync::atomic::Ordering::Relaxed);
158 if ms > 0 {
159 return Some(Duration::from_millis(ms));
160 }
161 }
162 None
163}
164
165#[cfg(any(test, feature = "testing"))]
167pub mod testing {
168 use std::sync::atomic::Ordering;
169 use std::time::Duration;
170
171 #[must_use = "the override is cleared when the guard drops"]
174 pub struct IntervalOverride(());
175
176 impl IntervalOverride {
177 pub fn set(interval: Duration) -> Self {
178 let ms = u64::try_from(interval.as_millis())
179 .unwrap_or(u64::MAX)
180 .max(1);
181 super::INTERVAL_OVERRIDE_MS.store(ms, Ordering::Relaxed);
182 Self(())
183 }
184 }
185
186 impl Drop for IntervalOverride {
187 fn drop(&mut self) {
188 super::INTERVAL_OVERRIDE_MS.store(0, Ordering::Relaxed);
189 }
190 }
191
192 static DUE_CHECKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
193
194 pub fn due_checks() -> u64 {
198 DUE_CHECKS.load(Ordering::SeqCst)
199 }
200
201 pub(super) fn note_due_check() {
202 DUE_CHECKS.fetch_add(1, Ordering::SeqCst);
203 }
204}
205
206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
208pub struct TtlEvidence {
209 pub downgraded: bool,
211 pub saw_1h: bool,
213 pub write_1h: u64,
215 pub write_5m: u64,
216}
217
218pub fn honoured_ttl(configured: CacheTtl, e: TtlEvidence) -> CacheTtl {
222 if configured != CacheTtl::OneHour {
223 return configured;
224 }
225 if e.downgraded {
226 return CacheTtl::FiveMinutes;
227 }
228 if e.write_1h > 0 {
229 return CacheTtl::OneHour;
230 }
231 if e.write_5m > 0 {
232 return CacheTtl::FiveMinutes;
233 }
234 if e.saw_1h {
235 CacheTtl::OneHour
236 } else {
237 CacheTtl::FiveMinutes
238 }
239}
240
241pub fn request_cost_usd(
244 model: &str,
245 ttl: CacheTtl,
246 read: u64,
247 write: u64,
248 input: u64,
249 output: u64,
250) -> f64 {
251 match ttl {
252 CacheTtl::OneHour => {
253 crate::pricing::calculate_cost_split(model, input, output, read, 0, write)
254 }
255 CacheTtl::FiveMinutes | CacheTtl::Hybrid => {
256 crate::pricing::calculate_cost_split(model, input, output, read, write, 0)
257 }
258 }
259}
260
261pub fn rewrite_cost_usd(model: &str, ttl: CacheTtl, prefix_tokens: u64) -> f64 {
264 request_cost_usd(model, ttl, 0, prefix_tokens, 0, 0)
265}
266
267#[derive(Debug, Clone, PartialEq)]
270pub enum PeriodEnd {
271 NothingCached,
274 UncachedInput {
276 input: u64,
277 prefix: u64,
278 },
279 BoundReached {
283 ledger_usd: f64,
284 bound_usd: f64,
285 reserve_usd: f64,
286 },
287 PingTooExpensive {
291 expected_usd: f64,
292 worst_usd: f64,
293 rewrite_usd: f64,
294 },
295 HoursReached(u32),
297 Expired {
300 age_secs: u64,
301 },
302 PrefixChanged,
304 ContextRollover(String),
307 CacheMiss {
309 written: u64,
310 },
311 PartialMiss {
313 written: u64,
314 prefix: u64,
315 },
316 Rejected(String),
318 TransientExhausted(String),
320 CostCap {
323 cap: f64,
324 },
325 TurnStarted,
326 Compaction,
327 Disabled,
329 ParkedByRequest,
331 SessionEnding,
332 Blocked(&'static str),
334}
335
336impl std::fmt::Display for PeriodEnd {
337 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338 match self {
339 PeriodEnd::NothingCached => f.write_str("nothing cached"),
340 PeriodEnd::UncachedInput { input, prefix } => write!(
341 f,
342 "ping paid for {input} uncached input tokens (prefix {prefix})"
343 ),
344 PeriodEnd::BoundReached {
345 ledger_usd,
346 bound_usd,
347 reserve_usd,
348 } if *reserve_usd > 0.0 => write!(
349 f,
350 "ski-rental bound reached (${ledger_usd:.4} spent of ${bound_usd:.4}: \
351 ${:.4} rewrite + ${reserve_usd:.4} thinking reserve)",
352 bound_usd - reserve_usd
353 ),
354 PeriodEnd::BoundReached {
355 ledger_usd,
356 bound_usd,
357 ..
358 } => write!(
359 f,
360 "ski-rental bound reached (${ledger_usd:.4} spent of ${bound_usd:.4})"
361 ),
362 PeriodEnd::PingTooExpensive {
363 expected_usd,
364 worst_usd,
365 rewrite_usd,
366 } if worst_usd > expected_usd => write!(
367 f,
368 "cache-hit ping expected at ${expected_usd:.4} (${worst_usd:.4} worst case) \
369 exceeds the ${rewrite_usd:.4} rewrite it would save"
370 ),
371 PeriodEnd::PingTooExpensive {
372 worst_usd,
373 rewrite_usd,
374 ..
375 } => write!(
376 f,
377 "worst-case cache-hit ping ${worst_usd:.4} exceeds the ${rewrite_usd:.4} rewrite it would save"
378 ),
379 PeriodEnd::HoursReached(h) => write!(f, "{h}h window reached"),
380 PeriodEnd::Expired { age_secs } => write!(
381 f,
382 "cache expired or about to ({age_secs}s since last request; slept?)"
383 ),
384 PeriodEnd::PrefixChanged => f.write_str("prefix changed"),
385 PeriodEnd::ContextRollover(why) => write!(f, "{why}"),
386 PeriodEnd::CacheMiss { written } => {
387 write!(f, "ping missed the cache (wrote {written} tokens)")
388 }
389 PeriodEnd::PartialMiss { written, prefix } => write!(
390 f,
391 "ping rewrote {written} of {prefix} prefix tokens (history no longer cached)"
392 ),
393 PeriodEnd::Rejected(m) => write!(f, "ping rejected: {m}"),
394 PeriodEnd::TransientExhausted(m) => write!(f, "transient errors: {m}"),
395 PeriodEnd::CostCap { cap } => write!(f, "max_session_cost ${cap:.4} reached"),
396 PeriodEnd::TurnStarted => f.write_str("turn started"),
397 PeriodEnd::Compaction => f.write_str("compaction"),
398 PeriodEnd::Disabled => f.write_str("disabled"),
399 PeriodEnd::ParkedByRequest => f.write_str("parked by request"),
400 PeriodEnd::SessionEnding => f.write_str("session ending"),
401 PeriodEnd::Blocked(why) => write!(f, "{why}"),
402 }
403 }
404}
405
406#[derive(Debug, Clone)]
408pub struct PeriodConfig {
409 pub mode: CacheKeepWarm,
410 pub ttl: CacheTtl,
412 pub model: String,
413 pub max_tokens: u64,
415 pub output_estimate: u64,
420 pub interval_override: Option<Duration>,
422 pub auto_max_hours: u32,
425 pub reply_tokens: Option<u64>,
430}
431
432#[derive(Debug, Clone, Copy, PartialEq)]
439pub struct EpochCarry {
440 pub ledger_usd: f64,
441 pub pings: u32,
442 pub prefix_tokens: u64,
443 pub ping_input: u64,
444 pub idle_since: Instant,
445 pub idle_wall: SystemTime,
446}
447
448#[derive(Debug, Clone, PartialEq)]
450pub enum Fire {
451 NotYet,
452 Ping,
453 End(PeriodEnd),
454}
455
456#[derive(Debug, Clone)]
458pub struct Period {
459 pub id: u64,
460 cfg: PeriodConfig,
461 ttl: CacheTtl,
463 evidence: TtlEvidence,
464 idle_since: Instant,
465 idle_wall: SystemTime,
466 anchor: CacheAnchor,
467 fingerprint: u64,
468 prefix_tokens: u64,
469 first_write: u64,
471 rewrite_usd: f64,
473 reserve_usd: f64,
476 bound_usd: f64,
478 ledger_usd: f64,
479 expected_next_usd: f64,
482 worst_next_usd: f64,
485 exposure_next_usd: f64,
488 ping_input: u64,
490 pings: u32,
491 retries: usize,
492 next_at: Instant,
493 next_wall: SystemTime,
494 in_flight: bool,
495 fired_age: Duration,
497}
498
499fn age(anchor: &CacheAnchor, now: Instant, wall: SystemTime) -> Duration {
503 let mono = now.saturating_duration_since(anchor.mono);
504 let wall = wall.duration_since(anchor.wall).unwrap_or_default();
505 mono.max(wall)
506}
507
508impl Period {
509 #[allow(clippy::too_many_arguments)]
513 pub fn start(
514 id: u64,
515 cfg: PeriodConfig,
516 fingerprint: u64,
517 last: &KeepWarmUsage,
518 evidence: TtlEvidence,
519 anchor: CacheAnchor,
520 now: Instant,
521 wall: SystemTime,
522 ) -> Result<Period, PeriodEnd> {
523 Self::start_in_epoch(
524 id,
525 cfg,
526 fingerprint,
527 last,
528 evidence,
529 anchor,
530 now,
531 wall,
532 None,
533 )
534 }
535
536 #[allow(clippy::too_many_arguments)]
539 pub fn start_in_epoch(
540 id: u64,
541 cfg: PeriodConfig,
542 fingerprint: u64,
543 last: &KeepWarmUsage,
544 evidence: TtlEvidence,
545 anchor: CacheAnchor,
546 now: Instant,
547 wall: SystemTime,
548 carry: Option<&EpochCarry>,
549 ) -> Result<Period, PeriodEnd> {
550 let cached = last.cache_read_tokens + last.cache_write_tokens;
551 if cached == 0 {
552 return Err(PeriodEnd::NothingCached);
553 }
554 let ttl = honoured_ttl(cfg.ttl, evidence);
555 let age_now = age(&anchor, now, wall);
556 if age_now + LATE_GUARD >= governing_ttl(ttl) {
557 return Err(PeriodEnd::Expired {
558 age_secs: age_now.as_secs(),
559 });
560 }
561 let reply = cfg
566 .reply_tokens
567 .map_or(last.output_tokens, |r| r.min(last.output_tokens));
568 let first_write = last.input_tokens + reply;
569 let prefix_tokens = cached + first_write;
570 let mut p = Period {
571 id,
572 cfg,
573 ttl,
574 evidence,
575 idle_since: now,
576 idle_wall: wall,
577 anchor,
578 fingerprint,
579 prefix_tokens,
580 first_write,
581 rewrite_usd: 0.0,
582 reserve_usd: 0.0,
583 bound_usd: 0.0,
584 ledger_usd: 0.0,
585 expected_next_usd: 0.0,
586 worst_next_usd: 0.0,
587 exposure_next_usd: 0.0,
588 ping_input: PING_INPUT_ESTIMATE,
589 pings: 0,
590 retries: 0,
591 next_at: now,
592 next_wall: wall,
593 in_flight: false,
594 fired_age: Duration::ZERO,
595 };
596 if let Some(c) = carry {
597 p.ledger_usd = c.ledger_usd;
600 p.pings = c.pings;
601 p.idle_since = c.idle_since;
602 p.idle_wall = c.idle_wall;
603 if c.pings > 0 {
604 p.prefix_tokens = c.prefix_tokens;
605 p.ping_input = c.ping_input;
606 }
607 }
608 p.reprice();
609 let interval = p.interval();
611 p.next_at = (anchor.mono + interval).max(now);
612 p.next_wall = (anchor.wall + interval).max(wall);
613 if p.worst_next_usd > p.bound_usd * (1.0 + BOUND_EPSILON) {
616 return Err(PeriodEnd::PingTooExpensive {
617 expected_usd: p.expected_next_usd,
618 worst_usd: p.worst_next_usd,
619 rewrite_usd: p.rewrite_usd,
620 });
621 }
622 if let Some(r) = p
623 .past_window(p.next_at, p.next_wall)
624 .or_else(|| p.past_bound())
625 {
626 return Err(r);
627 }
628 Ok(p)
629 }
630
631 pub fn epoch_carry(&self) -> EpochCarry {
633 EpochCarry {
634 ledger_usd: self.ledger_usd,
635 pings: self.pings,
636 prefix_tokens: self.prefix_tokens,
637 ping_input: self.ping_input,
638 idle_since: self.idle_since,
639 idle_wall: self.idle_wall,
640 }
641 }
642
643 fn reprice(&mut self) {
655 let model = self.cfg.model.as_str();
656 let max_tokens = self.cfg.max_tokens;
657 let expected_out = self.cfg.output_estimate.min(max_tokens);
658 self.rewrite_usd = rewrite_cost_usd(model, self.ttl, self.prefix_tokens);
659 let write = if self.pings == 0 { self.first_write } else { 0 };
660 let hit = |out| {
661 request_cost_usd(
662 model,
663 self.ttl,
664 self.prefix_tokens.saturating_sub(write),
665 write,
666 self.ping_input,
667 out,
668 )
669 };
670 self.expected_next_usd = hit(expected_out);
671 self.worst_next_usd = hit(max_tokens);
672 self.reserve_usd = (self.worst_next_usd - self.expected_next_usd).max(0.0);
675 self.bound_usd = self.rewrite_usd + self.reserve_usd;
676 let miss = |ttl| {
680 request_cost_usd(
681 model,
682 ttl,
683 0,
684 self.prefix_tokens,
685 self.ping_input,
686 self.cfg.max_tokens,
687 )
688 };
689 let mut miss_usd = miss(self.ttl);
690 if matches!(self.cfg.ttl, CacheTtl::OneHour | CacheTtl::Hybrid) {
691 miss_usd = miss_usd.max(miss(CacheTtl::OneHour));
692 }
693 self.exposure_next_usd = self.worst_next_usd.max(miss_usd);
694 }
695
696 fn interval(&self) -> Duration {
697 self.cfg
698 .interval_override
699 .unwrap_or_else(|| ping_interval(self.ttl))
700 }
701
702 pub fn fingerprint(&self) -> u64 {
703 self.fingerprint
704 }
705 pub fn pings(&self) -> u32 {
706 self.pings
707 }
708 pub fn ledger_usd(&self) -> f64 {
709 self.ledger_usd
710 }
711 pub fn bound_usd(&self) -> f64 {
715 self.bound_usd
716 }
717 pub fn rewrite_usd(&self) -> f64 {
719 self.rewrite_usd
720 }
721 pub fn reserve_usd(&self) -> f64 {
724 self.reserve_usd
725 }
726 pub fn expected_next_usd(&self) -> f64 {
728 self.expected_next_usd
729 }
730 pub fn worst_next_usd(&self) -> f64 {
734 self.worst_next_usd
735 }
736 pub fn exposure_next_usd(&self) -> f64 {
740 self.exposure_next_usd
741 }
742 pub fn prefix_tokens(&self) -> u64 {
743 self.prefix_tokens
744 }
745 pub fn next_at(&self) -> Instant {
746 self.next_at
747 }
748 pub fn mode(&self) -> CacheKeepWarm {
749 self.cfg.mode
750 }
751 pub fn ttl(&self) -> CacheTtl {
753 self.ttl
754 }
755 pub fn fired_age(&self) -> Duration {
756 self.fired_age
757 }
758 pub fn in_flight(&self) -> bool {
759 self.in_flight
760 }
761
762 pub fn set_mode(&mut self, mode: CacheKeepWarm) {
764 self.cfg.mode = mode;
765 }
766
767 pub fn deadline(&self, now: Instant, wall: SystemTime) -> Option<Instant> {
771 if self.in_flight {
772 return None;
773 }
774 let wall_left = self.next_wall.duration_since(wall).unwrap_or_default();
775 Some(self.next_at.min(now + wall_left))
776 }
777
778 fn past_window(&self, at: Instant, wall: SystemTime) -> Option<PeriodEnd> {
780 let h = match self.cfg.mode {
781 CacheKeepWarm::Hours(h) => h,
782 CacheKeepWarm::Auto if self.cfg.auto_max_hours > 0 => self.cfg.auto_max_hours,
783 _ => return None,
784 };
785 let window = Duration::from_secs(u64::from(h) * 3600);
786 let walled = wall.duration_since(self.idle_wall).unwrap_or_default();
787 (at.saturating_duration_since(self.idle_since) >= window || walled >= window)
788 .then_some(PeriodEnd::HoursReached(h))
789 }
790
791 fn past_bound(&self) -> Option<PeriodEnd> {
792 (self.ledger_usd + self.worst_next_usd > self.bound_usd * (1.0 + BOUND_EPSILON)).then_some(
793 PeriodEnd::BoundReached {
794 ledger_usd: self.ledger_usd,
795 bound_usd: self.bound_usd,
796 reserve_usd: self.reserve_usd,
797 },
798 )
799 }
800
801 pub fn fire(
803 &mut self,
804 now: Instant,
805 wall: SystemTime,
806 session_cost: f64,
807 max_session_cost: Option<f64>,
808 ) -> Fire {
809 if self.in_flight || (now < self.next_at && wall < self.next_wall) {
810 return Fire::NotYet;
811 }
812 let age_now = age(&self.anchor, now, wall);
813 if age_now + LATE_GUARD >= governing_ttl(self.ttl) {
814 return Fire::End(PeriodEnd::Expired {
815 age_secs: age_now.as_secs(),
816 });
817 }
818 if let Some(r) = self.past_window(now, wall).or_else(|| self.past_bound()) {
819 return Fire::End(r);
820 }
821 if let Some(cap) = max_session_cost.filter(|c| c.is_finite()) {
822 if session_cost + self.exposure_next_usd >= cap {
824 return Fire::End(PeriodEnd::CostCap { cap });
825 }
826 }
827 self.in_flight = true;
828 self.fired_age = age_now;
829 Fire::Ping
830 }
831
832 pub fn charge(&mut self, cost_usd: f64) {
834 self.ledger_usd += cost_usd;
835 }
836
837 pub fn ping_sent(
842 &mut self,
843 usage: &KeepWarmUsage,
844 anchor: Option<CacheAnchor>,
845 downgraded: bool,
846 ) -> Result<(), PeriodEnd> {
847 self.in_flight = false;
848 self.retries = 0;
849 self.pings += 1;
850 let (read, write) = (usage.cache_read_tokens, usage.cache_write_tokens);
851 if read == 0 && write == 0 {
852 return Err(PeriodEnd::NothingCached);
853 }
854 if read == 0 {
855 return Err(PeriodEnd::CacheMiss { written: write });
856 }
857 if over_share(usage.input_tokens, self.prefix_tokens) {
858 return Err(PeriodEnd::UncachedInput {
859 input: usage.input_tokens,
860 prefix: self.prefix_tokens,
861 });
862 }
863 let allowed = if self.pings == 1 { self.first_write } else { 0 };
864 if over_share(write.saturating_sub(allowed), self.prefix_tokens) {
865 return Err(PeriodEnd::PartialMiss {
866 written: write,
867 prefix: self.prefix_tokens,
868 });
869 }
870 self.prefix_tokens = read + write;
872 self.ping_input = usage.input_tokens.max(1);
873 self.evidence.downgraded |= downgraded;
874 self.evidence.write_1h = usage.cache_write_1h.unwrap_or(0);
875 self.evidence.write_5m = usage.cache_write_5m.unwrap_or(0);
876 self.evidence.saw_1h |= self.evidence.write_1h > 0;
877 self.ttl = honoured_ttl(self.cfg.ttl, self.evidence);
878 self.reprice();
879 if let Some(a) = anchor.filter(|a| a.mono >= self.anchor.mono) {
880 self.anchor = a;
881 }
882 let interval = self.interval();
883 self.next_at = self.anchor.mono + interval;
884 self.next_wall = self.anchor.wall + interval;
885 if let Some(r) = self
886 .past_window(self.next_at, self.next_wall)
887 .or_else(|| self.past_bound())
888 {
889 return Err(r);
890 }
891 Ok(())
892 }
893
894 pub fn ping_failed(
898 &mut self,
899 failure: &PingFailure,
900 now: Instant,
901 wall: SystemTime,
902 ) -> Result<Duration, PeriodEnd> {
903 self.in_flight = false;
904 let msg = match failure {
905 PingFailure::Fatal(m) => return Err(PeriodEnd::Rejected(m.clone())),
906 PingFailure::Canceled => return Err(PeriodEnd::Rejected("canceled".into())),
907 PingFailure::Transient(m) => m.clone(),
908 };
909 let Some(delay) = BACKOFF.get(self.retries).copied() else {
910 return Err(PeriodEnd::TransientExhausted(msg));
911 };
912 let age_then = age(&self.anchor, now, wall) + delay;
913 if age_then + LATE_GUARD >= governing_ttl(self.ttl) {
914 return Err(PeriodEnd::TransientExhausted(format!(
915 "{msg}; no retry fits before the cache expires"
916 )));
917 }
918 if self.ledger_usd + self.worst_next_usd > self.bound_usd * (1.0 + BOUND_EPSILON) {
919 return Err(PeriodEnd::BoundReached {
920 ledger_usd: self.ledger_usd,
921 bound_usd: self.bound_usd,
922 reserve_usd: self.reserve_usd,
923 });
924 }
925 self.retries += 1;
926 self.next_at = now + delay;
927 self.next_wall = wall + delay;
928 Ok(delay)
929 }
930}
931
932pub(crate) struct PingTask {
934 pub(crate) handle: tokio::task::JoinHandle<PingReport>,
935 cancel: CancellationToken,
936 tap: PingTap,
937 model: String,
939 worst_usd: f64,
942}
943
944impl PingTask {
945 fn bill(&self) -> PingBill {
948 self.tap.bill(&self.model, self.worst_usd)
949 }
950}
951
952pub(crate) fn client_qualifies(kind: ClientKind, mode: AttachMode, headless: bool) -> bool {
970 if mode == AttachMode::Observe {
971 return false;
972 }
973 match kind {
974 ClientKind::Tui | ClientKind::Attach => true,
975 ClientKind::Chat | ClientKind::Rpc | ClientKind::Server => headless,
976 ClientKind::Test => false,
977 }
978}
979
980pub(crate) const OWNED_KEEPWARM_REFUSAL: &str = "an owned session (bound to the client that created it) never keeps \
982 the cache warm: its pings would be spend that client never sees in its usage";
983
984#[derive(Default)]
986pub(crate) struct KeepWarmState {
987 configured: CacheKeepWarm,
989 auto_max_hours: u32,
991 headless: bool,
994 owned: bool,
998 epoch: Option<EpochCarry>,
1002 pub(crate) override_mode: Option<CacheKeepWarm>,
1004 pub(crate) override_slot: OverrideSlot,
1007 pub(crate) period: Option<Period>,
1008 pub(crate) task: Option<PingTask>,
1009 next_period_id: u64,
1010 turn_started_at: Option<Instant>,
1012 last_request: Option<KeepWarmUsage>,
1014 pub(crate) interactive_seen: bool,
1019 last_end: Option<String>,
1020 session_pings: u32,
1021 estimate_exceeded: bool,
1026}
1027
1028pub type OverrideSlot = std::sync::Arc<std::sync::Mutex<Option<CacheKeepWarm>>>;
1030
1031impl KeepWarmState {
1032 pub(crate) fn new(
1033 configured: CacheKeepWarm,
1034 auto_max_hours: u32,
1035 headless: bool,
1036 override_slot: OverrideSlot,
1037 owned: bool,
1038 ) -> Self {
1039 let override_mode = *override_slot
1040 .lock()
1041 .unwrap_or_else(std::sync::PoisonError::into_inner);
1042 Self {
1043 configured,
1044 auto_max_hours,
1045 headless,
1046 owned,
1047 override_mode,
1048 override_slot,
1049 ..Self::default()
1050 }
1051 }
1052
1053 pub(crate) fn mode(&self) -> CacheKeepWarm {
1054 self.override_mode.unwrap_or(self.configured)
1055 }
1056
1057 pub(crate) fn set_override(&mut self, mode: CacheKeepWarm) {
1058 self.override_mode = Some(mode);
1059 *self
1060 .override_slot
1061 .lock()
1062 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(mode);
1063 }
1064
1065 pub(crate) fn active(&self) -> bool {
1066 self.period.is_some()
1067 }
1068
1069 pub(crate) fn note_attach(&mut self, kind: ClientKind, mode: AttachMode) {
1073 if !self.owned && client_qualifies(kind, mode, self.headless) {
1074 self.interactive_seen = true;
1075 }
1076 }
1077
1078 pub(crate) fn may_start(&self, pinned: bool) -> bool {
1081 !self.owned && !self.mode().is_off() && (self.interactive_seen || pinned)
1082 }
1083
1084 pub(crate) fn deadline(&self) -> Option<Instant> {
1088 self.deadline_at(Instant::now(), SystemTime::now())
1089 }
1090
1091 fn deadline_at(&self, now: Instant, wall: SystemTime) -> Option<Instant> {
1092 self.period
1093 .as_ref()
1094 .and_then(|p| p.deadline(now, wall))
1095 .map(|d| d.min(now + WALL_POLL))
1096 }
1097}
1098
1099pub(crate) async fn keepwarm_timer(deadline: Option<Instant>) {
1101 match deadline {
1102 Some(t) => tokio::time::sleep_until(t).await,
1103 None => std::future::pending().await,
1104 }
1105}
1106
1107pub(crate) async fn poll_keepwarm_ping(
1109 task: &mut Option<PingTask>,
1110) -> std::result::Result<PingReport, tokio::task::JoinError> {
1111 match task {
1112 Some(t) => (&mut t.handle).await,
1113 None => std::future::pending().await,
1114 }
1115}
1116
1117impl SessionActor {
1118 fn cache_keepwarm_blocker(&self) -> Option<&'static str> {
1120 if !self.runtime.is_live() || !self.conv.is_live() || self.is_parked() {
1121 return Some("session parked");
1122 }
1123 if self.streaming {
1124 return Some("turn running");
1125 }
1126 if self.compact.is_some() {
1127 return Some("compaction running");
1128 }
1129 if self.conv.queued_message.is_some() || !self.event_queue.is_empty() {
1130 return Some("work queued");
1131 }
1132 if !self.pending_prompts.is_empty() {
1133 return Some("prompt pending");
1134 }
1135 if self.driver.is_some() || self.driver_pending.is_some() {
1136 return Some("session driver armed");
1137 }
1138 None
1139 }
1140
1141 pub(crate) fn cache_keepwarm_note_attach(&mut self, kind: ClientKind, mode: AttachMode) {
1144 self.cache_keepwarm.note_attach(kind, mode);
1145 }
1146
1147 pub(crate) fn cache_keepwarm_note_usage(&mut self, usage: KeepWarmUsage) {
1150 self.cache_keepwarm.last_request = Some(usage);
1151 self.cache_keepwarm.epoch = None;
1153 }
1154
1155 pub(crate) fn cache_keepwarm_turn_started(&mut self) {
1158 self.cache_keepwarm_end(PeriodEnd::TurnStarted);
1159 self.cache_keepwarm.turn_started_at = Some(Instant::now());
1160 self.cache_keepwarm.last_request = None;
1161 }
1162
1163 pub(crate) fn cache_keepwarm_compaction(&mut self) {
1166 self.cache_keepwarm_end(PeriodEnd::Compaction);
1167 self.cache_keepwarm.turn_started_at = None;
1168 self.cache_keepwarm.last_request = None;
1169 self.cache_keepwarm.epoch = None;
1170 }
1171
1172 pub(crate) fn cache_keepwarm_conversation_replaced(&mut self) {
1179 self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1180 self.cache_keepwarm.turn_started_at = None;
1181 self.cache_keepwarm.last_request = None;
1182 self.cache_keepwarm.epoch = None;
1183 }
1184
1185 pub(crate) fn cache_keepwarm_park_requested(&mut self) {
1188 if self.cache_keepwarm.active() && self.can_park_ignoring_keepwarm() {
1189 self.cache_keepwarm_end(PeriodEnd::ParkedByRequest);
1190 }
1191 }
1192
1193 fn cache_keepwarm_book(&mut self, usage: &KeepWarmUsage, cost: f64) {
1198 if (cost <= 0.0 && *usage == KeepWarmUsage::default()) || !self.conv.is_live() {
1199 return;
1200 }
1201 self.conv.session_cost += cost;
1202 self.conv.keepwarm_cost += cost;
1203 self.conv.total_input_tokens += usage.input_tokens;
1204 self.conv.total_output_tokens += usage.output_tokens;
1205 self.conv.total_cache_read_tokens += usage.cache_read_tokens;
1206 self.conv.total_cache_creation_tokens += usage.cache_write_tokens;
1207 let t = &mut self.conv.keepwarm_tokens;
1208 t.input += usage.input_tokens;
1209 t.output += usage.output_tokens;
1210 t.cache_read += usage.cache_read_tokens;
1211 t.cache_creation += usage.cache_write_tokens;
1212 if let Some(p) = self.cache_keepwarm.period.as_mut() {
1213 p.charge(cost);
1214 }
1215 self.emit_conversation();
1216 self.request_save();
1222 }
1223
1224 fn cache_keepwarm_abort_ping(&mut self) {
1228 let Some(task) = self.cache_keepwarm.task.take() else {
1229 return;
1230 };
1231 task.cancel.cancel();
1232 task.handle.abort();
1233 let bill = task.bill();
1234 if bill.cost_usd > 0.0 {
1235 tracing::info!(
1236 session = %self.id,
1237 cost_usd = bill.cost_usd,
1238 booking = ?bill.booking,
1239 "cache keep-warm ping aborted in flight; booked"
1240 );
1241 }
1242 self.cache_keepwarm_book(&bill.usage, bill.cost_usd);
1243 }
1244
1245 pub(crate) fn cache_keepwarm_end(&mut self, reason: PeriodEnd) {
1247 self.cache_keepwarm_abort_ping();
1248 let Some(p) = self.cache_keepwarm.period.take() else {
1249 return;
1250 };
1251 tracing::info!(
1252 session = %self.id,
1253 period = p.id,
1254 pings = p.pings(),
1255 ledger_usd = p.ledger_usd(),
1256 bound_usd = p.bound_usd(),
1257 reason = %reason,
1258 "cache keep-warm period ended"
1259 );
1260 self.cache_keepwarm.last_end = Some(reason.to_string());
1261 self.cache_keepwarm.epoch = Some(p.epoch_carry());
1263 self.rearm_park();
1265 }
1266
1267 pub(crate) async fn cache_keepwarm_on_idle(&mut self) {
1270 let Some(turn_started_at) = self.cache_keepwarm.turn_started_at.take() else {
1271 return;
1272 };
1273 self.cache_keepwarm_try_start(Some(turn_started_at)).await;
1274 }
1275
1276 async fn cache_keepwarm_try_start(&mut self, turn_started_at: Option<Instant>) {
1281 if self.cache_keepwarm.active() {
1282 return;
1283 }
1284 if self.cache_keepwarm.owned {
1285 self.cache_keepwarm.last_end = Some(format!("not started: {OWNED_KEEPWARM_REFUSAL}"));
1286 return;
1287 }
1288 if !self.cache_keepwarm.may_start(self.keep_warm) {
1291 return;
1292 }
1293 let mode = self.cache_keepwarm.mode();
1294 let Some(anchor) = self
1296 .runtime
1297 .cache_anchor()
1298 .filter(|a| turn_started_at.is_none_or(|t| a.mono >= t))
1299 else {
1300 return;
1301 };
1302 let Some(last) = self.cache_keepwarm.last_request.clone() else {
1303 return;
1304 };
1305 if let Some(why) = self.cache_keepwarm_blocker() {
1306 self.cache_keepwarm.last_end = Some(format!("not started: {why}"));
1307 return;
1308 }
1309 if let Some(why) = self.runtime.cache_keepwarm_ineligibility() {
1310 self.cache_keepwarm.last_end = Some(format!("not started: {why}"));
1311 return;
1312 }
1313 if !crate::runtime::keepwarm::history_ready(&self.conv.api_messages) {
1314 return;
1315 }
1316 let probe: KeepWarmProbe = match self
1317 .runtime
1318 .cache_keepwarm_probe(&self.conv.api_messages)
1319 .await
1320 {
1321 Ok(p) => p,
1322 Err(e) => {
1323 tracing::info!(session = %self.id, "cache keep-warm not started: {e}");
1324 self.cache_keepwarm.last_end = Some(format!("not started: {e}"));
1325 return;
1326 }
1327 };
1328 if let Some(why) = probe.context_blocks {
1329 let reason = PeriodEnd::ContextRollover(why);
1330 tracing::info!(session = %self.id, reason = %reason, "cache keep-warm not started");
1331 self.cache_keepwarm.last_end = Some(format!("not started: {reason}"));
1332 return;
1333 }
1334 let ttl = self.runtime.resolved_cache_ttl().await;
1339 let cfg = PeriodConfig {
1340 mode,
1341 ttl,
1342 model: self.runtime.model().to_string(),
1343 max_tokens: probe.max_tokens,
1344 output_estimate: if self.cache_keepwarm.estimate_exceeded {
1345 probe.max_tokens
1346 } else {
1347 ping_output_estimate(probe.max_tokens)
1348 },
1349 interval_override: interval_override(),
1350 auto_max_hours: self.cache_keepwarm.auto_max_hours,
1351 reply_tokens: Some(crate::runtime::keepwarm::reply_prefix_tokens(
1352 &self.conv.api_messages,
1353 )),
1354 };
1355 let evidence = TtlEvidence {
1356 downgraded: self.runtime.cache_ttl_downgraded(),
1357 saw_1h: self.runtime.cache_1h_honoured(),
1358 write_1h: last.cache_write_1h.unwrap_or(0),
1359 write_5m: last.cache_write_5m.unwrap_or(0),
1360 };
1361 self.cache_keepwarm.next_period_id += 1;
1362 let id = self.cache_keepwarm.next_period_id;
1363 let now = Instant::now();
1364 let carry = self.cache_keepwarm.epoch;
1365 match Period::start_in_epoch(
1366 id,
1367 cfg,
1368 probe.fingerprint,
1369 &last,
1370 evidence,
1371 anchor,
1372 now,
1373 SystemTime::now(),
1374 carry.as_ref(),
1375 ) {
1376 Ok(p) => {
1377 tracing::info!(
1378 session = %self.id,
1379 period = id,
1380 mode = %mode,
1381 ttl_secs = governing_ttl(p.ttl()).as_secs(),
1382 first_ping_in_secs = p.next_at().saturating_duration_since(now).as_secs(),
1383 prefix_tokens = p.prefix_tokens(),
1384 bound_usd = p.bound_usd(),
1385 rewrite_usd = p.rewrite_usd(),
1386 reserve_usd = p.reserve_usd(),
1387 expected_ping_usd = p.expected_next_usd(),
1388 worst_ping_usd = p.exposure_next_usd(),
1389 "cache keep-warm period started"
1390 );
1391 self.cache_keepwarm.period = Some(p);
1392 }
1393 Err(reason) => {
1394 tracing::info!(session = %self.id, reason = %reason, "cache keep-warm not started");
1395 self.cache_keepwarm.last_end = Some(format!("not started: {reason}"));
1396 }
1397 }
1398 }
1399
1400 pub(crate) async fn on_cache_keepwarm_due(&mut self) {
1402 #[cfg(any(test, feature = "testing"))]
1403 testing::note_due_check();
1404 if self.holds(super::actor::WorkSource::KeepWarmTimer)
1409 || self.cache_keepwarm.period.is_none()
1410 || self.cache_keepwarm.task.is_some()
1411 {
1412 return;
1413 }
1414 let (now, wall) = (Instant::now(), SystemTime::now());
1415 if self
1417 .cache_keepwarm
1418 .period
1419 .as_ref()
1420 .and_then(|p| p.deadline(now, wall))
1421 .is_some_and(|d| d > now)
1422 {
1423 return;
1424 }
1425 if let Some(why) = self.cache_keepwarm_blocker() {
1426 self.cache_keepwarm_end(PeriodEnd::Blocked(why));
1427 return;
1428 }
1429 if !crate::runtime::keepwarm::history_ready(&self.conv.api_messages) {
1430 self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1433 return;
1434 }
1435 match self
1438 .runtime
1439 .cache_keepwarm_probe(&self.conv.api_messages)
1440 .await
1441 {
1442 Ok(probe) => {
1443 if let Some(why) = probe.context_blocks {
1444 self.cache_keepwarm_end(PeriodEnd::ContextRollover(why));
1445 return;
1446 }
1447 }
1448 Err(e) => {
1449 self.cache_keepwarm_end(PeriodEnd::Rejected(e.to_string()));
1450 return;
1451 }
1452 }
1453 let session_cost = self.conv.session_cost;
1454 let cap = self.config.max_session_cost;
1455 let Some(p) = self.cache_keepwarm.period.as_mut() else {
1456 return;
1457 };
1458 match p.fire(Instant::now(), SystemTime::now(), session_cost, cap) {
1459 Fire::NotYet => {}
1460 Fire::End(reason) => self.cache_keepwarm_end(reason),
1461 Fire::Ping => {
1462 let expect = p.fingerprint();
1463 let worst_usd = p.worst_next_usd();
1464 let history = self.conv.api_messages.clone();
1468 let mut runtime = self.runtime.clone();
1469 runtime.share_ttl_latches(&self.runtime);
1470 let cancel = CancellationToken::new();
1471 let token = cancel.clone();
1472 let tap = PingTap::default();
1473 let task_tap = tap.clone();
1474 let handle = tokio::spawn(async move {
1475 runtime
1476 .cache_keepwarm_ping(&history, expect, &token, &task_tap)
1477 .await
1478 });
1479 self.cache_keepwarm.task = Some(PingTask {
1480 handle,
1481 cancel,
1482 tap,
1483 model: self.runtime.model().to_string(),
1484 worst_usd,
1485 });
1486 }
1487 }
1488 }
1489
1490 pub(crate) fn on_cache_keepwarm_done(
1492 &mut self,
1493 result: std::result::Result<PingReport, tokio::task::JoinError>,
1494 ) {
1495 let Some(task) = self.cache_keepwarm.task.take() else {
1496 return;
1497 };
1498 let report = match result {
1499 Ok(r) => r,
1500 Err(e) => PingReport {
1501 result: Err(if e.is_cancelled() {
1502 PingFailure::Canceled
1503 } else {
1504 PingFailure::Fatal(format!("ping task: {e}"))
1505 }),
1506 usage: task.tap.usage(),
1507 notices: Vec::new(),
1508 },
1509 };
1510 let bill = task.bill();
1512 let cost = bill.cost_usd;
1513 self.cache_keepwarm_book(&bill.usage, cost);
1514 if bill.usage.output_tokens > PING_OUTPUT_ESTIMATE && !self.cache_keepwarm.estimate_exceeded
1515 {
1516 self.cache_keepwarm.estimate_exceeded = true;
1517 tracing::warn!(
1518 session = %self.id,
1519 output = bill.usage.output_tokens,
1520 estimate = PING_OUTPUT_ESTIMATE,
1521 "cache keep-warm ping out-thought its output estimate; \
1522 later periods price pings at max_tokens"
1523 );
1524 }
1525 for notice in &report.notices {
1526 self.emit(crate::session::SessionEventWire::SystemNotice(
1527 notice.clone(),
1528 ));
1529 }
1530 if self.cache_keepwarm.period.is_none() {
1531 return;
1532 }
1533 match report.result {
1534 Ok(PingOutcome::Sent) => {
1535 let usage = report.usage;
1536 self.cache_keepwarm.session_pings += 1;
1537 let anchor = self.runtime.cache_anchor();
1538 let downgraded = self.runtime.cache_ttl_downgraded();
1539 let Some(p) = self.cache_keepwarm.period.as_mut() else {
1540 return;
1541 };
1542 let result = p.ping_sent(&usage, anchor, downgraded);
1543 tracing::info!(
1544 session = %self.id,
1545 period = p.id,
1546 ping = p.pings(),
1547 age_secs = p.fired_age().as_secs(),
1548 ttl_secs = governing_ttl(p.ttl()).as_secs(),
1549 cache_read = usage.cache_read_tokens,
1550 cache_write = usage.cache_write_tokens,
1551 input = usage.input_tokens,
1552 output = usage.output_tokens,
1553 cost_usd = cost,
1554 ledger_usd = p.ledger_usd(),
1555 bound_usd = p.bound_usd(),
1556 "cache keep-warm ping"
1557 );
1558 if let Err(reason) = result {
1559 if matches!(
1560 reason,
1561 PeriodEnd::PartialMiss { .. }
1562 | PeriodEnd::CacheMiss { .. }
1563 | PeriodEnd::UncachedInput { .. }
1564 | PeriodEnd::NothingCached
1565 ) {
1566 tracing::warn!(
1567 session = %self.id,
1568 reason = %reason,
1569 "cache keep-warm ping did not refresh the cache"
1570 );
1571 }
1572 self.cache_keepwarm_end(reason);
1573 return;
1574 }
1575 if let Some(cap) = self.config.max_session_cost.filter(|c| c.is_finite()) {
1576 if self.conv.session_cost >= cap {
1577 self.cache_keepwarm_end(PeriodEnd::CostCap { cap });
1578 }
1579 }
1580 }
1581 Ok(PingOutcome::PrefixChanged { .. }) => {
1582 self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1583 }
1584 Err(failure) => {
1585 let Some(p) = self.cache_keepwarm.period.as_mut() else {
1586 return;
1587 };
1588 match p.ping_failed(&failure, Instant::now(), SystemTime::now()) {
1589 Ok(delay) => tracing::info!(
1590 session = %self.id,
1591 period = p.id,
1592 retry_in_secs = delay.as_secs(),
1593 "cache keep-warm ping failed ({failure}); retrying"
1594 ),
1595 Err(reason) => self.cache_keepwarm_end(reason),
1596 }
1597 }
1598 }
1599 }
1600
1601 pub(crate) async fn cache_keepwarm_recheck(&mut self) -> Option<bool> {
1605 let expect = self.cache_keepwarm.period.as_ref()?.fingerprint();
1606 if self.cache_keepwarm.period.as_ref()?.in_flight() {
1607 return Some(true);
1608 }
1609 let matches = crate::runtime::keepwarm::history_ready(&self.conv.api_messages)
1610 && self
1611 .runtime
1612 .cache_keepwarm_probe(&self.conv.api_messages)
1613 .await
1614 .is_ok_and(|p| p.fingerprint == expect);
1615 if !matches {
1616 self.cache_keepwarm_end(PeriodEnd::PrefixChanged);
1617 }
1618 Some(matches)
1619 }
1620
1621 pub(crate) fn cache_keepwarm_status_json(
1623 &self,
1624 prefix_matches: Option<bool>,
1625 ) -> serde_json::Value {
1626 let kw = &self.cache_keepwarm;
1627 let (now, wall) = (Instant::now(), SystemTime::now());
1628 let period = kw.period.as_ref().map(|p| {
1629 let next = p
1631 .deadline(now, wall)
1632 .map(|d| d.saturating_duration_since(now).as_secs());
1633 serde_json::json!({
1634 "id": p.id,
1635 "ttl_secs": governing_ttl(p.ttl()).as_secs(),
1636 "pings": p.pings(),
1637 "ledger_usd": p.ledger_usd(),
1638 "bound_usd": p.bound_usd(),
1639 "rewrite_usd": p.rewrite_usd(),
1640 "reserve_usd": p.reserve_usd(),
1641 "expected_ping_usd": p.expected_next_usd(),
1642 "worst_ping_usd": p.exposure_next_usd(),
1643 "prefix_tokens": p.prefix_tokens(),
1644 "next_ping_in_secs": next.unwrap_or(0),
1645 "in_flight": p.in_flight(),
1646 })
1647 });
1648 serde_json::json!({
1649 "mode": kw.mode().to_string(),
1650 "active": kw.active(),
1651 "period": period,
1652 "prefix_matches": prefix_matches,
1653 "session_pings": kw.session_pings,
1654 "session_keepwarm_usd": self.conv.keepwarm_cost,
1655 "last_end": kw.last_end,
1656 })
1657 }
1658
1659 pub(crate) async fn cache_ttl_status_json(&mut self) -> serde_json::Value {
1666 let resolved = if self.runtime.cache_keepwarm_ineligibility().is_none() {
1668 Some(self.runtime.resolved_cache_ttl().await.as_str())
1669 } else {
1670 None
1671 };
1672 serde_json::json!({
1673 "setting": self.runtime.cache_ttl().as_str(),
1674 "resolved": resolved,
1675 })
1676 }
1677
1678 pub(crate) async fn cache_keepwarm_command(&mut self, arg: &str) -> serde_json::Value {
1680 let arg = arg.trim();
1681 if !(arg.is_empty() || arg.eq_ignore_ascii_case("status")) && self.cache_keepwarm.owned {
1682 return serde_json::json!({ "kind": "error", "text": OWNED_KEEPWARM_REFUSAL });
1683 }
1684 if !(arg.is_empty() || arg.eq_ignore_ascii_case("status")) {
1685 let Some(mode) = CacheKeepWarm::parse(arg) else {
1686 return serde_json::json!({
1687 "kind": "error",
1688 "text": format!(
1689 "usage: /keepwarm [status|off|auto|<N>h] (N = 1–{}; unknown: {arg})",
1690 crate::core::config::MAX_CACHE_KEEPWARM_HOURS
1691 ),
1692 });
1693 };
1694 self.cache_keepwarm.set_override(mode);
1695 if self.keep_warm {
1696 self.note_pin(); }
1698 if mode.is_off() {
1699 self.cache_keepwarm_end(PeriodEnd::Disabled);
1700 } else if let Some(p) = self.cache_keepwarm.period.as_mut() {
1701 p.set_mode(mode);
1702 } else {
1703 self.cache_keepwarm_try_start(None).await;
1705 }
1706 }
1707 let prefix_matches = self.cache_keepwarm_recheck().await;
1708 let mut status = self.cache_keepwarm_status_json(prefix_matches);
1709 status["cache_ttl"] = self.cache_ttl_status_json().await;
1710 serde_json::json!({
1711 "kind": "output",
1712 "text": render_status(&status),
1713 "keepwarm": status,
1714 })
1715 }
1716}
1717
1718pub(crate) fn render_status(s: &serde_json::Value) -> String {
1720 let mode = s["mode"].as_str().unwrap_or("off");
1721 let spent = s["session_keepwarm_usd"].as_f64().unwrap_or(0.0);
1722 let pings = s["session_pings"].as_u64().unwrap_or(0);
1723 let mut out = format!("cache keep-warm: {mode} — this session: {pings} pings, ${spent:.4}");
1724 if let Some(resolved) = s["cache_ttl"]["resolved"].as_str() {
1726 out.push_str(&format!("\nprompt-cache TTL: {resolved}"));
1727 if s["cache_ttl"]["setting"].as_str() == Some("auto") {
1728 out.push_str(" (auto)");
1729 }
1730 }
1731 let p = &s["period"];
1732 if p.is_object() {
1733 let reserve = p["reserve_usd"].as_f64().unwrap_or(0.0);
1734 let bound = if reserve > 0.0 {
1735 format!(
1736 "${:.4} bound (${:.4} rewrite + ${reserve:.4} thinking reserve)",
1737 p["bound_usd"].as_f64().unwrap_or(0.0),
1738 p["rewrite_usd"].as_f64().unwrap_or(0.0),
1739 )
1740 } else {
1741 format!("${:.4} bound", p["bound_usd"].as_f64().unwrap_or(0.0))
1742 };
1743 out.push_str(&format!(
1744 "\nactive: ping {} due in {}s (TTL {}s); ${:.4} spent of {bound}; prefix {}",
1745 p["pings"].as_u64().unwrap_or(0) + 1,
1746 p["next_ping_in_secs"].as_u64().unwrap_or(0),
1747 p["ttl_secs"].as_u64().unwrap_or(0),
1748 p["ledger_usd"].as_f64().unwrap_or(0.0),
1749 match s["prefix_matches"].as_bool() {
1750 Some(true) => "unchanged",
1751 Some(false) => "changed",
1752 None => "unchecked",
1753 },
1754 ));
1755 } else {
1756 out.push_str("\nidle period: none");
1757 }
1758 if let Some(last) = s["last_end"].as_str() {
1759 out.push_str(&format!("\nlast: {last}"));
1760 }
1761 out
1762}
1763
1764#[cfg(test)]
1765mod tests {
1766 use super::*;
1767
1768 #[test]
1769 fn client_qualification_rule() {
1770 use AttachMode::{Mirror, Observe, Takeover};
1771 use ClientKind::{Attach, Chat, Rpc, Server, Test, Tui};
1772 for headless in [false, true] {
1773 for kind in [Tui, Chat, Attach, Rpc, Server, Test] {
1774 assert!(!client_qualifies(kind, Observe, headless), "{kind:?} observe");
1776 }
1777 for mode in [Mirror, Takeover] {
1778 assert!(client_qualifies(Tui, mode, headless));
1780 assert!(client_qualifies(Attach, mode, headless));
1781 assert!(!client_qualifies(Test, mode, headless));
1782 for kind in [Chat, Rpc, Server] {
1784 assert_eq!(client_qualifies(kind, mode, headless), headless, "{kind:?}");
1785 }
1786 }
1787 }
1788 }
1789
1790 #[test]
1794 fn an_owned_session_never_keeps_warm() {
1795 for owned in [false, true] {
1796 let mut kw = KeepWarmState::new(CacheKeepWarm::Auto, 4, true, OverrideSlot::default(), owned);
1797 kw.note_attach(ClientKind::Rpc, AttachMode::Mirror);
1798 kw.note_attach(ClientKind::Tui, AttachMode::Mirror);
1799 assert_eq!(kw.interactive_seen, !owned, "owned: {owned}");
1800 assert_eq!(kw.may_start(false), !owned, "owned: {owned}");
1801 assert_eq!(kw.may_start(true), !owned, "pinned, owned: {owned}");
1802 kw.set_override(CacheKeepWarm::Hours(2));
1803 kw.interactive_seen = true;
1804 assert_eq!(kw.may_start(true), !owned, "override + pin, owned: {owned}");
1805 }
1806 }
1807
1808 const MIN: Duration = Duration::from_secs(60);
1809 const OPUS: &str = "claude-opus-4-7";
1810 const SONNET: &str = "claude-sonnet-4-6";
1811 const P: u64 = 300_000;
1812
1813 fn cfg(mode: CacheKeepWarm, ttl: CacheTtl, model: &str) -> PeriodConfig {
1815 cfg_max(mode, ttl, model, 1)
1816 }
1817
1818 fn cfg_max(mode: CacheKeepWarm, ttl: CacheTtl, model: &str, max_tokens: u64) -> PeriodConfig {
1819 PeriodConfig {
1820 mode,
1821 ttl,
1822 model: model.to_string(),
1823 max_tokens,
1824 output_estimate: ping_output_estimate(max_tokens),
1825 interval_override: None,
1826 auto_max_hours: 0,
1827 reply_tokens: None,
1828 }
1829 }
1830
1831 fn cfg_worst(mode: CacheKeepWarm, ttl: CacheTtl, model: &str, max_tokens: u64) -> PeriodConfig {
1834 PeriodConfig {
1835 output_estimate: max_tokens,
1836 ..cfg_max(mode, ttl, model, max_tokens)
1837 }
1838 }
1839
1840 fn ev_1h() -> TtlEvidence {
1842 TtlEvidence {
1843 saw_1h: true,
1844 ..TtlEvidence::default()
1845 }
1846 }
1847
1848 fn anchor_at(mono: Instant, wall: SystemTime) -> CacheAnchor {
1849 CacheAnchor { mono, wall }
1850 }
1851
1852 fn usage(model: &str, read: u64, write: u64, input: u64, out: u64) -> KeepWarmUsage {
1853 KeepWarmUsage {
1854 input_tokens: input,
1855 output_tokens: out,
1856 cache_read_tokens: read,
1857 cache_write_tokens: write,
1858 cache_write_5m: None,
1859 cache_write_1h: None,
1860 model: Some(model.to_string()),
1861 }
1862 }
1863
1864 fn last_read(model: &str) -> KeepWarmUsage {
1866 usage(model, P, 0, 0, 0)
1867 }
1868
1869 fn start(c: PeriodConfig, last: &KeepWarmUsage, t0: Instant, w0: SystemTime) -> Period {
1870 Period::start(1, c, 7, last, ev_1h(), anchor_at(t0, w0), t0, w0).expect("starts")
1871 }
1872
1873 fn run_from(
1877 cfg: PeriodConfig,
1878 last: &KeepWarmUsage,
1879 usages: &[KeepWarmUsage],
1880 ) -> (u32, f64, f64, PeriodEnd, Duration) {
1881 let t0 = Instant::now();
1882 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
1883 let model = cfg.model.clone();
1884 let mut p = start(cfg, last, t0, w0);
1885 let mut i = 0;
1886 loop {
1887 let at = p.next_at();
1888 let wall = w0 + at.saturating_duration_since(t0);
1889 match p.fire(at, wall, 0.0, None) {
1890 Fire::Ping => {}
1891 Fire::End(r) => {
1892 return (p.pings(), p.ledger_usd(), p.bound_usd(), r, at - t0);
1893 }
1894 Fire::NotYet => panic!("due but not fired"),
1895 }
1896 let u = &usages[i.min(usages.len() - 1)];
1897 i += 1;
1898 p.charge(u.cost_usd(&model));
1899 if let Err(r) = p.ping_sent(u, Some(anchor_at(at, wall)), false) {
1900 return (p.pings(), p.ledger_usd(), p.bound_usd(), r, at - t0);
1901 }
1902 assert!(i < 1000, "runaway");
1903 }
1904 }
1905
1906 fn run(cfg: PeriodConfig, usages: &[KeepWarmUsage]) -> (u32, f64, f64, PeriodEnd, Duration) {
1907 let last = last_read(&cfg.model);
1908 run_from(cfg, &last, usages)
1909 }
1910
1911 #[test]
1916 fn review_205_possible_cache_miss_must_fit_admission_cap() {
1917 let t0 = Instant::now();
1918 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
1919 let mut p = start(
1920 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1921 &last_read(OPUS),
1922 t0,
1923 w0,
1924 );
1925 let at = p.next_at();
1926 let wall = w0 + at.saturating_duration_since(t0);
1927 let cap = 0.30;
1928 let decision = p.fire(at, wall, 0.0, Some(cap));
1929 let miss = KeepWarmUsage {
1930 cache_write_tokens: P,
1931 cache_write_1h: Some(P),
1932 ..KeepWarmUsage::default()
1933 };
1934 println!(
1935 "REVIEW estimated_worst={} possible_write={} cap={cap} decision={decision:?}",
1936 p.worst_next_usd,
1937 miss.cost_usd(OPUS)
1938 );
1939 if matches!(decision, Fire::Ping) {
1940 assert!(
1941 miss.cost_usd(OPUS) <= cap,
1942 "admitted request can exceed its session cost cap on a cache miss"
1943 );
1944 }
1945 }
1946
1947 #[test]
1951 fn a_cap_with_room_for_a_cache_miss_still_pings() {
1952 let t0 = Instant::now();
1953 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
1954 let mut p = start(
1955 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1956 &last_read(OPUS),
1957 t0,
1958 w0,
1959 );
1960 let exposure = p.exposure_next_usd();
1962 let miss = usage(OPUS, 0, P, PING_INPUT_ESTIMATE, 1);
1963 let miss = KeepWarmUsage {
1964 cache_write_1h: Some(P),
1965 ..miss
1966 };
1967 assert!((exposure - miss.cost_usd(OPUS)).abs() < 1e-9, "{exposure}");
1968 assert!(p.worst_next_usd() < p.bound_usd(), "bound basis is the read");
1969 let at = p.next_at();
1970 let wall = w0 + at.saturating_duration_since(t0);
1971 assert_eq!(
1972 p.fire(at, wall, 0.0, Some(3.0)),
1973 Fire::End(PeriodEnd::CostCap { cap: 3.0 })
1974 );
1975 assert_eq!(p.fire(at, wall, 0.0, Some(3.01)), Fire::Ping);
1976 }
1977
1978 #[test]
1979 fn ski_rental_1h_is_nineteen_pure_reads_over_about_17h() {
1980 let (pings, ledger, bound, reason, elapsed) = run(
1984 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
1985 &[usage(OPUS, P, 0, 0, 0)],
1986 );
1987 assert!((bound - 3.0).abs() < 1e-9, "{bound}");
1988 assert_eq!(pings, 19);
1989 assert!(ledger <= bound * (1.0 + 1e-9));
1990 assert!(matches!(reason, PeriodEnd::BoundReached { .. }), "{reason}");
1991 assert_eq!(elapsed, 19 * 55 * MIN);
1992 }
1993
1994 #[test]
1995 fn ski_rental_5m_is_twelve_pure_reads_over_about_55_min() {
1996 let (pings, ledger, bound, reason, elapsed) = run(
1998 cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS),
1999 &[usage(OPUS, P, 0, 0, 0)],
2000 );
2001 assert!((bound - 1.875).abs() < 1e-9, "{bound}");
2002 assert_eq!(pings, 12);
2003 assert!((ledger - 1.8).abs() < 1e-9);
2004 assert!(matches!(reason, PeriodEnd::BoundReached { .. }), "{reason}");
2005 assert_eq!(elapsed, 12 * Duration::from_secs(270));
2006 }
2007
2008 #[test]
2009 fn measured_overhead_and_first_write_are_in_the_ledger() {
2010 let last = usage(OPUS, P - 2_000, 0, 0, 2_000);
2014 let first = usage(OPUS, P - 2_000, 2_000, 40, 1);
2015 let steady = usage(OPUS, P, 0, 40, 1);
2016 let (pings, ledger, bound, reason, _) = run_from(
2017 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2018 &last,
2019 &[first.clone(), steady.clone()],
2020 );
2021 assert!(matches!(reason, PeriodEnd::BoundReached { .. }), "{reason}");
2022 let expected_ledger = first.cost_usd(OPUS) + f64::from(pings - 1) * steady.cost_usd(OPUS);
2023 assert!(
2024 (ledger - expected_ledger).abs() < 1e-9,
2025 "ledger is the measured sum"
2026 );
2027 assert!(ledger <= bound * (1.0 + 1e-9), "never past the bound");
2028 assert!(ledger + steady.cost_usd(OPUS) > bound);
2029 assert_eq!(pings, 19);
2030 }
2031
2032 #[test]
2033 fn an_expensive_ping_stops_the_period_early() {
2034 let heavy_ping = usage(SONNET, P, 0, 40, 5_000);
2037 let (heavy, ledger, bound, ..) = run(
2038 cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, SONNET, 5_001),
2039 std::slice::from_ref(&heavy_ping),
2040 );
2041 let (worst, worst_ledger, worst_bound, ..) = run(
2042 cfg_worst(CacheKeepWarm::Auto, CacheTtl::OneHour, SONNET, 5_001),
2043 std::slice::from_ref(&heavy_ping),
2044 );
2045 let (light, ..) = run(
2046 cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, SONNET, 31),
2047 &[usage(SONNET, P, 0, 40, 30)],
2048 );
2049 assert_eq!(worst, 10);
2052 assert!((worst_bound - 1.80).abs() < 1e-9 && worst_ledger <= 1.80);
2053 let reserve = (5_001 - PING_OUTPUT_ESTIMATE) as f64 * 15.0 / 1e6;
2056 assert_eq!(heavy, 11);
2057 assert!((bound - (1.80 + reserve)).abs() < 1e-9, "{bound}");
2058 assert!(ledger > 1.80 && ledger <= bound, "{ledger} of {bound}");
2059 assert_eq!(light, 19);
2060 }
2061
2062 #[test]
2063 fn budgeted_thinking_output_is_accounted() {
2064 let u = usage(SONNET, P, 0, 40, 300);
2065 let read_only = usage(SONNET, P, 0, 0, 0);
2066 let thinking_usd = u.cost_usd(SONNET) - read_only.cost_usd(SONNET);
2067 assert!((thinking_usd - (300.0 * 15.0 + 40.0 * 3.0) / 1e6).abs() < 1e-12);
2069 let (pings, ledger, ..) = run(
2070 cfg_max(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, SONNET, 301),
2071 std::slice::from_ref(&u),
2072 );
2073 assert!((ledger - f64::from(pings) * u.cost_usd(SONNET)).abs() < 1e-9);
2074 }
2075
2076 #[test]
2083 fn budgeted_thinking_worst_case_must_fit_the_bound() {
2084 let t0 = Instant::now();
2085 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000);
2086 let small = usage(SONNET, 20_000, 0, 0, 0);
2087 let start_5m = |c| {
2088 Period::start(1, c, 7, &small, TtlEvidence::default(), anchor_at(t0, w0), t0, w0)
2089 };
2090 let worst = cfg_worst(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, SONNET, 32_769);
2091 match start_5m(worst).unwrap_err() {
2092 PeriodEnd::PingTooExpensive {
2093 expected_usd,
2094 worst_usd,
2095 rewrite_usd,
2096 } => {
2097 assert!((rewrite_usd - 0.075).abs() < 1e-9, "{rewrite_usd}");
2098 assert!(worst_usd > 0.49, "{worst_usd}");
2099 assert_eq!(expected_usd, worst_usd, "no estimate: priced at worst");
2100 }
2101 other => panic!("{other}"),
2102 }
2103 let p = start_5m(cfg_max(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, SONNET, 32_769))
2104 .expect("expected ping fits the rewrite");
2105 assert!((p.rewrite_usd() - 0.075).abs() < 1e-9);
2106 let reserve = (32_769 - PING_OUTPUT_ESTIMATE) as f64 * 15.0 / 1e6;
2107 assert!((p.reserve_usd() - reserve).abs() < 1e-9, "{}", p.reserve_usd());
2108 assert!((p.bound_usd() - (0.075 + reserve)).abs() < 1e-9);
2109 assert!(p.expected_next_usd() <= p.rewrite_usd());
2110 assert!(p.worst_next_usd() <= p.bound_usd() * (1.0 + 1e-12));
2111 let p = start(
2113 cfg_worst(CacheKeepWarm::Auto, CacheTtl::OneHour, SONNET, 32_769),
2114 &usage(SONNET, P, 0, 0, 0),
2115 t0,
2116 w0,
2117 );
2118 assert!(p.worst_next_usd() > 0.58 && p.worst_next_usd() < p.bound_usd());
2119 assert_eq!(p.reserve_usd(), 0.0);
2120 }
2121
2122 fn break_even(c: &PeriodConfig) -> u64 {
2124 let t0 = Instant::now();
2125 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000);
2126 let starts = |prefix| {
2127 let last = usage(&c.model, prefix, 0, 0, 0);
2128 Period::start(1, c.clone(), 7, &last, ev_1h(), anchor_at(t0, w0), t0, w0).is_ok()
2129 };
2130 let (mut lo, mut hi) = (1, 1_000_000);
2131 assert!(starts(hi) && !starts(lo));
2132 while hi - lo > 1 {
2133 let mid = (lo + hi) / 2;
2134 if starts(mid) {
2135 hi = mid;
2136 } else {
2137 lo = mid;
2138 }
2139 }
2140 hi
2141 }
2142
2143 #[test]
2148 fn break_even_prefix_budgeted_vs_adaptive() {
2149 use CacheTtl::{FiveMinutes, OneHour};
2150 let auto = CacheKeepWarm::Auto;
2151 let worst_5m = break_even(&cfg_worst(auto, FiveMinutes, SONNET, 4_097));
2152 let worst_1h = break_even(&cfg_worst(auto, OneHour, SONNET, 4_097));
2153 let est_5m = break_even(&cfg_max(auto, FiveMinutes, SONNET, 4_097));
2154 let est_1h = break_even(&cfg_max(auto, OneHour, SONNET, 4_097));
2155 let adaptive_5m = break_even(&cfg(auto, FiveMinutes, OPUS));
2156 let adaptive_1h = break_even(&cfg(auto, OneHour, OPUS));
2157 println!(
2158 "BREAK-EVEN worst 5m={worst_5m} 1h={worst_1h}; estimate 5m={est_5m} 1h={est_1h}; \
2159 adaptive 5m={adaptive_5m} 1h={adaptive_1h}"
2160 );
2161 assert!((17_860..=17_880).contains(&worst_5m), "{worst_5m}");
2163 assert!((10_810..=10_820).contains(&worst_1h), "{worst_1h}");
2164 assert!((1_165..=1_175).contains(&est_5m), "{est_5m}");
2166 assert!((705..=712).contains(&est_1h), "{est_1h}");
2167 assert!(adaptive_5m < 1_024 && adaptive_1h < 1_024);
2169 let off = cfg_max(auto, FiveMinutes, SONNET, 1);
2171 let off_no_estimate = PeriodConfig {
2172 output_estimate: 1,
2173 ..off.clone()
2174 };
2175 assert_eq!(break_even(&off), break_even(&off_no_estimate));
2176 }
2177
2178 #[test]
2182 fn the_bound_holds_whatever_the_pings_think() {
2183 for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour] {
2184 for prefix in [1_200, 2_000, 5_000, 20_000, 100_000] {
2185 for out in [1, 100, PING_OUTPUT_ESTIMATE, 1_000, 4_097] {
2186 let c = cfg_max(CacheKeepWarm::Auto, ttl, SONNET, 4_097);
2187 let last = usage(SONNET, prefix, 0, 0, 0);
2188 let ping = usage(SONNET, prefix, 0, 40, out);
2189 let (pings, ledger, bound, reason, _) = run_from(c, &last, &[ping]);
2190 let rewrite = rewrite_cost_usd(SONNET, ttl, prefix);
2191 let ctx = format!("{ttl:?} prefix {prefix} out {out}: {pings} pings, {reason}");
2192 assert!(pings >= 1, "{ctx}");
2193 assert!(ledger <= bound * (1.0 + 1e-9), "{ctx}: ${ledger} > ${bound}");
2194 if out <= PING_OUTPUT_ESTIMATE {
2195 assert!(ledger <= rewrite * (1.0 + 1e-9), "{ctx}: ${ledger} > ${rewrite}");
2196 }
2197 assert!(matches!(reason, PeriodEnd::BoundReached { .. }), "{ctx}");
2198 }
2199 }
2200 }
2201 }
2202
2203 #[test]
2204 fn max_session_cost_is_checked_against_the_worst_case() {
2205 let t0 = Instant::now();
2206 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(100_000);
2207 let mut p = start(
2210 cfg_max(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS, 10_001),
2211 &last_read(OPUS),
2212 t0,
2213 w0,
2214 );
2215 let at = p.next_at();
2216 let wat = w0 + (at - t0);
2217 assert_eq!(
2219 p.fire(at, wat, 4.70, Some(5.0)),
2220 Fire::End(PeriodEnd::CostCap { cap: 5.0 })
2221 );
2222 assert_eq!(
2225 p.fire(at, wat, 4.50, Some(5.0)),
2226 Fire::End(PeriodEnd::CostCap { cap: 5.0 })
2227 );
2228 assert_eq!(p.fire(at, wat, 1.70, Some(5.0)), Fire::Ping);
2230 }
2231
2232 #[test]
2237 fn small_prompt_below_the_cache_minimum_never_starts() {
2238 let t0 = Instant::now();
2239 let w0 = SystemTime::UNIX_EPOCH;
2240 let below_minimum = usage(OPUS, 0, 0, 800, 20);
2241 assert_eq!(
2242 Period::start(
2243 1,
2244 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2245 7,
2246 &below_minimum,
2247 ev_1h(),
2248 anchor_at(t0, w0),
2249 t0,
2250 w0
2251 )
2252 .unwrap_err(),
2253 PeriodEnd::NothingCached
2254 );
2255 }
2256
2257 fn fired(c: PeriodConfig) -> (Period, Instant, SystemTime) {
2258 let t0 = Instant::now();
2259 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(90_000);
2260 let mut p = start(c, &last_read(OPUS), t0, w0);
2261 let at = p.next_at();
2262 let wat = w0 + (at - t0);
2263 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2264 (p, at, wat)
2265 }
2266
2267 #[test]
2268 fn a_ping_that_cached_nothing_ends_the_period() {
2269 let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
2270 assert_eq!(
2271 p.ping_sent(&usage(OPUS, 0, 0, P, 1), None, false),
2272 Err(PeriodEnd::NothingCached)
2273 );
2274 }
2275
2276 #[test]
2277 fn a_ping_paying_for_a_large_uncached_share_ends_the_period() {
2278 let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
2280 assert_eq!(
2281 p.ping_sent(&usage(OPUS, P - 60_000, 0, 60_000, 1), None, false),
2282 Err(PeriodEnd::UncachedInput {
2283 input: 60_000,
2284 prefix: P
2285 })
2286 );
2287 let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
2289 assert_eq!(p.ping_sent(&usage(OPUS, P, 0, 40, 1), None, false), Ok(()));
2290 }
2291
2292 #[test]
2293 fn a_ping_that_writes_instead_of_reading_ends_the_period() {
2294 let (mut p, ..) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
2295 assert_eq!(
2296 p.ping_sent(&usage(OPUS, 0, P, 40, 1), None, false),
2297 Err(PeriodEnd::CacheMiss { written: P })
2298 );
2299 }
2300
2301 #[test]
2304 fn a_partial_miss_after_ping_one_ends_the_period() {
2305 let (mut p, at, wat) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
2306 p.ping_sent(&usage(OPUS, P, 0, 40, 1), Some(anchor_at(at, wat)), false)
2307 .unwrap();
2308 let at = p.next_at();
2309 let wat = wat + (at - p.anchor.mono);
2310 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2311 assert_eq!(
2312 p.ping_sent(&usage(OPUS, P - 50_000, 50_000, 40, 1), None, false),
2313 Err(PeriodEnd::PartialMiss {
2314 written: 50_000,
2315 prefix: P
2316 })
2317 );
2318 }
2319
2320 #[test]
2321 fn ping_one_may_write_the_last_reply() {
2322 let t0 = Instant::now();
2323 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(3_000);
2324 let last = usage(OPUS, 250_000, 0, 0, 50_000);
2326 let mut p = start(
2327 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2328 &last,
2329 t0,
2330 w0,
2331 );
2332 assert_eq!(p.prefix_tokens(), P);
2333 let at = p.next_at();
2334 assert_eq!(p.fire(at, w0 + (at - t0), 0.0, None), Fire::Ping);
2335 assert_eq!(
2336 p.ping_sent(&usage(OPUS, 250_000, 50_000, 40, 1), None, false),
2337 Ok(()),
2338 "the reply's write is expected on ping 1"
2339 );
2340 }
2341
2342 #[test]
2345 fn honoured_ttl_needs_evidence_for_1h() {
2346 use CacheTtl::*;
2347 let none = TtlEvidence::default();
2348 assert_eq!(honoured_ttl(FiveMinutes, ev_1h()), FiveMinutes);
2349 assert_eq!(honoured_ttl(Hybrid, ev_1h()), Hybrid);
2350 assert_eq!(honoured_ttl(OneHour, none), FiveMinutes, "no evidence");
2351 assert_eq!(honoured_ttl(OneHour, ev_1h()), OneHour);
2352 let wrote_1h = TtlEvidence {
2353 write_1h: 10,
2354 ..none
2355 };
2356 assert_eq!(honoured_ttl(OneHour, wrote_1h), OneHour);
2357 let wrote_5m = TtlEvidence {
2358 saw_1h: true,
2359 write_5m: 10,
2360 ..none
2361 };
2362 assert_eq!(
2363 honoured_ttl(OneHour, wrote_5m),
2364 FiveMinutes,
2365 "5m-only write"
2366 );
2367 let downgraded = TtlEvidence {
2368 downgraded: true,
2369 write_1h: 10,
2370 saw_1h: true,
2371 ..none
2372 };
2373 assert_eq!(honoured_ttl(OneHour, downgraded), FiveMinutes, "latch wins");
2374 }
2375
2376 #[test]
2380 fn a_silent_downgrade_governs_by_5m() {
2381 let t0 = Instant::now();
2382 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(4_000);
2383 let downgraded = TtlEvidence {
2384 downgraded: true,
2385 ..TtlEvidence::default()
2386 };
2387 let p = Period::start(
2388 1,
2389 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2390 7,
2391 &last_read(OPUS),
2392 downgraded,
2393 anchor_at(t0, w0),
2394 t0,
2395 w0,
2396 )
2397 .unwrap();
2398 assert_eq!(p.ttl(), CacheTtl::FiveMinutes);
2399 assert_eq!(p.next_at(), t0 + Duration::from_secs(270));
2400 assert!((p.bound_usd() - 1.875).abs() < 1e-9);
2401
2402 let mut p = Period::start(
2405 2,
2406 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2407 7,
2408 &usage(OPUS, P - 1_000, 0, 0, 1_000),
2409 TtlEvidence::default(),
2410 anchor_at(t0, w0),
2411 t0,
2412 w0,
2413 )
2414 .unwrap();
2415 assert_eq!(p.ttl(), CacheTtl::FiveMinutes);
2416 let at = p.next_at();
2417 let wat = w0 + (at - t0);
2418 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2419 let mut first = usage(OPUS, P - 1_000, 1_000, 40, 1);
2420 first.cache_write_1h = Some(1_000);
2421 first.cache_write_5m = Some(0);
2422 p.ping_sent(&first, Some(anchor_at(at, wat)), false)
2423 .unwrap();
2424 assert_eq!(p.ttl(), CacheTtl::OneHour);
2425 assert_eq!(p.next_at(), at + 55 * MIN);
2426 let at = p.next_at();
2427 let wat = wat + 55 * MIN;
2428 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2429 p.ping_sent(&usage(OPUS, P, 0, 40, 1), Some(anchor_at(at, wat)), true)
2430 .unwrap();
2431 assert_eq!(p.ttl(), CacheTtl::FiveMinutes);
2432 assert_eq!(p.next_at(), at + Duration::from_secs(270));
2433 }
2434
2435 #[test]
2438 fn five_minute_pings_are_scheduled_from_the_anchor() {
2439 let t0 = Instant::now();
2440 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(5_000);
2441 let c = cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS);
2442 let idle = t0 + MIN;
2444 let mut p = Period::start(
2445 1,
2446 c,
2447 7,
2448 &last_read(OPUS),
2449 ev_1h(),
2450 anchor_at(t0, w0),
2451 idle,
2452 w0 + MIN,
2453 )
2454 .unwrap();
2455 assert_eq!(
2456 p.deadline(idle, w0 + MIN),
2457 Some(t0 + Duration::from_secs(270))
2458 );
2459 assert_eq!(
2460 p.fire(
2461 t0 + Duration::from_secs(200),
2462 w0 + Duration::from_secs(200),
2463 0.0,
2464 None
2465 ),
2466 Fire::NotYet
2467 );
2468 let at = t0 + Duration::from_secs(270);
2469 let wat = w0 + Duration::from_secs(270);
2470 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2471 assert_eq!(p.deadline(at, wat), None, "no second ping while in flight");
2472 assert_eq!(p.fire(at, wat, 0.0, None), Fire::NotYet);
2473 let sent = anchor_at(at + Duration::from_secs(2), wat + Duration::from_secs(2));
2475 p.ping_sent(&usage(OPUS, P, 0, 0, 1), Some(sent), false)
2476 .unwrap();
2477 assert_eq!(
2478 p.deadline(sent.mono, sent.wall),
2479 Some(sent.mono + Duration::from_secs(270))
2480 );
2481 }
2482
2483 #[test]
2484 fn hybrid_is_governed_by_the_5m_history_marker() {
2485 assert_eq!(governing_ttl(CacheTtl::Hybrid), Duration::from_secs(300));
2486 assert_eq!(governing_ttl(CacheTtl::OneHour), Duration::from_secs(3600));
2487 assert_eq!(ping_interval(CacheTtl::Hybrid), Duration::from_secs(270));
2488 assert!((rewrite_cost_usd(OPUS, CacheTtl::Hybrid, P) - 1.875).abs() < 1e-9);
2490 let (pings, ..) = run(
2491 cfg(CacheKeepWarm::Auto, CacheTtl::Hybrid, OPUS),
2492 &[usage(OPUS, P, 0, 0, 0)],
2493 );
2494 assert_eq!(pings, 12);
2495 }
2496
2497 #[test]
2498 fn a_turn_that_streamed_past_the_interval_pings_at_once_or_not_at_all() {
2499 let t0 = Instant::now();
2500 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(9_000);
2501 let c = || cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS);
2502 let at = |s: u64| (t0 + Duration::from_secs(s), w0 + Duration::from_secs(s));
2503 let (idle, widle) = at(280);
2504 let p = Period::start(
2505 1,
2506 c(),
2507 7,
2508 &last_read(OPUS),
2509 ev_1h(),
2510 anchor_at(t0, w0),
2511 idle,
2512 widle,
2513 )
2514 .unwrap();
2515 assert_eq!(p.deadline(idle, widle), Some(idle), "due immediately");
2516 let (late, wlate) = at(290);
2517 let err = Period::start(
2518 1,
2519 c(),
2520 7,
2521 &last_read(OPUS),
2522 ev_1h(),
2523 anchor_at(t0, w0),
2524 late,
2525 wlate,
2526 )
2527 .unwrap_err();
2528 assert!(matches!(err, PeriodEnd::Expired { age_secs: 290 }), "{err}");
2529 }
2530
2531 #[test]
2532 fn sleep_past_expiry_ends_the_period_without_pinging() {
2533 let t0 = Instant::now();
2534 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(50_000);
2535 for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour] {
2536 let c = cfg(CacheKeepWarm::Auto, ttl, OPUS);
2537 let mut p = start(c, &last_read(OPUS), t0, w0);
2538 let due = p.next_at();
2539 let wall = w0 + Duration::from_secs(3 * 3600);
2542 match p.fire(due, wall, 0.0, None) {
2543 Fire::End(PeriodEnd::Expired { age_secs }) => assert_eq!(age_secs, 3 * 3600),
2544 other => panic!("{ttl:?}: {other:?}"),
2545 }
2546 }
2547 }
2548
2549 #[test]
2554 fn a_suspend_pulls_the_deadline_forward_by_the_wall_clock() {
2555 let t0 = Instant::now();
2556 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(60_000);
2557 let mut p = start(
2558 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2559 &last_read(OPUS),
2560 t0,
2561 w0,
2562 );
2563 let (now, wall) = (t0 + 10 * MIN, w0 + 40 * MIN);
2565 assert_eq!(p.deadline(now, wall), Some(now + 15 * MIN));
2566 assert_eq!(p.fire(now, wall, 0.0, None), Fire::NotYet);
2567 let (now, wall) = (now + 15 * MIN, wall + 15 * MIN);
2570 assert_eq!(p.fire(now, wall, 0.0, None), Fire::Ping);
2571 assert_eq!(p.fired_age(), 55 * MIN);
2572 }
2573
2574 #[test]
2575 fn the_actor_polls_the_wall_clock_while_a_period_is_active() {
2576 assert!(WALL_POLL < safety_margin(CacheTtl::FiveMinutes));
2577 let t0 = Instant::now();
2578 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(61_000);
2579 let mut kw = KeepWarmState::default();
2580 assert_eq!(kw.deadline_at(t0, w0), None, "no period: no timer");
2581 kw.period = Some(start(
2583 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2584 &last_read(OPUS),
2585 t0,
2586 w0,
2587 ));
2588 assert_eq!(kw.deadline_at(t0, w0), Some(t0 + WALL_POLL));
2589 let (near, wnear) = (
2591 t0 + 55 * MIN - Duration::from_secs(3),
2592 w0 + 55 * MIN - Duration::from_secs(3),
2593 );
2594 assert_eq!(kw.deadline_at(near, wnear), Some(t0 + 55 * MIN));
2595 let p = kw.period.as_mut().unwrap();
2597 assert_eq!(p.fire(t0 + 55 * MIN, w0 + 55 * MIN, 0.0, None), Fire::Ping);
2598 assert_eq!(kw.deadline_at(t0 + 55 * MIN, w0 + 55 * MIN), None);
2599 }
2600
2601 #[test]
2604 fn transient_errors_back_off_within_the_ttl_then_end() {
2605 let t0 = Instant::now();
2606 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(70_000);
2607 let mut p = start(
2608 cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS),
2609 &last_read(OPUS),
2610 t0,
2611 w0,
2612 );
2613 let mut now = p.next_at();
2614 let transient = PingFailure::Transient("HTTP 529".into());
2615 for want in BACKOFF {
2616 let wall = w0 + now.saturating_duration_since(t0);
2617 assert_eq!(p.fire(now, wall, 0.0, None), Fire::Ping);
2618 assert_eq!(p.ping_failed(&transient, now, wall), Ok(want));
2619 assert_eq!(p.deadline(now, wall), Some(now + want));
2620 now += want;
2621 }
2622 let wall = w0 + now.saturating_duration_since(t0);
2623 assert_eq!(p.fire(now, wall, 0.0, None), Fire::Ping);
2624 assert!(matches!(
2625 p.ping_failed(&transient, now, wall),
2626 Err(PeriodEnd::TransientExhausted(_))
2627 ));
2628
2629 let mut p = start(
2631 cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS),
2632 &last_read(OPUS),
2633 t0,
2634 w0,
2635 );
2636 let at = p.next_at();
2637 let wat = w0 + Duration::from_secs(270);
2638 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2639 assert_eq!(p.ping_failed(&transient, at, wat), Ok(BACKOFF[0]));
2640 let at = at + BACKOFF[0];
2641 let wat = wat + BACKOFF[0];
2642 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2643 assert!(matches!(
2644 p.ping_failed(&transient, at, wat),
2645 Err(PeriodEnd::TransientExhausted(_))
2646 ));
2647 }
2648
2649 #[test]
2651 fn failed_ping_spend_is_in_the_ledger() {
2652 let (mut p, at, wat) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, OPUS));
2653 p.charge(1.80);
2654 assert!(matches!(
2655 p.ping_failed(&PingFailure::Transient("x".into()), at, wat),
2656 Err(PeriodEnd::BoundReached { .. })
2657 ));
2658 }
2659
2660 #[test]
2661 fn a_4xx_ends_the_period() {
2662 let (mut p, at, wat) = fired(cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS));
2663 let err = p
2664 .ping_failed(&PingFailure::Fatal("HTTP 400".into()), at, wat)
2665 .unwrap_err();
2666 assert_eq!(err, PeriodEnd::Rejected("HTTP 400".into()));
2667 }
2668
2669 #[test]
2670 fn hours_window_and_bound_whichever_first() {
2671 let (pings, _, _, reason, _) = run(
2673 cfg(CacheKeepWarm::Hours(1), CacheTtl::OneHour, OPUS),
2674 &[usage(OPUS, P, 0, 0, 0)],
2675 );
2676 assert_eq!((pings, reason), (1, PeriodEnd::HoursReached(1)));
2677 let (pings, _, _, reason, _) = run(
2679 cfg(CacheKeepWarm::Hours(2), CacheTtl::FiveMinutes, OPUS),
2680 &[usage(OPUS, P, 0, 0, 0)],
2681 );
2682 assert_eq!(pings, 12);
2683 assert!(matches!(reason, PeriodEnd::BoundReached { .. }));
2684 let t0 = Instant::now();
2686 let w0 = SystemTime::UNIX_EPOCH;
2687 let mut c = cfg(CacheKeepWarm::Hours(1), CacheTtl::OneHour, OPUS);
2688 c.interval_override = Some(Duration::from_secs(2 * 3600));
2689 assert_eq!(
2690 Period::start(
2691 1,
2692 c,
2693 7,
2694 &last_read(OPUS),
2695 ev_1h(),
2696 anchor_at(t0, w0),
2697 t0,
2698 w0
2699 )
2700 .unwrap_err(),
2701 PeriodEnd::HoursReached(1)
2702 );
2703 }
2704
2705 #[test]
2711 #[serial_test::serial(synaps_keepwarm_interval)]
2712 fn the_interval_override_is_set_only_by_code_never_by_the_environment() {
2713 let _env = crate::test_env::EnvVarGuard::set("SYNAPS_CACHE_KEEPWARM_INTERVAL_MS", "250");
2714 assert_eq!(interval_override(), None, "the old env var does nothing");
2715 {
2716 let _g = testing::IntervalOverride::set(Duration::from_millis(250));
2717 assert_eq!(interval_override(), Some(Duration::from_millis(250)));
2718 }
2719 assert_eq!(interval_override(), None, "cleared with the guard");
2720 }
2721
2722 #[test]
2727 fn a_re_enabled_period_continues_the_exhausted_ledger() {
2728 let t0 = Instant::now();
2729 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(80_000);
2730 let c = || cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS);
2731 let mut p = start(c(), &last_read(OPUS), t0, w0);
2732 let read = usage(OPUS, P, 0, 0, 0);
2733 let end = loop {
2734 let at = p.next_at();
2735 let wat = w0 + (at - t0);
2736 match p.fire(at, wat, 0.0, None) {
2737 Fire::Ping => {}
2738 Fire::End(r) => break r,
2739 Fire::NotYet => unreachable!(),
2740 }
2741 p.charge(read.cost_usd(OPUS));
2742 if let Err(r) = p.ping_sent(&read, Some(anchor_at(at, wat)), false) {
2743 break r;
2744 }
2745 };
2746 assert!(matches!(end, PeriodEnd::BoundReached { .. }), "{end}");
2747 let carry = p.epoch_carry();
2748 assert_eq!(carry.pings, 19);
2749 let anchor = p.anchor;
2751 let err = Period::start_in_epoch(
2752 2,
2753 c(),
2754 7,
2755 &last_read(OPUS),
2756 ev_1h(),
2757 anchor,
2758 anchor.mono,
2759 anchor.wall,
2760 Some(&carry),
2761 )
2762 .unwrap_err();
2763 assert!(matches!(err, PeriodEnd::BoundReached { .. }), "{err}");
2764 assert!(Period::start_in_epoch(
2766 3,
2767 c(),
2768 7,
2769 &last_read(OPUS),
2770 ev_1h(),
2771 anchor,
2772 anchor.mono,
2773 anchor.wall,
2774 None
2775 )
2776 .is_ok());
2777 }
2778
2779 #[test]
2782 fn ping_one_write_allowance_is_never_re_granted_in_an_epoch() {
2783 let t0 = Instant::now();
2784 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(81_000);
2785 let c = || cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS);
2786 let last = usage(OPUS, 250_000, 0, 0, 50_000);
2787 let mut p = start(c(), &last, t0, w0);
2788 let at = p.next_at();
2789 let wat = w0 + (at - t0);
2790 assert_eq!(p.fire(at, wat, 0.0, None), Fire::Ping);
2791 let first = usage(OPUS, 250_000, 50_000, 40, 1);
2792 p.charge(first.cost_usd(OPUS));
2793 p.ping_sent(&first, Some(anchor_at(at, wat)), false)
2794 .unwrap();
2795 let carry = p.epoch_carry();
2796 let mut q = Period::start_in_epoch(
2798 2,
2799 c(),
2800 7,
2801 &last,
2802 ev_1h(),
2803 anchor_at(at, wat),
2804 at,
2805 wat,
2806 Some(&carry),
2807 )
2808 .unwrap();
2809 assert_eq!(q.pings(), 1);
2810 assert!((q.ledger_usd() - first.cost_usd(OPUS)).abs() < 1e-12);
2811 let at2 = q.next_at();
2812 let wat2 = wat + (at2 - at);
2813 assert_eq!(q.fire(at2, wat2, 0.0, None), Fire::Ping);
2814 assert_eq!(
2816 q.ping_sent(&usage(OPUS, 250_000, 50_000, 40, 1), None, false),
2817 Err(PeriodEnd::PartialMiss {
2818 written: 50_000,
2819 prefix: P
2820 })
2821 );
2822 }
2823
2824 #[test]
2830 fn ping_counts_per_multiplier_and_the_auto_cap() {
2831 for (model, uncapped, capped) in [
2832 ("claude-opus-4-7", 19, 19),
2833 ("claude-opus-5-5", 39, 26),
2834 ("claude-fable-5-1", 79, 26),
2835 ("claude-fable-5-2", 79, 26),
2836 ] {
2837 let mut c = cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, model);
2838 let (pings, _, _, reason, elapsed) = run(c.clone(), &[usage(model, P, 0, 0, 0)]);
2839 assert_eq!(pings, uncapped, "{model}");
2840 assert!(
2841 matches!(reason, PeriodEnd::BoundReached { .. }),
2842 "{model}: {reason}"
2843 );
2844 assert_eq!(elapsed, uncapped * 55 * MIN, "{model}");
2845 c.auto_max_hours = 24;
2846 let (pings, _, _, reason, _) = run(c, &[usage(model, P, 0, 0, 0)]);
2847 assert_eq!(pings, capped, "{model}");
2848 if capped < uncapped {
2849 assert_eq!(reason, PeriodEnd::HoursReached(24), "{model}");
2850 }
2851 }
2852 for (model, want) in [
2854 ("claude-opus-4-7", 12),
2855 ("claude-opus-5-5", 24),
2856 ("claude-fable-5-1", 49),
2857 ("claude-fable-5-2", 49),
2858 ] {
2859 let mut c = cfg(CacheKeepWarm::Auto, CacheTtl::FiveMinutes, model);
2860 c.auto_max_hours = 24;
2861 let (pings, ..) = run(c, &[usage(model, P, 0, 0, 0)]);
2862 assert_eq!(pings, want, "{model} 5m");
2863 }
2864 let mut c = cfg(
2866 CacheKeepWarm::Hours(48),
2867 CacheTtl::OneHour,
2868 "claude-fable-5-1",
2869 );
2870 c.auto_max_hours = 24;
2871 let (pings, ..) = run(c, &[usage("claude-fable-5-1", P, 0, 0, 0)]);
2872 assert_eq!(pings, 52, "48h window: 52 pings at 55 min");
2873 }
2874
2875 #[test]
2878 fn first_write_counts_only_what_the_prefix_will_hold() {
2879 let t0 = Instant::now();
2880 let w0 = SystemTime::UNIX_EPOCH + Duration::from_secs(82_000);
2881 let last = usage(OPUS, 250_000, 0, 1_000, 40_000);
2885 let mut c = cfg(CacheKeepWarm::Auto, CacheTtl::OneHour, OPUS);
2886 let billed = start(c.clone(), &last, t0, w0);
2887 assert_eq!(billed.prefix_tokens(), 291_000, "without the estimate");
2888 c.reply_tokens = Some(2_000);
2889 let p = start(c.clone(), &last, t0, w0);
2890 assert_eq!(p.prefix_tokens(), 253_000);
2891 c.reply_tokens = Some(90_000);
2893 assert_eq!(start(c, &last, t0, w0).prefix_tokens(), 291_000);
2894 }
2895
2896 #[test]
2897 fn status_renders_spend_and_period() {
2898 let s = serde_json::json!({
2899 "mode": "auto",
2900 "active": true,
2901 "period": {"id": 3, "ttl_secs": 300, "pings": 2, "ledger_usd": 0.3,
2902 "bound_usd": 1.875, "prefix_tokens": P, "next_ping_in_secs": 120,
2903 "in_flight": false},
2904 "prefix_matches": true,
2905 "session_pings": 5,
2906 "session_keepwarm_usd": 0.75,
2907 "last_end": "prefix changed",
2908 });
2909 let text = render_status(&s);
2910 assert!(text.contains("auto"), "{text}");
2911 assert!(text.contains("5 pings, $0.7500"), "{text}");
2912 assert!(text.contains("ping 3 due in 120s"), "{text}");
2913 assert!(text.contains("$0.3000 spent of $1.8750 bound"), "{text}");
2914 assert!(text.contains("prefix unchanged"), "{text}");
2915 assert!(text.contains("last: prefix changed"), "{text}");
2916 assert!(!text.contains("prompt-cache TTL"), "absent field, no line: {text}");
2917 }
2918
2919 #[test]
2920 fn status_renders_the_resolved_cache_ttl() {
2921 let mut s = serde_json::json!({
2922 "mode": "auto",
2923 "active": false,
2924 "period": null,
2925 "session_pings": 0,
2926 "session_keepwarm_usd": 0.0,
2927 "cache_ttl": {"setting": "auto", "resolved": "1h"},
2928 });
2929 let text = render_status(&s);
2930 assert!(text.contains("\nprompt-cache TTL: 1h (auto)\n"), "{text}");
2931 s["cache_ttl"] = serde_json::json!({"setting": "5m", "resolved": "5m"});
2932 let text = render_status(&s);
2933 assert!(text.contains("\nprompt-cache TTL: 5m\n"), "{text}");
2934 assert!(!text.contains("(auto)"), "{text}");
2935 }
2936}