use router_protocol::{AttemptId, RequestId};
use std::collections::{BTreeMap, HashMap};
use std::hash::{Hash, Hasher};
use std::time::{Duration, SystemTime};
pub const DEFAULT_USAGE_LEDGER_TTL: Duration = Duration::from_secs(24 * 60 * 60);
pub const DEFAULT_USAGE_LEDGER_MAX_RECORDS: usize = 100_000;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UsageRecord {
pub request_id: RequestId,
pub attempt_id: AttemptId,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
pub recorded_at_unix_seconds: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct InsertOutcome {
pub inserted: bool,
pub deduplicated: bool,
pub conflict: bool,
}
impl InsertOutcome {
pub fn is_new(&self) -> bool {
self.inserted
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct UsageKey {
request_id: RequestId,
attempt_id: AttemptId,
}
impl Hash for UsageKey {
fn hash<H: Hasher>(&self, state: &mut H) {
self.request_id.as_str().hash(state);
self.attempt_id.as_str().hash(state);
}
}
#[derive(Debug)]
pub struct UsageLedger {
records: HashMap<UsageKey, UsageRecord>,
by_time: BTreeMap<u64, Vec<UsageKey>>,
ttl: Duration,
clock: fn() -> u64,
last_emitted_seconds: u64,
max_records: usize,
}
impl Default for UsageLedger {
fn default() -> Self {
Self::with_ttl(DEFAULT_USAGE_LEDGER_TTL)
}
}
impl UsageLedger {
pub fn new() -> Self {
Self::default()
}
pub fn with_ttl(ttl: Duration) -> Self {
Self::with_limits(ttl, unix_now, DEFAULT_USAGE_LEDGER_MAX_RECORDS)
}
#[cfg(test)]
fn with_clock(ttl: Duration, clock: fn() -> u64) -> Self {
Self::with_limits(ttl, clock, DEFAULT_USAGE_LEDGER_MAX_RECORDS)
}
#[cfg(test)]
fn with_clock_and_cap(ttl: Duration, clock: fn() -> u64, max_records: usize) -> Self {
Self::with_limits(ttl, clock, max_records)
}
fn with_limits(ttl: Duration, clock: fn() -> u64, max_records: usize) -> Self {
Self {
records: HashMap::new(),
by_time: BTreeMap::new(),
ttl,
clock,
last_emitted_seconds: 0,
max_records,
}
}
fn now(&mut self) -> u64 {
let now = (self.clock)().max(self.last_emitted_seconds);
self.last_emitted_seconds = now;
now
}
pub fn record(
&mut self,
request_id: RequestId,
attempt_id: AttemptId,
prompt_tokens: u64,
completion_tokens: u64,
total_tokens: u64,
) -> InsertOutcome {
self.sweep_expired();
let key = UsageKey {
request_id: request_id.clone(),
attempt_id: attempt_id.clone(),
};
if let Some(existing) = self.records.get(&key) {
let same_usage = existing.prompt_tokens == prompt_tokens
&& existing.completion_tokens == completion_tokens
&& existing.total_tokens == total_tokens;
return InsertOutcome {
inserted: false,
deduplicated: same_usage,
conflict: !same_usage,
};
}
let recorded_at_unix_seconds = self.now();
self.by_time
.entry(recorded_at_unix_seconds)
.or_default()
.push(key.clone());
self.records.insert(
key,
UsageRecord {
request_id,
attempt_id,
prompt_tokens,
completion_tokens,
total_tokens,
recorded_at_unix_seconds,
},
);
self.enforce_record_cap();
InsertOutcome {
inserted: true,
deduplicated: false,
conflict: false,
}
}
fn enforce_record_cap(&mut self) {
while self.records.len() > self.max_records {
let Some((&bucket, _)) = self.by_time.iter().next() else {
break;
};
let key = {
let keys = self.by_time.get_mut(&bucket).expect("bucket exists");
let key = keys.remove(0);
if keys.is_empty() {
self.by_time.remove(&bucket);
}
key
};
self.records.remove(&key);
}
}
pub fn len(&self) -> usize {
self.records.len()
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
pub fn contains(&self, request_id: &RequestId, attempt_id: &AttemptId) -> bool {
self.records.contains_key(&UsageKey {
request_id: request_id.clone(),
attempt_id: attempt_id.clone(),
})
}
pub fn total_tokens(&self) -> u64 {
self.records
.values()
.map(|record| record.total_tokens)
.sum()
}
pub fn sweep_expired(&mut self) {
let now = self.now();
let ttl_seconds = self.ttl.as_secs();
let cutoff = now.saturating_sub(ttl_seconds);
let expired: Vec<u64> = self
.by_time
.range(..=cutoff)
.map(|(bucket, _)| *bucket)
.collect();
for bucket in expired {
if let Some(keys) = self.by_time.remove(&bucket) {
for key in keys {
self.records.remove(&key);
}
}
}
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
fn request_id(value: &str) -> RequestId {
RequestId::parse(value).expect("fixture request id")
}
fn attempt_id(value: &str) -> AttemptId {
AttemptId::parse(value).expect("fixture attempt id")
}
#[test]
fn same_attempt_is_recorded_at_most_once() {
let mut ledger = UsageLedger::new();
let request = request_id("request-dedupe");
let attempt = attempt_id("attempt-dedupe");
let first = ledger.record(request.clone(), attempt.clone(), 10, 20, 30);
assert!(first.is_new());
assert!(!first.deduplicated);
assert!(!first.conflict);
let second = ledger.record(request.clone(), attempt.clone(), 10, 20, 30);
assert!(!second.is_new());
assert!(second.deduplicated);
assert!(!second.conflict);
let third = ledger.record(request, attempt, 99, 99, 99);
assert!(!third.is_new());
assert!(!third.deduplicated);
assert!(third.conflict);
assert_eq!(ledger.len(), 1);
assert_eq!(ledger.total_tokens(), 30);
}
#[test]
fn backwards_clock_jump_keeps_timestamps_monotonic() {
use std::sync::atomic::{AtomicU64, Ordering};
static TICK: AtomicU64 = AtomicU64::new(1_000_000);
fn clock() -> u64 {
TICK.load(Ordering::Relaxed)
}
TICK.store(1_000_000, Ordering::Relaxed);
let mut ledger = UsageLedger::with_clock(Duration::from_secs(1000), clock);
let request = request_id("request-mono");
ledger.record(request.clone(), attempt_id("attempt-1"), 1, 1, 2);
TICK.store(999_500, Ordering::Relaxed);
ledger.record(request.clone(), attempt_id("attempt-2"), 2, 2, 4);
TICK.store(999_000, Ordering::Relaxed);
ledger.record(request, attempt_id("attempt-3"), 3, 3, 6);
assert_eq!(ledger.len(), 3);
let stamps: Vec<u64> = ledger
.records
.values()
.map(|record| record.recorded_at_unix_seconds)
.collect();
assert!(
stamps.iter().all(|&stamp| stamp == 1_000_000),
"all records pinned to the monotonic floor, got {stamps:?}"
);
ledger.sweep_expired();
assert_eq!(ledger.len(), 3);
}
#[test]
fn record_cap_evicts_oldest_deterministically() {
use std::sync::atomic::{AtomicU64, Ordering};
static TICK: AtomicU64 = AtomicU64::new(1_000_000);
fn clock() -> u64 {
TICK.load(Ordering::Relaxed)
}
TICK.store(1_000_000, Ordering::Relaxed);
let mut ledger = UsageLedger::with_clock_and_cap(Duration::from_secs(1000), clock, 2);
let request = request_id("request-cap");
ledger.record(request.clone(), attempt_id("attempt-1"), 1, 1, 2);
TICK.store(1_000_001, Ordering::Relaxed);
ledger.record(request.clone(), attempt_id("attempt-2"), 2, 2, 4);
assert_eq!(ledger.len(), 2);
TICK.store(1_000_002, Ordering::Relaxed);
ledger.record(request, attempt_id("attempt-3"), 3, 3, 6);
assert_eq!(ledger.len(), 2);
assert!(!ledger.contains(&request_id("request-cap"), &attempt_id("attempt-1")));
assert!(ledger.contains(&request_id("request-cap"), &attempt_id("attempt-2")));
assert!(ledger.contains(&request_id("request-cap"), &attempt_id("attempt-3")));
assert_eq!(ledger.total_tokens(), 10);
}
#[test]
fn distinct_attempts_are_separate_billing_events() {
let mut ledger = UsageLedger::new();
let request = request_id("request-multi");
ledger.record(request.clone(), attempt_id("attempt-1"), 1, 1, 2);
ledger.record(request.clone(), attempt_id("attempt-2"), 2, 2, 4);
ledger.record(request, attempt_id("attempt-3"), 3, 3, 6);
assert_eq!(ledger.len(), 3);
assert_eq!(ledger.total_tokens(), 12);
}
#[test]
fn expired_records_are_swept() {
use std::sync::atomic::{AtomicU64, Ordering};
static TICK: AtomicU64 = AtomicU64::new(1_000_000);
fn clock() -> u64 {
TICK.load(Ordering::Relaxed)
}
TICK.store(1_000_000, Ordering::Relaxed);
let mut ledger = UsageLedger::with_clock(Duration::from_secs(100), clock);
let request = request_id("request-ttl");
ledger.record(request.clone(), attempt_id("attempt-old"), 1, 1, 2);
assert_eq!(ledger.len(), 1);
TICK.store(1_000_200, Ordering::Relaxed);
ledger.record(request.clone(), attempt_id("attempt-new"), 1, 1, 2);
assert_eq!(ledger.len(), 1);
assert!(ledger.contains(&request, &attempt_id("attempt-new")));
assert!(!ledger.contains(&request, &attempt_id("attempt-old")));
ledger.sweep_expired();
assert_eq!(ledger.len(), 1);
}
}