1//! What every binary that can spend has ever spent, on disk, so that no 2//! amount of restarting, crashing or running two of them at once can lose 3//! track of the total. 4//! 5//! **Why a file and not a counter in each process.** jevsnes's `native` 6//! and `jevprobe`, jevhooks, lmjtfy's eval and anything else that builds a 7//! [`crate::Jev`] are separate processes with no shared memory. A total kept 8//! in one process's memory only sees that process's own spend; two of them 9//! running at once could each think the account had spent nothing so far. 10//! The file is what makes "lifetime" mean the account, not the process. 11//! 12//! **Why `flock` and not a database.** One file, appended to, read 13//! occasionally, by at most a handful of processes that mostly are not 14//! actually running at the same moment - a lock file is the whole mechanism a 15//! database would give here, without a dependency that cannot compile for 16//! nothing (this crate is already the host-only one that opens sockets and 17//! files at all; see its README). `flock(2)`'s lock is released 18//! when the file descriptor closes, including when the process is killed, so 19//! a crash mid-write cannot leave the ledger wedged - which is the property 20//! that matters more than losing one torn write to a crash nobody could have 21//! flushed cleanly anyway. 22//! 23//! **The guards, and which of them a human ever has to touch.** Every request 24//! is admitted by [`Ledger::admit`], which reads the file, decides, and - in 25//! the same `flock` - appends a [`Hold`] for the request it admitted, so two 26//! processes admitting at the same moment are serialised on the lock and the 27//! second one sees the first one's question. Nothing about the guards lives 28//! in any process's memory; the file IS the bucket. 29//! 30//! - **Questions a minute: a throttle, never a pause.** The bucket holds 31//! `max_questions_per_minute` tokens; a question spends one and it comes back 32//! exactly a minute later, which is the sliding window read straight off the 33//! ledger's recent lines. An empty bucket refuses with 34//! [`Refusal::Throttled`] and how long until the next token returns; the 35//! caller keeps playing without asking (the bot takes its own pick), and the 36//! bucket refills as the window slides. There is no file to delete, because 37//! there is no state but the ledger: on 2026-09-21 the old breaker's 38//! `paused.json` was "cleared" by an agent deleting it - a guard being routed 39//! around, which this design makes pointless rather than forbidden. 40//! - **Dollars an hour: a hard stop that reopens by itself.** While the last 41//! hour's spend plus this request's worst case is over 42//! `max_dollars_per_hour`, nothing goes out, from any process. It reopens 43//! once enough of that spend is more than an hour old - never by a human. 44//! This is a RATE guard, not a budget: it bounds how fast money can go out, 45//! not how much, ever - see below for why there is no lifetime cap here. 46//! - **There is no lifetime cap.** Removed 2026-09-22, the user's own ruling 47//! ("not this random $4 number") on an earlier version of this 48//! file that refused once `lifetime_usd` crossed a self-imposed 49//! `JEV_LIFETIME_CAP_USD`: a made-up ceiling on the account's own money is 50//! not this project's number to invent. [`Status::line`] shows what has 51//! been spent, plainly, with no denominator. 52//! - **Out of credit** (the vendor said so) is the one on-disk flag, 53//! `out-of-credit.json`, because it is a fact about the account that no 54//! amount of waiting changes - the only hard stop on total spend now; 55//! [`Ledger::credit_restored`] clears it once the account has been topped 56//! up. 57//! 58//! **Holds.** A hold counts as a question at the moment it was admitted and 59//! as its worst-case cost until the request's real cost is appended against 60//! it ([`Ledger::settle`]) or it is released unanswered ([`Ledger::release`]). 61//! A process killed mid-request leaves its hold unsettled, which keeps 62//! counting its worst case - a few hundredths of a cent - forever: the 63//! conservative direction. 64//! 65//! **Why the running total is read from the file every time, not cached.** 66//! Two files that must agree - a fast running total and the log it summarises 67//! - are a state that can drift, and a cache that has drifted toward 68//! "we have spent less than we have" is exactly the failure this exists to 69//! prevent. Summing a file of a few thousand lines costs low single-digit 70//! milliseconds, which is nothing next to the hundred-plus milliseconds the 71//! network call itself takes. 72 73use std::collections::HashMap; 74use std::fs::{File, OpenOptions}; 75use std::io::{Read, Seek, SeekFrom, Write}; 76use std::os::unix::io::AsRawFd; 77use std::path::{Path, PathBuf}; 78use std::sync::atomic::{AtomicU64, Ordering}; 79use std::time::{SystemTime, UNIX_EPOCH}; 80 81use serde::{Deserialize, Serialize}; 82 83/// Seconds in the windows the guards watch. 84pub const MINUTE: f64 = 60.0; 85pub const HOUR: f64 = 3600.0; 86 87/// What crashed-before-the-ledger-existed spend is booked as, absent a 88/// vendor usage endpoint to read the true figure from (digest §7b/§10: no 89/// such endpoint exists, re-confirmed 2026-09-22 by probing `api.typesafe.ai` 90/// directly and reading `docs.typesafe.ai`'s own index - see 91/// `../README.md`'s "The lifetime ledger" section). 92/// 93/// **Corrected 2026-09-22, from the user's own TypeSafe dashboard reading** 94/// (last 7 days, $0.1876 total spend - the account's whole history, since it 95/// is only a couple of days old): that figure minus this ledger's own real, 96/// measured spend at the same moment ($0.0359, from its own recorded token 97/// counts) leaves $0.1517 of real spend that predates the ledger - not the 98/// $0.25 guessed here on 2026-09-20. The guess was about 65% too high. This 99/// is the same move as correcting `Failure::OutOfCredit`'s classification 100/// when a real example turns up (`../CLAUDE.md`): a documented guess, fixed 101/// once better data exists, in the same commit as the measurement. It is 102/// still a guess, not a vendor-confirmed figure - the dashboard itself is 103/// marked "*Estimated" - which is why the lifetime total stays flagged 104/// "estimated" in [`Status::line`] rather than being trusted exactly. 105const OPENING_ESTIMATE_USD: f64 = 0.1517; 106const OPENING_REASON: &str = 107 "spend before the ledger existed, 2026-09-20 - revised 2026-09-22 against the TypeSafe dashboard's own last-7-day reading ($0.1876) minus this ledger's real spend at that moment"; 108 109/// Now, as seconds since the epoch. A plain `f64` rather than a formatted 110/// timestamp: nothing here does timezone arithmetic, and no `time`/`chrono` 111/// crate is in this project's dependency graph to spend on formatting one. 112/// Everything below takes `now` as an argument instead of calling this, so a 113/// test can hand it any clock it likes. 114pub fn now() -> f64 { 115 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) 116} 117 118/// One request's real cost, as the ledger keeps it. `Serialize`/`Deserialize` 119/// are the wire format: one JSON object per line. 120#[derive(Debug, Clone, Serialize, Deserialize)] 121pub struct Entry { 122 /// Seconds since the epoch. 123 pub time: f64, 124 /// Which binary spent this - `native`, `zbanks`, `jevprobe`, ... - so a 125 /// ledger shared by all of them still says who. 126 pub binary: String, 127 #[serde(default)] 128 pub input_tokens: u64, 129 #[serde(default)] 130 pub output_tokens: u64, 131 pub cost_usd: f64, 132 #[serde(default)] 133 pub request_id: Option<String>, 134 /// True for the one opening line seeded when the ledger is created, and 135 /// for nothing else - every other line is the API's own reported usage. 136 #[serde(default)] 137 pub estimated: bool, 138 #[serde(default)] 139 pub reason: Option<String>, 140 /// The [`Hold`] this settles. Lines written before holds existed 141 /// (2026-09-21) have none, and each of them is a question of its own. 142 #[serde(default, skip_serializing_if = "Option::is_none")] 143 pub hold: Option<String>, 144} 145 146/// A question admitted and not yet answered, written in the same lock as the 147/// check that admitted it and before the request goes out. 148#[derive(Debug, Clone, Serialize, Deserialize)] 149pub struct Hold { 150 pub time: f64, 151 pub binary: String, 152 /// Unique across processes: pid, clock, and a per-process counter. 153 pub hold_id: String, 154 /// The most this request could cost; counted as spent until settled. 155 pub held_usd: f64, 156 pub reason: String, 157} 158 159/// A line of the file. A hold is told apart by its `hold_id`, which no 160/// [`Entry`] has; every line written before holds existed is an `Entry`. 161#[derive(Deserialize)] 162#[serde(untagged)] 163enum Line { 164 Hold(Hold), 165 Spend(Entry), 166} 167 168/// The two RATE limits a request is admitted against. There is no lifetime 169/// cap here - removed 2026-09-22, the user's own ruling ("not this 170/// random $4 number"): a self-imposed ceiling on an account's own money is a 171/// number this project has no standing to invent. The only hard stop on 172/// total spend is the vendor's own out-of-credit answer (`Refusal::OutOfCredit`), 173/// because that is a FACT about the account, not a guess. 174#[derive(Debug, Clone, Copy, PartialEq, Serialize)] 175pub struct Guards { 176 /// The question bucket's size; each token returns a minute after it is spent. 177 pub max_questions_per_minute: u32, 178 /// Hard while over, reopens as the hour slides. 179 pub max_dollars_per_hour: f64, 180} 181 182impl Guards { 183 /// `JEV_MAX_QUESTIONS_PER_MINUTE` (30) and `JEV_MAX_DOLLARS_PER_HOUR` 184 /// ($0.25). 185 pub fn from_env() -> Self { 186 Self { 187 max_questions_per_minute: env_or("JEV_MAX_QUESTIONS_PER_MINUTE", 30), 188 max_dollars_per_hour: env_or("JEV_MAX_DOLLARS_PER_HOUR", 0.25), 189 } 190 } 191} 192 193fn env_or<T: std::str::FromStr>(name: &str, default: T) -> T { 194 std::env::var(name).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(default) 195} 196 197/// What the ledger says about the recent past, read in one pass: everything a 198/// guard decides from. 199#[derive(Debug, Clone, Default)] 200pub struct Window { 201 pub lifetime_usd: f64, 202 /// When each question of the last minute was admitted, oldest first. 203 pub minute: Vec<f64>, 204 /// Every amount booked in the last hour and when, oldest first. 205 pub hour: Vec<(f64, f64)>, 206 /// Holds not yet settled or released: requests in flight, or ones whose 207 /// process died mid-request. 208 pub unsettled: u32, 209} 210 211/// Why a request was not admitted. 212#[derive(Debug, Clone, PartialEq)] 213pub enum Refusal { 214 /// A guard that clears itself: in `retry_in` seconds the same request 215 /// would be admitted, if nothing else is asked meanwhile. The caller goes 216 /// on without an answer; nobody has to do anything. 217 Throttled { why: String, retry_in: f64 }, 218 /// The vendor said the account has no credit. Never clears by waiting - 219 /// the only hard stop on total spend (there is no self-imposed lifetime 220 /// cap; see `Guards`'s own doc comment). 221 OutOfCredit(String), 222 /// The ledger could not be read or written, so nothing is admitted: a 223 /// guard that cannot see the spend fails closed. 224 Ledger(String), 225} 226 227impl std::fmt::Display for Refusal { 228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 229 match self { 230 Self::Throttled { why, retry_in } => write!(f, "{why}; clears in {retry_in:.0}s"), 231 Self::OutOfCredit(why) => f.write_str(why), 232 Self::Ledger(why) => write!(f, "the spend ledger: {why}"), 233 } 234 } 235} 236 237impl Window { 238 /// Read the lines, as of `now`. Pure: the tests hand it any clock. 239 fn of(lines: Vec<Line>, now: f64) -> Self { 240 let mut holds: HashMap<String, Hold> = HashMap::new(); 241 let mut spends = Vec::new(); 242 for line in lines { 243 match line { 244 Line::Hold(hold) => { 245 holds.insert(hold.hold_id.clone(), hold); 246 } 247 Line::Spend(entry) => spends.push(entry), 248 } 249 } 250 let mut window = Self::default(); 251 let mut questions = Vec::new(); 252 let mut booked = Vec::new(); 253 for entry in spends { 254 window.lifetime_usd += entry.cost_usd; 255 if entry.estimated { 256 // Booked spend from before the ledger existed, not activity: 257 // the moment it is written must not look like a burst. 258 continue; 259 } 260 match entry.hold.as_ref().and_then(|id| holds.remove(id)) { 261 // The question was asked when it was admitted. 262 Some(hold) => questions.push(hold.time), 263 // A line from before holds, or one whose hold was a torn write. 264 None => questions.push(entry.time), 265 } 266 booked.push((entry.time, entry.cost_usd)); 267 } 268 for hold in holds.into_values() { 269 window.unsettled += 1; 270 window.lifetime_usd += hold.held_usd; 271 questions.push(hold.time); 272 booked.push((hold.time, hold.held_usd)); 273 } 274 window.minute = questions.into_iter().filter(|t| now - t < MINUTE).collect(); 275 window.minute.sort_by(f64::total_cmp); 276 window.hour = booked.into_iter().filter(|(t, _)| now - t < HOUR).collect(); 277 window.hour.sort_by(|a, b| a.0.total_cmp(&b.0)); 278 window 279 } 280 281 pub fn dollars_last_hour(&self) -> f64 { 282 // `.max(0.0)` is not a clamp against a real negative - every addend is 283 // a non-negative cost, so the sum cannot be negative - it is here 284 // only to normalise the `-0.0` that `Iterator::sum` returns for an 285 // EMPTY iterator (Rust's chosen additive identity for floats is 286 // negative zero, so no recent spend sums to `-0.0`, not `0.0`), which 287 // otherwise survives to `Status::line()`'s `${:.4}` and prints as 288 // "$-0.0000" - a real, reproduced display bug (2026-09-22), not a 289 // hypothetical one. 290 self.hour.iter().map(|(_, usd)| usd).sum::<f64>().max(0.0) 291 } 292 293 /// Whether a request that could cost up to `worst_case_usd` may go out 294 /// at `now`: the question bucket, then the hour. No lifetime check - the 295 /// caller (`Ledger::admit`) has already refused before this runs if the 296 /// vendor said the account is out of credit, which is the only hard stop 297 /// on total spend. 298 pub fn verdict(&self, guards: &Guards, now: f64, worst_case_usd: f64) -> Result<(), Refusal> { 299 if let Some(retry_in) = self.bucket_empty_for(guards, now) { 300 return Err(Refusal::Throttled { 301 why: format!( 302 "{} questions in the last minute (bucket of {})", 303 self.minute.len(), 304 guards.max_questions_per_minute 305 ), 306 retry_in, 307 }); 308 } 309 if let Some(retry_in) = self.hour_closed_for(guards, now, worst_case_usd) { 310 return Err(Refusal::Throttled { 311 why: format!( 312 "${:.4} spent in the last hour (limit ${:.2})", 313 self.dollars_last_hour(), 314 guards.max_dollars_per_hour 315 ), 316 retry_in, 317 }); 318 } 319 Ok(()) 320 } 321 322 /// Seconds until the question bucket has a token again, or `None` if it 323 /// has one now. The token spent at `minute[len - max]` is the one whose 324 /// return brings the count below the limit. 325 pub fn bucket_empty_for(&self, guards: &Guards, now: f64) -> Option<f64> { 326 let max = guards.max_questions_per_minute as usize; 327 if self.minute.len() < max { 328 return None; 329 } 330 if max == 0 { 331 return Some(f64::INFINITY); 332 } 333 Some((self.minute[self.minute.len() - max] + MINUTE - now).max(0.0)) 334 } 335 336 /// Seconds until the hour's spend has slid down far enough to admit a 337 /// request costing `worst_case_usd`, or `None` if it would be admitted now. 338 pub fn hour_closed_for(&self, guards: &Guards, now: f64, worst_case_usd: f64) -> Option<f64> { 339 let mut spent = self.dollars_last_hour(); 340 if spent + worst_case_usd <= guards.max_dollars_per_hour { 341 return None; 342 } 343 for (time, usd) in &self.hour { 344 spent -= usd; 345 if spent + worst_case_usd <= guards.max_dollars_per_hour { 346 return Some((time + HOUR - now).max(0.0)); 347 } 348 } 349 // Bigger than an hour's whole allowance on its own: never admitted. 350 Some(f64::INFINITY) 351 } 352} 353 354/// Everything the Bot panel and the `budget` MCP tool show, as of `now`. 355#[derive(Debug, Clone, Serialize)] 356pub struct Status { 357 pub now: f64, 358 pub guards: Guards, 359 /// Spent so far, lifetime - plainly, with no cap and no denominator (the 360 /// user's own ruling, 2026-09-22: no self-imposed number to show it 361 /// against). Booked from this ledger's own recorded token counts plus 362 /// one guessed opening line for whatever predates it - see 363 /// `OPENING_ESTIMATE_USD`'s own doc comment for why, and why it stays 364 /// approximate. 365 pub lifetime_usd: f64, 366 pub questions_last_minute: u32, 367 /// Tokens in the question bucket: questions that could go out right now. 368 pub questions_available: u32, 369 /// Seconds until the next token returns, when the bucket is empty. 370 pub throttled_for_s: Option<f64>, 371 pub dollars_last_hour: f64, 372 /// Seconds until an ordinary question would clear the hour breaker, when 373 /// it is closed. "Ordinary" is a thousand-token question, about $0.00004. 374 pub hour_breaker_closed_for_s: Option<f64>, 375 pub unsettled_holds: u32, 376 /// The vendor's own word that the account is empty, if it has said so. 377 pub out_of_credit: Option<NoCredit>, 378} 379 380/// A thousand input tokens: what a typical question costs, for [`Status`]. 381const TYPICAL_USD: f64 = 1_000.0 / 1e6 * jev_protocol::DOLLARS_PER_MTOK; 382 383impl Status { 384 /// One line for a panel: no cap, no denominator, just what has actually 385 /// been spent (the user's own ruling, 2026-09-22 - see `Guards`'s own 386 /// doc comment) - marked "estimated" because it is booked from our own 387 /// recorded token counts plus a pre-ledger guess 388 /// (`OPENING_ESTIMATE_USD`'s own doc comment), never a vendor-confirmed 389 /// number: TypeSafe exposes no usage/balance/credit endpoint to confirm 390 /// it against (`../README.md` / `research/jev-api-digest.md` §10, 391 /// checked directly 2026-09-22). 392 pub fn line(&self) -> String { 393 let g = &self.guards; 394 let mut line = format!( 395 "{}/{} questions in the last minute, ${:.4}/${:.2} in the last hour, ~${:.4} spent lifetime (estimated)", 396 self.questions_last_minute, 397 g.max_questions_per_minute, 398 self.dollars_last_hour, 399 g.max_dollars_per_hour, 400 self.lifetime_usd, 401 ); 402 if self.unsettled_holds > 0 { 403 line.push_str(&format!(", {} in flight", self.unsettled_holds)); 404 } 405 line 406 } 407 408 /// Whether questions are stopped by something waiting will not clear: 409 /// the vendor's own out-of-credit answer, the only hard stop on total 410 /// spend (there is no self-imposed lifetime cap). 411 pub fn stopped_for_good(&self) -> bool { 412 self.out_of_credit.is_some() 413 } 414 415 /// What is stopping questions right now, in words, or `None` if nothing. 416 pub fn stopped(&self) -> Option<String> { 417 if let Some(no) = &self.out_of_credit { 418 return Some(format!("out of credit (API) - {} - top up, then Ledger::credit_restored", no.reason)); 419 } 420 if let Some(s) = self.hour_breaker_closed_for_s { 421 return Some(format!("hour breaker closed, reopens by itself in {s:.0}s; the bot picks its own goals")); 422 } 423 self.throttled_for_s.map(|s| { 424 format!("throttled: bucket empty, next question in {s:.0}s; the bot picks its own goals") 425 }) 426 } 427} 428 429/// The vendor said the account has no credit. 430#[derive(Debug, Clone, Serialize, Deserialize)] 431pub struct NoCredit { 432 pub reason: String, 433 pub since: f64, 434} 435 436/// The shared spend record. Cheap to construct - it is a directory path and 437/// nothing held open - so a caller reopens it whenever, rather than keeping 438/// one alive across a restart. 439#[derive(Debug, Clone)] 440pub struct Ledger { 441 dir: PathBuf, 442} 443 444impl Ledger { 445 /// `$XDG_STATE_HOME/jev` or `~/.local/state/jev`, created if it does not 446 /// exist yet, with `spend.jsonl` seeded with the opening estimate if this 447 /// is the very first time anything here has opened it. 448 pub fn open() -> Result<Self, String> { 449 Self::at(state_dir()?) 450 } 451 452 /// Open a ledger at a specific directory. [`Self::open`] is this with the 453 /// environment already resolved; tests point it at a private directory 454 /// instead of the real `$XDG_STATE_HOME/jev`, which every test in the 455 /// process shares. 456 pub fn at(dir: PathBuf) -> Result<Self, String> { 457 std::fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?; 458 let ledger = Self { dir }; 459 ledger.seed_if_new()?; 460 Ok(ledger) 461 } 462 463 pub fn path(&self) -> &Path { 464 &self.dir 465 } 466 467 fn spend_path(&self) -> PathBuf { 468 self.dir.join("spend.jsonl") 469 } 470 471 fn no_credit_path(&self) -> PathBuf { 472 self.dir.join("out-of-credit.json") 473 } 474 475 fn rate_limit_path(&self) -> PathBuf { 476 self.dir.join("last-rate-limit.json") 477 } 478 479 /// Write the one opening line, but only the first time this ledger is 480 /// ever created anywhere. `create_new` is what makes this race-free 481 /// across two processes starting at once: at most one of them wins the 482 /// create and the other sees `AlreadyExists`, never two opening lines. 483 fn seed_if_new(&self) -> Result<(), String> { 484 let path = self.spend_path(); 485 match OpenOptions::new().write(true).create_new(true).open(&path) { 486 Ok(mut file) => { 487 let seed = Entry { 488 time: now(), 489 binary: "ledger".to_owned(), 490 input_tokens: 0, 491 output_tokens: 0, 492 cost_usd: OPENING_ESTIMATE_USD, 493 request_id: None, 494 estimated: true, 495 reason: Some(OPENING_REASON.to_owned()), 496 hold: None, 497 }; 498 writeln!(file, "{}", to_line(&seed)?).map_err(|e| format!("seeding {}: {e}", path.display())) 499 } 500 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), 501 Err(e) => Err(format!("opening {}: {e}", path.display())), 502 } 503 } 504 505 /// The recent past as of `now`, read fresh from the file. 506 pub fn window(&self, now: f64) -> Result<Window, String> { 507 with_lock(&self.spend_path(), |file| Ok(Window::of(read_lines(file)?, now))) 508 } 509 510 /// What the guards say as of `now`. 511 pub fn status(&self, now: f64, guards: Guards) -> Result<Status, String> { 512 let window = self.window(now)?; 513 let questions = window.minute.len() as u32; 514 Ok(Status { 515 now, 516 guards, 517 lifetime_usd: window.lifetime_usd, 518 questions_last_minute: questions, 519 questions_available: guards.max_questions_per_minute.saturating_sub(questions), 520 throttled_for_s: window.bucket_empty_for(&guards, now), 521 dollars_last_hour: window.dollars_last_hour(), 522 hour_breaker_closed_for_s: window.hour_closed_for(&guards, now, TYPICAL_USD), 523 unsettled_holds: window.unsettled, 524 out_of_credit: self.out_of_credit()?, 525 }) 526 } 527 528 /// Check a request against every guard and, if it passes, write its hold 529 /// - all under one lock, so no other process can be admitted between this 530 /// one's check and its hold. `now` is the caller's clock. 531 pub fn admit( 532 &self, 533 now: f64, 534 guards: &Guards, 535 worst_case_usd: f64, 536 binary: &str, 537 reason: &str, 538 ) -> Result<Hold, Refusal> { 539 if let Some(no) = self.out_of_credit().map_err(Refusal::Ledger)? { 540 return Err(Refusal::OutOfCredit(format!("the vendor reported no credit: {}", no.reason))); 541 } 542 with_lock(&self.spend_path(), |file| { 543 let window = Window::of(read_lines(file)?, now); 544 if let Err(refusal) = window.verdict(guards, now, worst_case_usd) { 545 return Ok(Err(refusal)); 546 } 547 let hold = Hold { 548 time: now, 549 binary: binary.to_owned(), 550 hold_id: hold_id(now), 551 held_usd: worst_case_usd, 552 reason: reason.to_owned(), 553 }; 554 file.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?; 555 writeln!(file, "{}", to_line(&hold)?).map_err(|e| e.to_string())?; 556 Ok(Ok(hold)) 557 }) 558 .map_err(Refusal::Ledger)? 559 } 560 561 /// Record what an admitted request actually cost, once it is known. 562 pub fn settle(&self, hold: &Hold, mut entry: Entry) -> Result<(), String> { 563 entry.hold = Some(hold.hold_id.clone()); 564 self.append(&entry) 565 } 566 567 /// An admitted request that got no answer, so cost nothing: it still 568 /// counts as a question at the moment it was admitted. 569 pub fn release(&self, hold: &Hold, now: f64, why: &str) -> Result<(), String> { 570 self.settle( 571 hold, 572 Entry { 573 time: now, 574 binary: hold.binary.clone(), 575 input_tokens: 0, 576 output_tokens: 0, 577 cost_usd: 0.0, 578 request_id: None, 579 estimated: false, 580 reason: Some(format!("not answered: {why}")), 581 hold: None, 582 }, 583 ) 584 } 585 586 /// Append one spend line as it is. 587 pub fn append(&self, entry: &Entry) -> Result<(), String> { 588 with_lock(&self.spend_path(), |file| { 589 file.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?; 590 writeln!(file, "{}", to_line(entry)?).map_err(|e| e.to_string()) 591 }) 592 } 593 594 /// Whether the vendor has said the account has no credit. 595 pub fn out_of_credit(&self) -> Result<Option<NoCredit>, String> { 596 let path = self.no_credit_path(); 597 match std::fs::read_to_string(&path) { 598 Ok(text) => serde_json::from_str(&text) 599 .map(Some) 600 .map_err(|e| format!("reading {}: {e}", path.display())), 601 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), 602 Err(e) => Err(format!("reading {}: {e}", path.display())), 603 } 604 } 605 606 /// Record the vendor's out-of-credit answer. The first one's reason and 607 /// time are kept. 608 pub fn mark_out_of_credit(&self, reason: impl Into<String>) -> Result<(), String> { 609 if self.out_of_credit()?.is_some() { 610 return Ok(()); 611 } 612 let no = NoCredit { reason: reason.into(), since: now() }; 613 let path = self.no_credit_path(); 614 let text = serde_json::to_string(&no).map_err(|e| e.to_string())?; 615 std::fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display())) 616 } 617 618 /// The account has been topped up. Not an error if it was never marked. 619 pub fn credit_restored(&self) -> Result<(), String> { 620 match std::fs::remove_file(self.no_credit_path()) { 621 Ok(()) => Ok(()), 622 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), 623 Err(e) => Err(e.to_string()), 624 } 625 } 626 627 /// Remember the most recent rate-limit snapshot any process has seen, so 628 /// the `budget` MCP tool can show it without reaching into whichever 629 /// process happened to make the last request. Best-effort: a failure 630 /// here must never fail the request that triggered it, so callers should 631 /// log and carry on rather than propagate this one. 632 pub fn note_rate_limit(&self, snapshot: &crate::RateLimit) -> Result<(), String> { 633 let path = self.rate_limit_path(); 634 let text = serde_json::to_string(snapshot).map_err(|e| e.to_string())?; 635 std::fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display())) 636 } 637 638 pub fn last_rate_limit(&self) -> Option<crate::RateLimit> { 639 std::fs::read_to_string(self.rate_limit_path()) 640 .ok() 641 .and_then(|text| serde_json::from_str(&text).ok()) 642 } 643} 644 645fn hold_id(now: f64) -> String { 646 static NEXT: AtomicU64 = AtomicU64::new(0); 647 format!("{}-{}-{}", std::process::id(), (now * 1e6) as u64, NEXT.fetch_add(1, Ordering::Relaxed)) 648} 649 650fn to_line(value: &impl Serialize) -> Result<String, String> { 651 serde_json::to_string(value).map_err(|e| format!("serializing a ledger line: {e}")) 652} 653 654/// Every well-formed line. A line that fails to parse - a torn 655/// write from a process killed mid-append - is skipped rather than failing 656/// the whole read: the ledger's job is to bound spend, and one unreadable 657/// line must not make every future request refuse to send by making the 658/// total unreadable. 659fn read_lines(file: &mut File) -> Result<Vec<Line>, String> { 660 file.seek(SeekFrom::Start(0)).map_err(|e| e.to_string())?; 661 let mut text = String::new(); 662 file.read_to_string(&mut text).map_err(|e| e.to_string())?; 663 Ok(text 664 .lines() 665 .filter(|line| !line.trim().is_empty()) 666 .filter_map(|line| match serde_json::from_str(line) { 667 Ok(entry) => Some(entry), 668 Err(e) => { 669 eprintln!("jev ledger: skipping an unreadable line: {e}"); 670 None 671 } 672 }) 673 .collect()) 674} 675 676/// Hold an exclusive `flock` on `path` for the duration of `f`. The lock is 677/// released when `file` drops at the end of this function, which is also 678/// what happens if the process is killed with the lock held - `flock` is 679/// tied to the open file descriptor, not written into the file itself, so 680/// there is nothing on disk for a crash to leave stuck. 681fn with_lock<T>(path: &Path, f: impl FnOnce(&mut File) -> Result<T, String>) -> Result<T, String> { 682 let mut file = OpenOptions::new() 683 .create(true) 684 .read(true) 685 .write(true) 686 .open(path) 687 .map_err(|e| format!("opening {}: {e}", path.display()))?; 688 // SAFETY: `fd` is a valid, open file descriptor owned by `file` for the 689 // whole call; `LOCK_EX` blocks until any other holder releases it rather 690 // than failing, so there is no error path here to leave unlocked. 691 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 { 692 return Err(format!("locking {}: {}", path.display(), std::io::Error::last_os_error())); 693 } 694 let result = f(&mut file); 695 // SAFETY: same fd, still open; unlocking a lock we hold cannot fail in a 696 // way worth propagating, and the fd closing at the end of this function 697 // would release it anyway. 698 let _ = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) }; 699 result 700} 701 702fn state_dir() -> Result<PathBuf, String> { 703 if let Ok(xdg) = std::env::var("XDG_STATE_HOME") 704 && !xdg.trim().is_empty() 705 { 706 return Ok(PathBuf::from(xdg).join("jev")); 707 } 708 let home = std::env::var("HOME") 709 .map_err(|_| "neither XDG_STATE_HOME nor HOME is set".to_owned())?; 710 Ok(PathBuf::from(home).join(".local/state/jev")) 711} 712 713#[cfg(test)] 714mod tests { 715 use super::*; 716 717 /// Every test gets its own directory rather than the real 718 /// `$XDG_STATE_HOME/jev` - the ledger is process-global by design, and 719 /// `buck2` runs `#[test]`s concurrently in one process, so sharing the 720 /// real path (or mutating the environment, which every thread shares) 721 /// would make one test's spend count toward another's assertions. 722 fn sandbox() -> Ledger { 723 let dir = std::env::temp_dir() 724 .join(format!("jev-ledger-test-{}", std::process::id())) 725 .join(uniqueish()); 726 Ledger::at(dir).expect("opening a fresh ledger") 727 } 728 729 fn uniqueish() -> String { 730 static NEXT: AtomicU64 = AtomicU64::new(0); 731 format!("{}-{}", now(), NEXT.fetch_add(1, Ordering::Relaxed)) 732 } 733 734 fn spend(time: f64, cost_usd: f64) -> Entry { 735 Entry { 736 time, 737 binary: "test".into(), 738 input_tokens: 0, 739 output_tokens: 0, 740 cost_usd, 741 request_id: None, 742 estimated: false, 743 reason: None, 744 hold: None, 745 } 746 } 747 748 const GUARDS: Guards = Guards { max_questions_per_minute: 30, max_dollars_per_hour: 0.25 }; 749 750 /// The fake clock starts two hours after the ledger's own seed line, so 751 /// the opening estimate is out of every window, on a whole second so 752 /// that its times survive the trip through JSON exactly. 753 fn t0() -> f64 { 754 (now() + 2.0 * HOUR).floor() 755 } 756 757 fn ask(ledger: &Ledger, at: f64) -> Result<Hold, Refusal> { 758 ledger.admit(at, &GUARDS, 0.00005, "test", "a test question") 759 } 760 761 #[test] 762 fn a_new_ledger_seeds_the_opening_estimate_once() { 763 let ledger = sandbox(); 764 let lifetime = ledger.window(t0()).expect("window").lifetime_usd; 765 assert!((lifetime - OPENING_ESTIMATE_USD).abs() < 1e-9); 766 Ledger::at(ledger.path().to_path_buf()).expect("opening the same ledger again"); 767 let lifetime = ledger.window(t0()).expect("window").lifetime_usd; 768 assert!((lifetime - OPENING_ESTIMATE_USD).abs() < 1e-9, "seeded twice"); 769 } 770 771 #[test] 772 fn only_recent_lines_count_toward_the_windows() { 773 let ledger = sandbox(); 774 let at = t0(); 775 ledger.append(&spend(at - 5_400.0, 10.0)).expect("appending"); 776 ledger.append(&spend(at - 600.0, 0.05)).expect("appending"); 777 let window = ledger.window(at).expect("window"); 778 assert_eq!(window.minute.len(), 0); 779 assert!((window.dollars_last_hour() - 0.05).abs() < 1e-9, "the older line must not count"); 780 assert!(window.lifetime_usd > 10.0, "but it is still spent"); 781 } 782 783 /// Real, observed 2026-09-22 (`budget`, live window): with nothing spent 784 /// in the last hour, `dollars_last_hour()` is a sum over zero elements, 785 /// and Rust's chosen additive identity for floats is negative zero - so 786 /// the unclamped sum was `-0.0`, and `Status::line()` printed 787 /// "$-0.0000/$0.25 in the last hour". `-0.0 == 0.0` is true, which is 788 /// exactly why an assertion against `0.0` alone would not have caught 789 /// this - the sign bit has to be checked directly. 790 #[test] 791 fn an_empty_hour_is_positive_zero_not_negative_zero() { 792 let ledger = sandbox(); 793 let dollars = ledger.window(t0()).expect("window").dollars_last_hour(); 794 assert_eq!(dollars, 0.0); 795 assert!(!dollars.is_sign_negative(), "{dollars} must not print as \"-0.0000\""); 796 assert_eq!(format!("{dollars:.4}"), "0.0000"); 797 } 798 799 /// The trip of 2026-09-21, replayed: more questions than the bucket 800 /// holds are refused, nothing is written that anyone has to delete, and 801 /// asking works again as the minute slides. 802 #[test] 803 fn the_question_bucket_throttles_then_heals_by_itself() { 804 let ledger = sandbox(); 805 let at = t0(); 806 for i in 0..30 { 807 ask(&ledger, at + f64::from(i)).expect("the first thirty are admitted"); 808 } 809 let refused = ask(&ledger, at + 30.0).expect_err("the thirty-first is not"); 810 let Refusal::Throttled { retry_in, .. } = refused else { panic!("{refused:?}") }; 811 // The oldest token (spent at `at`) comes back at `at + 60`. 812 assert!((retry_in - 30.0).abs() < 1e-6, "{retry_in}"); 813 assert!(ask(&ledger, at + 59.9).is_err(), "not before the minute is up"); 814 let status = ledger.status(at + 45.0, GUARDS).expect("status"); 815 assert_eq!(status.questions_available, 0); 816 assert!(status.stopped().expect("throttled").starts_with("throttled"), "{status:?}"); 817 // No pause file, nothing to clear: the window slides and it heals. 818 let files: Vec<_> = std::fs::read_dir(ledger.path()) 819 .expect("dir") 820 .map(|e| e.expect("entry").file_name()) 821 .collect(); 822 assert_eq!(files, vec![std::ffi::OsString::from("spend.jsonl")]); 823 ask(&ledger, at + 60.0).expect("a minute on, the first token is back"); 824 assert!(ask(&ledger, at + 60.5).is_err(), "and only the one"); 825 ask(&ledger, at + 61.0).expect("the second comes back a second later"); 826 let later = ledger.status(at + 200.0, GUARDS).expect("status"); 827 assert_eq!(later.questions_available, 30); 828 assert!(later.stopped().is_none(), "{later:?}"); 829 } 830 831 #[test] 832 fn the_hour_breaker_is_a_hard_stop_that_reopens_as_the_hour_slides() { 833 let ledger = sandbox(); 834 let at = t0(); 835 ledger.append(&spend(at, 0.20)).expect("appending"); 836 ledger.append(&spend(at + 600.0, 0.05)).expect("appending"); 837 let refused = ask(&ledger, at + 700.0).expect_err("$0.25 already spent this hour"); 838 let Refusal::Throttled { retry_in, why } = refused else { panic!("{refused:?}") }; 839 assert!(why.contains("last hour"), "{why}"); 840 // Only once the $0.20 line is an hour old does a question fit again. 841 assert!((retry_in - (HOUR - 700.0)).abs() < 1e-6, "{retry_in}"); 842 assert!(ask(&ledger, at + HOUR - 1.0).is_err()); 843 ask(&ledger, at + HOUR + 1.0).expect("reopened by itself"); 844 } 845 846 /// There is no lifetime cap (removed 2026-09-22, the user's own ruling): 847 /// spending far past what the old $4 constant would have allowed is 848 /// admitted fine, as long as the rate guards are clear. 849 #[test] 850 fn spending_past_the_old_four_dollar_figure_is_not_refused() { 851 let ledger = sandbox(); 852 let at = t0(); 853 ledger.append(&spend(at - 3.0 * HOUR, 40.0)).expect("appending"); 854 ask(&ledger, at).expect("no lifetime cap to hit"); 855 let window = ledger.window(at).expect("window"); 856 assert!(window.lifetime_usd > 40.0, "{}", window.lifetime_usd); 857 } 858 859 #[test] 860 fn a_hold_counts_its_worst_case_until_settled_then_the_real_cost() { 861 let ledger = sandbox(); 862 let at = t0(); 863 let hold = ledger.admit(at, &GUARDS, 0.01, "test", "why").expect("admitted"); 864 let window = ledger.window(at).expect("window"); 865 assert_eq!(window.unsettled, 1); 866 assert!((window.dollars_last_hour() - 0.01).abs() < 1e-12); 867 ledger.settle(&hold, spend(at + 0.2, 0.00002)).expect("settling"); 868 let window = ledger.window(at + 1.0).expect("window"); 869 assert_eq!(window.unsettled, 0); 870 assert_eq!(window.minute, vec![at], "one question, when it was admitted"); 871 assert!((window.dollars_last_hour() - 0.00002).abs() < 1e-12); 872 let other = ask(&ledger, at + 2.0).expect("admitted"); 873 ledger.release(&other, at + 3.0, "timed out").expect("releasing"); 874 let window = ledger.window(at + 4.0).expect("window"); 875 assert_eq!(window.minute.len(), 2, "an unanswered question is still a question"); 876 assert_eq!(window.unsettled, 0); 877 } 878 879 /// Two processes are two `Ledger`s on one directory. Asking from both at 880 /// once, hard, admits exactly the bucket's worth between them: the check 881 /// and the hold are one locked step, so neither slips in between the 882 /// other's. 883 #[test] 884 fn two_processes_share_one_bucket() { 885 let first = sandbox(); 886 let second = Ledger::at(first.path().to_path_buf()).expect("the second process's handle"); 887 let at = t0(); 888 let threads: Vec<_> = [first, second] 889 .into_iter() 890 .map(|ledger| std::thread::spawn(move || (0..40).filter(|_| ask(&ledger, at).is_ok()).count())) 891 .collect(); 892 let admitted: usize = threads.into_iter().map(|t| t.join().expect("thread")).sum(); 893 assert_eq!(admitted, 30); 894 } 895 896 #[test] 897 fn out_of_credit_holds_until_credit_is_restored() { 898 let ledger = sandbox(); 899 ledger.mark_out_of_credit("first").expect("marking"); 900 ledger.mark_out_of_credit("second").expect("marking again"); 901 assert_eq!(ledger.out_of_credit().expect("reading").expect("marked").reason, "first"); 902 assert!(matches!(ask(&ledger, t0()), Err(Refusal::OutOfCredit(_)))); 903 ledger.credit_restored().expect("restoring"); 904 ask(&ledger, t0()).expect("asking again"); 905 ledger.credit_restored().expect("restoring twice is not an error"); 906 } 907 908 #[test] 909 fn lines_from_before_holds_still_read() { 910 let old = r#"{"time":1790024291.15,"binary":"native","input_tokens":521,"output_tokens":31,"cost_usd":0.000021882,"request_id":"req_x","estimated":false,"reason":"zbanks goal choice"}"#; 911 let line: Line = serde_json::from_str(old).expect("an old line"); 912 assert!(matches!(line, Line::Spend(Entry { hold: None, .. }))); 913 } 914}