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