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 cost_cap_message(&self) -> Option<String> {
73 let cfg = crate::core::config::Config::load();
74 let cap = cfg.cost.max_session_cost_usd;
75 if cap <= 0.0 {
76 return None;
77 }
78 if std::env::var("LEAN_CTX_COST_CAP_OVERRIDE")
79 .ok()
80 .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
81 {
82 return None;
83 }
84 let used = self.cost_usd();
85 if used < cap {
86 return None;
87 }
88 Some(format!(
89 "[COST CAP] Session cost ${used:.2} reached ${cap:.2} limit. Use ctx_session(action=budget, override=true) or LEAN_CTX_COST_CAP_OVERRIDE=1 to continue."
90 ))
91 }
92
93 pub fn reset(&self) {
94 self.context_tokens.store(0, Ordering::Relaxed);
95 self.shell_invocations.store(0, Ordering::Relaxed);
96 self.cost_millicents.store(0, Ordering::Relaxed);
97 self.tool_calls.store(0, Ordering::Relaxed);
98 }
99
100 fn capped_token_limit(role_limit: usize, policy_cap: Option<u32>) -> usize {
103 match policy_cap {
104 Some(cap) => role_limit.min(cap as usize),
105 None => role_limit,
106 }
107 }
108
109 pub fn check(&self) -> BudgetSnapshot {
110 let mut limits = roles::active_role().limits;
111 let role_name = roles::active_role_name();
112
113 let policy_cap =
116 crate::core::policy::runtime::active().and_then(|p| p.resolved.max_context_tokens);
117 limits.max_context_tokens = Self::capped_token_limit(limits.max_context_tokens, policy_cap);
118
119 let tokens = self.tokens_used();
120 let shell = self.shell_used();
121 let cost = self.cost_usd();
122
123 BudgetSnapshot {
124 role: role_name,
125 tokens: DimensionStatus::evaluate(tokens as usize, limits.max_context_tokens, &limits),
126 shell: DimensionStatus::evaluate(shell, limits.max_shell_invocations, &limits),
127 cost: CostStatus::evaluate(cost, limits.max_cost_usd, &limits),
128 }
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
133pub enum BudgetLevel {
134 Ok,
135 Warning,
136 Exhausted,
137}
138
139impl std::fmt::Display for BudgetLevel {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 match self {
142 Self::Ok => write!(f, "OK"),
143 Self::Warning => write!(f, "WARNING"),
144 Self::Exhausted => write!(f, "EXHAUSTED"),
145 }
146 }
147}
148
149#[derive(Debug, Clone, Serialize)]
150pub struct DimensionStatus {
151 pub used: usize,
152 pub limit: usize,
153 pub percent: u8,
154 pub level: BudgetLevel,
155}
156
157impl DimensionStatus {
158 fn evaluate(used: usize, limit: usize, limits: &RoleLimits) -> Self {
159 if limit == 0 {
160 return Self {
162 used,
163 limit,
164 percent: 0,
165 level: if used > 0 {
166 BudgetLevel::Warning
167 } else {
168 BudgetLevel::Ok
169 },
170 };
171 }
172 let percent = ((used as f64 / limit as f64) * 100.0).min(254.0) as u8;
173 let level = if limits.block_at_percent < 255 && percent >= limits.block_at_percent {
175 BudgetLevel::Exhausted
176 } else if percent >= limits.warn_at_percent {
177 BudgetLevel::Warning
178 } else {
179 BudgetLevel::Ok
180 };
181 Self {
182 used,
183 limit,
184 percent,
185 level,
186 }
187 }
188}
189
190#[derive(Debug, Clone, Serialize)]
191pub struct CostStatus {
192 pub used_usd: f64,
193 pub limit_usd: f64,
194 pub percent: u8,
195 pub level: BudgetLevel,
196}
197
198impl CostStatus {
199 fn evaluate(used: f64, limit: f64, limits: &RoleLimits) -> Self {
200 if limit <= 0.0 {
201 return Self {
203 used_usd: used,
204 limit_usd: limit,
205 percent: 0,
206 level: if used > 0.0 {
207 BudgetLevel::Warning
208 } else {
209 BudgetLevel::Ok
210 },
211 };
212 }
213 let pct = ((used / limit) * 100.0).min(254.0) as u8;
214 let level = if limits.block_at_percent < 255 && pct >= limits.block_at_percent {
216 BudgetLevel::Exhausted
217 } else if pct >= limits.warn_at_percent {
218 BudgetLevel::Warning
219 } else {
220 BudgetLevel::Ok
221 };
222 Self {
223 used_usd: used,
224 limit_usd: limit,
225 percent: pct,
226 level,
227 }
228 }
229}
230
231#[derive(Debug, Clone, Serialize)]
232pub struct BudgetSnapshot {
233 pub role: String,
234 pub tokens: DimensionStatus,
235 pub shell: DimensionStatus,
236 pub cost: CostStatus,
237}
238
239impl BudgetSnapshot {
240 pub fn worst_level(&self) -> &BudgetLevel {
241 for level in [&self.tokens.level, &self.shell.level, &self.cost.level] {
242 if *level == BudgetLevel::Exhausted {
243 return level;
244 }
245 }
246 for level in [&self.tokens.level, &self.shell.level, &self.cost.level] {
247 if *level == BudgetLevel::Warning {
248 return level;
249 }
250 }
251 &BudgetLevel::Ok
252 }
253
254 pub fn format_compact(&self) -> String {
255 format!(
256 "Budget[role:{}]: tokens {}/{} ({}%) | shell {}/{} ({}%) | cost ${:.2}/${:.2} ({}%) → {}",
257 self.role,
258 self.tokens.used,
259 self.tokens.limit,
260 self.tokens.percent,
261 self.shell.used,
262 self.shell.limit,
263 self.shell.percent,
264 self.cost.used_usd,
265 self.cost.limit_usd,
266 self.cost.percent,
267 self.worst_level(),
268 )
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn tracker_starts_at_zero() {
278 let t = BudgetTracker::new();
279 assert_eq!(t.tokens_used(), 0);
280 assert_eq!(t.shell_used(), 0);
281 assert!((t.cost_usd() - 0.0).abs() < f64::EPSILON);
282 }
283
284 #[test]
285 fn record_and_read() {
286 let t = BudgetTracker::new();
287 t.record_tokens(5000);
288 t.record_tokens(3000);
289 t.record_shell();
290 t.record_shell();
291 t.record_cost_usd(0.50);
292 assert_eq!(t.tokens_used(), 8000);
293 assert_eq!(t.shell_used(), 2);
294 assert!((t.cost_usd() - 0.50).abs() < 0.001);
295 }
296
297 #[test]
298 fn reset_clears_all() {
299 let t = BudgetTracker::new();
300 t.record_tokens(10_000);
301 t.record_shell();
302 t.record_cost_usd(1.0);
303 t.reset();
304 assert_eq!(t.tokens_used(), 0);
305 assert_eq!(t.shell_used(), 0);
306 assert!((t.cost_usd() - 0.0).abs() < f64::EPSILON);
307 }
308
309 #[test]
310 fn dimension_status_ok() {
311 let limits = RoleLimits::default();
312 let s = DimensionStatus::evaluate(50_000, 200_000, &limits);
313 assert_eq!(s.level, BudgetLevel::Ok);
314 assert_eq!(s.percent, 25);
315 }
316
317 #[test]
318 fn policy_cap_tightens_but_never_loosens() {
319 assert_eq!(
321 BudgetTracker::capped_token_limit(200_000, Some(5_000)),
322 5_000
323 );
324 assert_eq!(
325 BudgetTracker::capped_token_limit(4_000, Some(50_000)),
326 4_000
327 );
328 assert_eq!(BudgetTracker::capped_token_limit(10_000, None), 10_000);
329 }
330
331 #[test]
332 fn dimension_status_warning() {
333 let limits = RoleLimits::default();
334 let s = DimensionStatus::evaluate(170_000, 200_000, &limits);
335 assert_eq!(s.level, BudgetLevel::Warning);
336 assert_eq!(s.percent, 85);
337 }
338
339 #[test]
340 fn dimension_status_at_100_percent_is_warning_by_default() {
341 let limits = RoleLimits::default();
343 assert_eq!(limits.block_at_percent, 255); let s = DimensionStatus::evaluate(200_000, 200_000, &limits);
345 assert_eq!(s.level, BudgetLevel::Warning);
346 assert_eq!(s.percent, 100);
347 }
348
349 #[test]
350 fn dimension_status_exhausted_when_blocking_enabled() {
351 let limits = RoleLimits {
353 block_at_percent: 100,
354 ..Default::default()
355 };
356 let s = DimensionStatus::evaluate(200_000, 200_000, &limits);
357 assert_eq!(s.level, BudgetLevel::Exhausted);
358 }
359
360 #[test]
361 fn zero_limit_warns_usage() {
362 let limits = RoleLimits::default();
364 let s = DimensionStatus::evaluate(1, 0, &limits);
365 assert_eq!(s.level, BudgetLevel::Warning);
366 }
367
368 #[test]
369 fn cost_cap_no_limit_returns_none() {
370 let t = BudgetTracker::new();
371 t.record_cost_usd(100.0);
372 assert!(t.cost_cap_message().is_none());
375 }
376
377 #[test]
378 fn cost_cap_blocks_when_exceeded() {
379 let _env_lock = crate::core::data_dir::test_env_lock();
380 let t = BudgetTracker::new();
381 t.record_cost_usd(6.0);
382 unsafe {
384 std::env::set_var("LEAN_CTX_COST_CAP_OVERRIDE", "1");
385 }
386 assert!(
387 t.cost_cap_message().is_none(),
388 "override=1 must bypass cost cap"
389 );
390 unsafe {
392 std::env::remove_var("LEAN_CTX_COST_CAP_OVERRIDE");
393 }
394 }
395
396 #[test]
397 fn cost_status_warning() {
398 let limits = RoleLimits::default();
399 let s = CostStatus::evaluate(4.5, 5.0, &limits);
400 assert_eq!(s.level, BudgetLevel::Warning);
401 }
402
403 #[test]
404 fn snapshot_worst_level() {
405 let limits = RoleLimits::default();
406 let snap = BudgetSnapshot {
407 role: "test".into(),
408 tokens: DimensionStatus::evaluate(50_000, 200_000, &limits),
409 shell: DimensionStatus::evaluate(90, 100, &limits),
410 cost: CostStatus::evaluate(1.0, 5.0, &limits),
411 };
412 assert_eq!(*snap.worst_level(), BudgetLevel::Warning);
413 }
414
415 #[test]
416 fn format_compact_includes_all() {
417 let s = BudgetSnapshot {
418 role: "coder".into(),
419 tokens: DimensionStatus {
420 used: 1000,
421 limit: 200_000,
422 percent: 0,
423 level: BudgetLevel::Ok,
424 },
425 shell: DimensionStatus {
426 used: 5,
427 limit: 100,
428 percent: 5,
429 level: BudgetLevel::Ok,
430 },
431 cost: CostStatus {
432 used_usd: 0.25,
433 limit_usd: 5.0,
434 percent: 5,
435 level: BudgetLevel::Ok,
436 },
437 };
438 let out = s.format_compact();
439 assert!(out.contains("role:coder"));
440 assert!(out.contains("tokens"));
441 assert!(out.contains("shell"));
442 assert!(out.contains("cost"));
443 assert!(out.contains("OK"));
444 }
445}