1use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub struct TokenUsage {
18 pub input_tokens: u32,
20 pub output_tokens: u32,
22}
23
24impl TokenUsage {
25 pub fn total(&self) -> u32 {
26 self.input_tokens.saturating_add(self.output_tokens)
27 }
28
29 pub fn saturating_add(self, other: Self) -> Self {
31 Self {
32 input_tokens: self.input_tokens.saturating_add(other.input_tokens),
33 output_tokens: self.output_tokens.saturating_add(other.output_tokens),
34 }
35 }
36}
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct TurnMetrics {
41 pub elapsed_ms: u64,
44
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub usage: Option<TokenUsage>,
48
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub cost_usd: Option<f64>,
52
53 pub model_id: String,
55
56 pub endpoint: String,
58
59 #[serde(default, skip_serializing_if = "is_zero_u32")]
63 pub hallucinations: u32,
64}
65
66fn is_zero_u32(n: &u32) -> bool {
67 *n == 0
68}
69
70impl TurnMetrics {
71 pub fn display_line(&self) -> String {
78 let elapsed = if self.elapsed_ms >= 1000 {
79 format!("{:.1}s", self.elapsed_ms as f64 / 1000.0)
80 } else {
81 format!("{}ms", self.elapsed_ms)
82 };
83
84 let token_part = match self.usage {
85 Some(u) => format!(
86 "{} in / {} out",
87 fmt_count(u.input_tokens),
88 fmt_count(u.output_tokens)
89 ),
90 None => "(tokens unavailable)".into(),
91 };
92
93 let cost_part = match self.cost_usd {
94 Some(c) if c < f64::EPSILON => "free (local)".into(),
95 Some(c) if c < 0.001 => format!("~${c:.5}"),
96 Some(c) if c < 0.01 => format!("~${c:.4}"),
97 Some(c) => format!("~${c:.4}"),
98 None if self.usage.is_some() => "free (local)".into(),
99 None => String::new(),
100 };
101
102 let base = if cost_part.is_empty() {
103 format!("{elapsed} · {token_part}")
104 } else {
105 format!("{elapsed} · {token_part} · {cost_part}")
106 };
107 if self.hallucinations > 0 {
108 format!(
109 "{base} · ⚠ {} hallucination(s) corrected",
110 self.hallucinations
111 )
112 } else {
113 base
114 }
115 }
116
117 pub fn prometheus_labels(&self) -> [(&'static str, String); 2] {
119 [
120 ("model", self.model_id.clone()),
121 ("endpoint", self.endpoint.clone()),
122 ]
123 }
124
125 pub fn append_to_log(&self, path: &std::path::Path) {
129 let _ = append_jsonl(self, path);
130 }
131
132 pub fn append_to_log_with_policy(&self, path: &std::path::Path, policy: &crate::LogConfig) {
135 let _ = append_jsonl(self, path);
136 let _ = rotate_log(path, policy);
137 }
138}
139
140fn fmt_count(n: u32) -> String {
141 let s = n.to_string();
143 let mut out = String::with_capacity(s.len() + s.len() / 3);
144 for (i, c) in s.chars().rev().enumerate() {
145 if i > 0 && i % 3 == 0 {
146 out.push(',');
147 }
148 out.push(c);
149 }
150 out.chars().rev().collect()
151}
152
153fn append_jsonl(metrics: &TurnMetrics, path: &std::path::Path) -> std::io::Result<()> {
154 use std::io::Write as _;
155 if let Some(parent) = path.parent() {
156 std::fs::create_dir_all(parent)?;
157 }
158 let mut line = serde_json::to_string(metrics).map_err(std::io::Error::other)?;
159 line.push('\n');
160 let mut f = std::fs::OpenOptions::new()
161 .create(true)
162 .append(true)
163 .open(path)?;
164 f.write_all(line.as_bytes())
165}
166
167fn rotate_log(path: &std::path::Path, policy: &crate::LogConfig) -> std::io::Result<()> {
176 if policy.max_sessions > 0 {
178 let content = std::fs::read_to_string(path)?;
179 let lines: Vec<&str> = content.lines().collect();
180 if lines.len() > policy.max_sessions {
181 let kept = lines[lines.len() - policy.max_sessions..].join("\n");
182 std::fs::write(path, format!("{kept}\n"))?;
183 }
184 }
186
187 if policy.max_size_mb > 0 {
189 let meta = std::fs::metadata(path)?;
190 let limit_bytes = policy.max_size_mb * 1024 * 1024;
191 if meta.len() > limit_bytes {
192 for i in (1..=policy.keep_rotated).rev() {
194 let older = path.with_extension(format!("jsonl.{i}"));
195 let newer = if i == 1 {
196 path.to_path_buf()
197 } else {
198 path.with_extension(format!("jsonl.{}", i - 1))
199 };
200 if newer.exists() {
201 if i == policy.keep_rotated && older.exists() {
202 let _ = std::fs::remove_file(&older);
203 }
204 let _ = std::fs::rename(&newer, &older);
205 }
206 }
207 std::fs::File::create(path)?;
209 }
210 }
211
212 Ok(())
213}
214
215#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn metrics(elapsed_ms: u64, in_tok: u32, out_tok: u32, cost: Option<f64>) -> TurnMetrics {
224 TurnMetrics {
225 elapsed_ms,
226 usage: Some(TokenUsage {
227 input_tokens: in_tok,
228 output_tokens: out_tok,
229 }),
230 cost_usd: cost,
231 model_id: "gemma4:e2b".into(),
232 endpoint: "http://REDACTED-HOST:11434".into(),
233 ..Default::default()
234 }
235 }
236
237 #[test]
238 fn display_free_local() {
239 let m = metrics(3200, 847, 312, Some(0.0));
240 let line = m.display_line();
241 assert!(line.starts_with("3.2s"), "got: {line}");
242 assert!(line.contains("847"), "got: {line}");
243 assert!(line.contains("312"), "got: {line}");
244 assert!(line.contains("free (local)"), "got: {line}");
245 }
246
247 #[test]
248 fn display_with_cost() {
249 let m = metrics(8700, 1204, 892, Some(0.0041));
250 let line = m.display_line();
251 assert!(line.contains("$0.0041"), "got: {line}");
252 assert!(line.contains("1,204"), "got: {line}");
253 }
254
255 #[test]
256 fn display_tokens_unavailable() {
257 let m = TurnMetrics {
258 elapsed_ms: 5100,
259 usage: None,
260 cost_usd: None,
261 model_id: "gpt-4o".into(),
262 endpoint: "https://api.openai.com".into(),
263 ..Default::default()
264 };
265 let line = m.display_line();
266 assert!(line.contains("tokens unavailable"), "got: {line}");
267 }
268
269 #[test]
270 fn display_milliseconds_under_one_second() {
271 let m = metrics(850, 100, 50, None);
272 assert!(
273 m.display_line().starts_with("850ms"),
274 "got: {}",
275 m.display_line()
276 );
277 }
278
279 #[test]
280 fn fmt_count_thousands() {
281 assert_eq!(fmt_count(1000), "1,000");
282 assert_eq!(fmt_count(1234567), "1,234,567");
283 assert_eq!(fmt_count(42), "42");
284 }
285
286 #[test]
287 fn token_usage_total() {
288 let u = TokenUsage {
289 input_tokens: 300,
290 output_tokens: 150,
291 };
292 assert_eq!(u.total(), 450);
293 }
294
295 #[test]
296 fn token_usage_saturating_add() {
297 let a = TokenUsage {
298 input_tokens: 100,
299 output_tokens: 50,
300 };
301 let b = TokenUsage {
302 input_tokens: 200,
303 output_tokens: 75,
304 };
305 let sum = a.saturating_add(b);
306 assert_eq!(sum.input_tokens, 300);
307 assert_eq!(sum.output_tokens, 125);
308 let big = TokenUsage {
310 input_tokens: u32::MAX,
311 output_tokens: u32::MAX,
312 };
313 let sat = big.saturating_add(b);
314 assert_eq!(sat.input_tokens, u32::MAX);
315 }
316
317 #[test]
318 fn display_with_hallucinations() {
319 let mut m = metrics(3200, 847, 312, Some(0.0));
320 m.hallucinations = 2;
321 let line = m.display_line();
322 assert!(line.contains("2 hallucination(s) corrected"), "got: {line}");
323 }
324
325 #[test]
326 fn display_no_hallucinations_omits_warning() {
327 let m = metrics(3200, 847, 312, Some(0.0));
328 assert_eq!(m.hallucinations, 0);
329 assert!(
330 !m.display_line().contains("hallucination"),
331 "zero hallucinations must not appear in display"
332 );
333 }
334
335 #[test]
336 fn hallucinations_zero_skipped_in_json() {
337 let m = metrics(1000, 10, 5, Some(0.0));
338 let json = serde_json::to_string(&m).unwrap();
339 assert!(
340 !json.contains("hallucination"),
341 "zero hallucinations must be omitted from JSON"
342 );
343 }
344
345 #[test]
346 fn hallucinations_nonzero_in_json() {
347 let mut m = metrics(1000, 10, 5, Some(0.0));
348 m.hallucinations = 3;
349 let json = serde_json::to_string(&m).unwrap();
350 assert!(json.contains("\"hallucinations\":3"), "got: {json}");
351 }
352
353 #[test]
354 fn append_to_log_creates_file() {
355 let dir = tempfile::tempdir().unwrap();
356 let path = dir.path().join("usage.jsonl");
357 let m = metrics(1000, 10, 5, Some(0.0));
358 m.append_to_log(&path);
359 let content = std::fs::read_to_string(&path).unwrap();
360 assert!(content.contains("gemma4:e2b"));
361 assert!(content.ends_with('\n'));
362 m.append_to_log(&path);
364 let raw = std::fs::read_to_string(&path).unwrap();
365 let lines: Vec<_> = raw.lines().collect();
366 assert_eq!(lines.len(), 2);
367 }
368
369 #[test]
370 fn rotation_session_limit_trims_oldest() {
371 let dir = tempfile::tempdir().unwrap();
372 let path = dir.path().join("usage.jsonl");
373 let m = metrics(1000, 10, 5, Some(0.0));
374 for _ in 0..10 {
376 m.append_to_log(&path);
377 }
378 let policy = crate::LogConfig {
379 max_sessions: 7,
380 ..Default::default()
381 };
382 rotate_log(&path, &policy).unwrap();
383 let n = std::fs::read_to_string(&path).unwrap().lines().count();
384 assert_eq!(n, 7, "should keep exactly max_sessions entries");
385 }
386
387 #[test]
388 fn rotation_session_limit_noop_when_under_cap() {
389 let dir = tempfile::tempdir().unwrap();
390 let path = dir.path().join("usage.jsonl");
391 let m = metrics(1000, 10, 5, Some(0.0));
392 for _ in 0..5 {
393 m.append_to_log(&path);
394 }
395 let policy = crate::LogConfig {
396 max_sessions: 7,
397 ..Default::default()
398 };
399 rotate_log(&path, &policy).unwrap();
400 let lines = std::fs::read_to_string(&path).unwrap().lines().count();
401 assert_eq!(lines, 5, "under cap — no entries should be dropped");
402 }
403
404 #[test]
405 fn rotation_size_limit_renames_and_resets() {
406 let dir = tempfile::tempdir().unwrap();
407 let path = dir.path().join("usage.jsonl");
408 let m = metrics(1000, 10, 5, Some(0.0));
409 m.append_to_log(&path);
411 let policy = crate::LogConfig {
412 max_sessions: 0,
413 max_size_mb: 0, keep_rotated: 2,
415 ..Default::default()
416 };
417 let policy_tiny = crate::LogConfig {
419 max_size_mb: 0, ..policy
421 };
422 rotate_log(&path, &policy_tiny).unwrap();
424 assert!(path.exists(), "file must still exist when size limit is 0");
425 }
426
427 #[test]
428 fn log_config_default_is_7_sessions() {
429 let cfg = crate::LogConfig::default();
430 assert_eq!(cfg.max_sessions, 7);
431 assert_eq!(cfg.max_size_mb, 0);
432 assert_eq!(cfg.max_age_days, 0);
433 assert_eq!(cfg.keep_rotated, 3);
434 }
435}