1use std::collections::VecDeque;
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use parking_lot::Mutex;
14
15use crate::provider::Usage;
16use crate::registry::{BackendKind, Task};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum CallOutcome {
21 Success,
23 Failure(String),
25}
26
27impl CallOutcome {
28 #[must_use]
30 pub fn status(&self) -> &'static str {
31 match self {
32 CallOutcome::Success => "ok",
33 CallOutcome::Failure(_) => "error",
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct AiCallRecord {
41 pub timestamp_ms: i64,
43 pub model: String,
45 pub provider: &'static str,
47 pub task: Task,
49 pub kind: BackendKind,
51 pub batch_size: u32,
53 pub usage: Usage,
55 pub latency_ms: u64,
57 pub outcome: CallOutcome,
59}
60
61#[derive(Debug)]
63pub struct AiCallLog {
64 records: Mutex<VecDeque<AiCallRecord>>,
65 capacity: usize,
66 recorded: AtomicU64,
67}
68
69impl AiCallLog {
70 #[must_use]
72 pub fn new(capacity: usize) -> Self {
73 Self {
74 records: Mutex::new(VecDeque::with_capacity(capacity.min(1024))),
75 capacity: capacity.max(1),
76 recorded: AtomicU64::new(0),
77 }
78 }
79
80 #[must_use]
82 pub fn with_defaults() -> Self {
83 Self::new(10_000)
84 }
85
86 pub fn record(&self, record: AiCallRecord) {
88 let mut records = self.records.lock();
89 if records.len() >= self.capacity {
90 records.pop_front();
91 }
92 records.push_back(record);
93 self.recorded.fetch_add(1, Ordering::Relaxed);
94 }
95
96 #[must_use]
98 pub fn snapshot(&self) -> Vec<AiCallRecord> {
99 self.records.lock().iter().cloned().collect()
100 }
101
102 #[must_use]
104 pub fn len(&self) -> usize {
105 self.records.lock().len()
106 }
107
108 #[must_use]
110 pub fn is_empty(&self) -> bool {
111 self.records.lock().is_empty()
112 }
113
114 #[must_use]
116 pub fn total_recorded(&self) -> u64 {
117 self.recorded.load(Ordering::Relaxed)
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn record(model: &str, ts: i64, outcome: CallOutcome) -> AiCallRecord {
126 AiCallRecord {
127 timestamp_ms: ts,
128 model: model.to_string(),
129 provider: "anthropic",
130 task: Task::Classify,
131 kind: BackendKind::Remote,
132 batch_size: 4,
133 usage: Usage {
134 input_tokens: 10,
135 output_tokens: 2,
136 cost_micros: 5,
137 },
138 latency_ms: 42,
139 outcome,
140 }
141 }
142
143 #[test]
144 fn records_and_snapshots_in_order() {
145 let log = AiCallLog::with_defaults();
146 assert!(log.is_empty());
147 log.record(record("haiku", 1, CallOutcome::Success));
148 log.record(record("haiku", 2, CallOutcome::Failure("timeout".into())));
149 let snap = log.snapshot();
150 assert_eq!(snap.len(), 2);
151 assert_eq!(snap[0].timestamp_ms, 1);
152 assert_eq!(snap[1].outcome, CallOutcome::Failure("timeout".into()));
153 assert_eq!(log.total_recorded(), 2);
154 }
155
156 #[test]
157 fn evicts_oldest_when_full() {
158 let log = AiCallLog::new(2);
159 log.record(record("m", 1, CallOutcome::Success));
160 log.record(record("m", 2, CallOutcome::Success));
161 log.record(record("m", 3, CallOutcome::Success));
162 let snap = log.snapshot();
163 assert_eq!(snap.len(), 2);
164 assert_eq!(snap[0].timestamp_ms, 2, "oldest dropped");
165 assert_eq!(snap[1].timestamp_ms, 3);
166 assert_eq!(log.total_recorded(), 3, "monotonic count survives eviction");
167 }
168}