1use std::sync::OnceLock;
7use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
8
9use serde::Serialize;
10
11use crate::core::roles::{self, RoleLimits};
12
13static TRACKER: OnceLock<BudgetTracker> = OnceLock::new();
14
15pub struct BudgetTracker {
16 context_tokens: AtomicU64,
17 shell_invocations: AtomicUsize,
18 cost_millicents: AtomicU64,
19 tool_calls: AtomicUsize,
20}
21
22impl BudgetTracker {
23 fn new() -> Self {
24 Self {
25 context_tokens: AtomicU64::new(0),
26 shell_invocations: AtomicUsize::new(0),
27 cost_millicents: AtomicU64::new(0),
28 tool_calls: AtomicUsize::new(0),
29 }
30 }
31
32 pub fn global() -> &'static BudgetTracker {
33 TRACKER.get_or_init(BudgetTracker::new)
34 }
35
36 pub fn record_tokens(&self, tokens: u64) {
37 self.context_tokens.fetch_add(tokens, Ordering::Relaxed);
38 }
39
40 pub fn record_shell(&self) {
41 self.shell_invocations.fetch_add(1, Ordering::Relaxed);
42 }
43
44 pub fn record_tool_call(&self) {
45 self.tool_calls.fetch_add(1, Ordering::Relaxed);
46 }
47
48 pub fn tool_calls_count(&self) -> usize {
49 self.tool_calls.load(Ordering::Relaxed)
50 }
51
52 pub fn record_cost_usd(&self, usd: f64) {
53 let mc = (usd * 100_000.0) as u64;
54 self.cost_millicents.fetch_add(mc, Ordering::Relaxed);
55 }
56
57 pub fn tokens_used(&self) -> u64 {
58 self.context_tokens.load(Ordering::Relaxed)
59 }
60
61 pub fn shell_used(&self) -> usize {
62 self.shell_invocations.load(Ordering::Relaxed)
63 }
64
65 pub fn cost_usd(&self) -> f64 {
66 self.cost_millicents.load(Ordering::Relaxed) as f64 / 100_000.0
67 }
68
69 pub fn reset(&self) {
70 self.context_tokens.store(0, Ordering::Relaxed);
71 self.shell_invocations.store(0, Ordering::Relaxed);
72 self.cost_millicents.store(0, Ordering::Relaxed);
73 self.tool_calls.store(0, Ordering::Relaxed);
74 }
75
76 fn capped_token_limit(role_limit: usize, policy_cap: Option<u32>) -> usize {
79 match policy_cap {
80 Some(cap) => role_limit.min(cap as usize),
81 None => role_limit,
82 }
83 }
84
85 pub fn check(&self) -> BudgetSnapshot {
86 let mut limits = roles::active_role().limits;
87 let role_name = roles::active_role_name();
88
89 let policy_cap =
92 crate::core::policy::runtime::active().and_then(|p| p.resolved.max_context_tokens);
93 limits.max_context_tokens = Self::capped_token_limit(limits.max_context_tokens, policy_cap);
94
95 let tokens = self.tokens_used();
96 let shell = self.shell_used();
97 let cost = self.cost_usd();
98
99 BudgetSnapshot {
100 role: role_name,
101 tokens: DimensionStatus::evaluate(tokens as usize, limits.max_context_tokens, &limits),
102 shell: DimensionStatus::evaluate(shell, limits.max_shell_invocations, &limits),
103 cost: CostStatus::evaluate(cost, limits.max_cost_usd, &limits),
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
109pub enum BudgetLevel {
110 Ok,
111 Warning,
112 Exhausted,
113}
114
115impl std::fmt::Display for BudgetLevel {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 match self {
118 Self::Ok => write!(f, "OK"),
119 Self::Warning => write!(f, "WARNING"),
120 Self::Exhausted => write!(f, "EXHAUSTED"),
121 }
122 }
123}
124
125#[derive(Debug, Clone, Serialize)]
126pub struct DimensionStatus {
127 pub used: usize,
128 pub limit: usize,
129 pub percent: u8,
130 pub level: BudgetLevel,
131}
132
133impl DimensionStatus {
134 fn evaluate(used: usize, limit: usize, limits: &RoleLimits) -> Self {
135 if limit == 0 {
136 return Self {
138 used,
139 limit,
140 percent: 0,
141 level: if used > 0 {
142 BudgetLevel::Warning
143 } else {
144 BudgetLevel::Ok
145 },
146 };
147 }
148 let percent = ((used as f64 / limit as f64) * 100.0).min(254.0) as u8;
149 let level = if limits.block_at_percent < 255 && percent >= limits.block_at_percent {
151 BudgetLevel::Exhausted
152 } else if percent >= limits.warn_at_percent {
153 BudgetLevel::Warning
154 } else {
155 BudgetLevel::Ok
156 };
157 Self {
158 used,
159 limit,
160 percent,
161 level,
162 }
163 }
164}
165
166#[derive(Debug, Clone, Serialize)]
167pub struct CostStatus {
168 pub used_usd: f64,
169 pub limit_usd: f64,
170 pub percent: u8,
171 pub level: BudgetLevel,
172}
173
174impl CostStatus {
175 fn evaluate(used: f64, limit: f64, limits: &RoleLimits) -> Self {
176 if limit <= 0.0 {
177 return Self {
179 used_usd: used,
180 limit_usd: limit,
181 percent: 0,
182 level: if used > 0.0 {
183 BudgetLevel::Warning
184 } else {
185 BudgetLevel::Ok
186 },
187 };
188 }
189 let pct = ((used / limit) * 100.0).min(254.0) as u8;
190 let level = if limits.block_at_percent < 255 && pct >= limits.block_at_percent {
192 BudgetLevel::Exhausted
193 } else if pct >= limits.warn_at_percent {
194 BudgetLevel::Warning
195 } else {
196 BudgetLevel::Ok
197 };
198 Self {
199 used_usd: used,
200 limit_usd: limit,
201 percent: pct,
202 level,
203 }
204 }
205}
206
207#[derive(Debug, Clone, Serialize)]
208pub struct BudgetSnapshot {
209 pub role: String,
210 pub tokens: DimensionStatus,
211 pub shell: DimensionStatus,
212 pub cost: CostStatus,
213}
214
215impl BudgetSnapshot {
216 pub fn worst_level(&self) -> &BudgetLevel {
217 for level in [&self.tokens.level, &self.shell.level, &self.cost.level] {
218 if *level == BudgetLevel::Exhausted {
219 return level;
220 }
221 }
222 for level in [&self.tokens.level, &self.shell.level, &self.cost.level] {
223 if *level == BudgetLevel::Warning {
224 return level;
225 }
226 }
227 &BudgetLevel::Ok
228 }
229
230 pub fn format_compact(&self) -> String {
231 format!(
232 "Budget[{}]: tokens {}/{} ({}%) | shell {}/{} ({}%) | cost ${:.2}/${:.2} ({}%) → {}",
233 self.role,
234 self.tokens.used,
235 self.tokens.limit,
236 self.tokens.percent,
237 self.shell.used,
238 self.shell.limit,
239 self.shell.percent,
240 self.cost.used_usd,
241 self.cost.limit_usd,
242 self.cost.percent,
243 self.worst_level(),
244 )
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn tracker_starts_at_zero() {
254 let t = BudgetTracker::new();
255 assert_eq!(t.tokens_used(), 0);
256 assert_eq!(t.shell_used(), 0);
257 assert!((t.cost_usd() - 0.0).abs() < f64::EPSILON);
258 }
259
260 #[test]
261 fn record_and_read() {
262 let t = BudgetTracker::new();
263 t.record_tokens(5000);
264 t.record_tokens(3000);
265 t.record_shell();
266 t.record_shell();
267 t.record_cost_usd(0.50);
268 assert_eq!(t.tokens_used(), 8000);
269 assert_eq!(t.shell_used(), 2);
270 assert!((t.cost_usd() - 0.50).abs() < 0.001);
271 }
272
273 #[test]
274 fn reset_clears_all() {
275 let t = BudgetTracker::new();
276 t.record_tokens(10_000);
277 t.record_shell();
278 t.record_cost_usd(1.0);
279 t.reset();
280 assert_eq!(t.tokens_used(), 0);
281 assert_eq!(t.shell_used(), 0);
282 assert!((t.cost_usd() - 0.0).abs() < f64::EPSILON);
283 }
284
285 #[test]
286 fn dimension_status_ok() {
287 let limits = RoleLimits::default();
288 let s = DimensionStatus::evaluate(50_000, 200_000, &limits);
289 assert_eq!(s.level, BudgetLevel::Ok);
290 assert_eq!(s.percent, 25);
291 }
292
293 #[test]
294 fn policy_cap_tightens_but_never_loosens() {
295 assert_eq!(
297 BudgetTracker::capped_token_limit(200_000, Some(5_000)),
298 5_000
299 );
300 assert_eq!(
301 BudgetTracker::capped_token_limit(4_000, Some(50_000)),
302 4_000
303 );
304 assert_eq!(BudgetTracker::capped_token_limit(10_000, None), 10_000);
305 }
306
307 #[test]
308 fn dimension_status_warning() {
309 let limits = RoleLimits::default();
310 let s = DimensionStatus::evaluate(170_000, 200_000, &limits);
311 assert_eq!(s.level, BudgetLevel::Warning);
312 assert_eq!(s.percent, 85);
313 }
314
315 #[test]
316 fn dimension_status_at_100_percent_is_warning_by_default() {
317 let limits = RoleLimits::default();
319 assert_eq!(limits.block_at_percent, 255); let s = DimensionStatus::evaluate(200_000, 200_000, &limits);
321 assert_eq!(s.level, BudgetLevel::Warning);
322 assert_eq!(s.percent, 100);
323 }
324
325 #[test]
326 fn dimension_status_exhausted_when_blocking_enabled() {
327 let limits = RoleLimits {
329 block_at_percent: 100,
330 ..Default::default()
331 };
332 let s = DimensionStatus::evaluate(200_000, 200_000, &limits);
333 assert_eq!(s.level, BudgetLevel::Exhausted);
334 }
335
336 #[test]
337 fn zero_limit_warns_usage() {
338 let limits = RoleLimits::default();
340 let s = DimensionStatus::evaluate(1, 0, &limits);
341 assert_eq!(s.level, BudgetLevel::Warning);
342 }
343
344 #[test]
345 fn cost_status_warning() {
346 let limits = RoleLimits::default();
347 let s = CostStatus::evaluate(4.5, 5.0, &limits);
348 assert_eq!(s.level, BudgetLevel::Warning);
349 }
350
351 #[test]
352 fn snapshot_worst_level() {
353 let limits = RoleLimits::default();
354 let snap = BudgetSnapshot {
355 role: "test".into(),
356 tokens: DimensionStatus::evaluate(50_000, 200_000, &limits),
357 shell: DimensionStatus::evaluate(90, 100, &limits),
358 cost: CostStatus::evaluate(1.0, 5.0, &limits),
359 };
360 assert_eq!(*snap.worst_level(), BudgetLevel::Warning);
361 }
362
363 #[test]
364 fn format_compact_includes_all() {
365 let s = BudgetSnapshot {
366 role: "coder".into(),
367 tokens: DimensionStatus {
368 used: 1000,
369 limit: 200_000,
370 percent: 0,
371 level: BudgetLevel::Ok,
372 },
373 shell: DimensionStatus {
374 used: 5,
375 limit: 100,
376 percent: 5,
377 level: BudgetLevel::Ok,
378 },
379 cost: CostStatus {
380 used_usd: 0.25,
381 limit_usd: 5.0,
382 percent: 5,
383 level: BudgetLevel::Ok,
384 },
385 };
386 let out = s.format_compact();
387 assert!(out.contains("coder"));
388 assert!(out.contains("tokens"));
389 assert!(out.contains("shell"));
390 assert!(out.contains("cost"));
391 assert!(out.contains("OK"));
392 }
393}