1use crate::aggregate::parse_iso;
11use crate::usage_signal::AgentUsage;
12
13pub const WIN_SESSION_SECS: f64 = 5.0 * 3600.0;
15
16#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
18pub struct BlockStatus {
19 pub tokens: u64,
20 pub cost: f64,
21 pub cache_read: u64,
22 pub pct_of_limit: Option<f64>,
24 pub resets_at: Option<String>,
25 pub secs_until_reset: Option<i64>,
27 pub elapsed_hr: Option<f64>,
29 pub burn_cost_per_hr: Option<f64>,
31 pub burn_tokens_per_min: Option<f64>,
33 pub projected_cost: Option<f64>,
35 pub eta_to_limit_secs: Option<i64>,
37}
38
39pub fn block_status(agent: &AgentUsage, now: f64) -> Option<BlockStatus> {
42 if agent.session_5h_tokens == 0 && agent.cost_5h <= 0.0 {
43 return None;
44 }
45
46 let mut s = BlockStatus {
47 tokens: agent.session_5h_tokens,
48 cost: agent.cost_5h,
49 cache_read: agent.cache_read_tokens_5h,
50 pct_of_limit: agent.session_5h_percent,
51 resets_at: agent.session_5h_resets_at.clone(),
52 ..Default::default()
53 };
54
55 if let Some(reset_ts) = agent.session_5h_resets_at.as_deref().and_then(|r| parse_iso(Some(r))) {
57 let secs_until = (reset_ts - now).round() as i64;
58 s.secs_until_reset = Some(secs_until.max(0));
59
60 let window_start = reset_ts - WIN_SESSION_SECS;
61 let elapsed = (now - window_start).clamp(0.0, WIN_SESSION_SECS);
62 if elapsed > 0.0 {
63 let elapsed_hr = elapsed / 3600.0;
64 s.elapsed_hr = Some(elapsed_hr);
65 s.burn_cost_per_hr = Some(agent.cost_5h / elapsed_hr);
66 s.burn_tokens_per_min = Some(agent.session_5h_tokens as f64 / (elapsed / 60.0));
67
68 let remaining_hr = (WIN_SESSION_SECS - elapsed) / 3600.0;
70 s.projected_cost = Some(agent.cost_5h + s.burn_cost_per_hr.unwrap() * remaining_hr);
71
72 if let Some(pct) = agent.session_5h_percent {
74 if pct > 0.0 && pct < 100.0 {
75 let pct_per_hr = pct / elapsed_hr;
76 if pct_per_hr > 0.0 {
77 let hrs_to_100 = (100.0 - pct) / pct_per_hr;
78 s.eta_to_limit_secs = Some((hrs_to_100 * 3600.0).round() as i64);
79 }
80 }
81 }
82 }
83 }
84
85 Some(s)
86}
87
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum Tier {
91 Ok,
92 Warn,
93 Critical,
94}
95
96impl Tier {
97 pub fn from_pct(pct: f64) -> Tier {
98 if pct >= 80.0 {
99 Tier::Critical
100 } else if pct >= 50.0 {
101 Tier::Warn
102 } else {
103 Tier::Ok
104 }
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 fn agent(tokens: u64, cost: f64, pct: Option<f64>, resets_at: Option<&str>) -> AgentUsage {
113 AgentUsage {
114 session_5h_tokens: tokens,
115 cost_5h: cost,
116 session_5h_percent: pct,
117 session_5h_resets_at: resets_at.map(str::to_string),
118 ..Default::default()
119 }
120 }
121
122 #[test]
123 fn empty_block_is_none() {
124 assert!(block_status(&agent(0, 0.0, None, None), 1_000.0).is_none());
125 }
126
127 #[test]
128 fn burn_and_projection_from_elapsed() {
129 let now = 9000.0;
131 let resets = crate::aggregate::iso_utc(18000.0);
132 let a = agent(150_000, 10.0, Some(40.0), Some(&resets));
133 let s = block_status(&a, now).unwrap();
134 assert_eq!(s.secs_until_reset, Some(9000));
135 let eh = s.elapsed_hr.unwrap();
136 assert!((eh - 2.5).abs() < 1e-6, "elapsed {eh}");
137 assert!((s.burn_cost_per_hr.unwrap() - 4.0).abs() < 1e-6);
139 assert!((s.projected_cost.unwrap() - 20.0).abs() < 1e-6);
141 assert!((s.burn_tokens_per_min.unwrap() - 1000.0).abs() < 1e-6);
143 assert_eq!(s.eta_to_limit_secs, Some(13500));
145 }
146
147 #[test]
148 fn no_reset_still_reports_totals() {
149 let s = block_status(&agent(500, 1.0, None, None), 100.0).unwrap();
150 assert_eq!(s.tokens, 500);
151 assert!(s.burn_cost_per_hr.is_none());
152 assert!(s.secs_until_reset.is_none());
153 }
154
155 #[test]
156 fn tiers() {
157 assert_eq!(Tier::from_pct(10.0), Tier::Ok);
158 assert_eq!(Tier::from_pct(60.0), Tier::Warn);
159 assert_eq!(Tier::from_pct(95.0), Tier::Critical);
160 }
161}