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, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum TurnEndReason {
47 Completed,
49 NarrationCapExhausted,
52 NarrationFinalRound,
55 RoundCap,
57 Empty,
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63pub struct TurnMetrics {
64 pub elapsed_ms: u64,
67
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub usage: Option<TokenUsage>,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub cost_usd: Option<f64>,
75
76 pub model_id: String,
78
79 pub endpoint: String,
81
82 #[serde(default, skip_serializing_if = "is_zero_u32")]
86 pub hallucinations: u32,
87
88 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub end_reason: Option<TurnEndReason>,
92}
93
94fn is_zero_u32(n: &u32) -> bool {
95 *n == 0
96}
97
98impl TurnMetrics {
99 pub fn display_line(&self) -> String {
106 let elapsed = if self.elapsed_ms >= 1000 {
107 format!("{:.1}s", self.elapsed_ms as f64 / 1000.0)
108 } else {
109 format!("{}ms", self.elapsed_ms)
110 };
111
112 let token_part = match self.usage {
113 Some(u) => format!(
114 "{} in / {} out",
115 fmt_count(u.input_tokens),
116 fmt_count(u.output_tokens)
117 ),
118 None => "(tokens unavailable)".into(),
119 };
120
121 let cost_part = match self.cost_usd {
122 Some(c) if c < f64::EPSILON => "free (local)".into(),
123 Some(c) if c < 0.001 => format!("~${c:.5}"),
124 Some(c) if c < 0.01 => format!("~${c:.4}"),
125 Some(c) => format!("~${c:.4}"),
126 None if self.usage.is_some() => "free (local)".into(),
127 None => String::new(),
128 };
129
130 let base = if cost_part.is_empty() {
131 format!("{elapsed} · {token_part}")
132 } else {
133 format!("{elapsed} · {token_part} · {cost_part}")
134 };
135 let base = if self.hallucinations > 0 {
136 format!(
137 "{base} · ⚠ {} hallucination(s) corrected",
138 self.hallucinations
139 )
140 } else {
141 base
142 };
143 match self.end_reason {
147 Some(TurnEndReason::NarrationCapExhausted) => {
148 format!("{base} · ⚠ ended on narration (rescue budget spent)")
149 }
150 Some(TurnEndReason::NarrationFinalRound) => {
151 format!("{base} · ⚠ ended on narration (final round)")
152 }
153 Some(TurnEndReason::RoundCap) => format!("{base} · round cap"),
154 Some(TurnEndReason::Empty) => format!("{base} · ⚠ empty response"),
155 Some(TurnEndReason::Completed) | None => base,
156 }
157 }
158
159 pub fn prometheus_labels(&self) -> [(&'static str, String); 2] {
161 [
162 ("model", self.model_id.clone()),
163 ("endpoint", self.endpoint.clone()),
164 ]
165 }
166
167 pub fn append_to_log(&self, path: &std::path::Path) {
171 let _ = append_jsonl(self, path);
172 }
173
174 pub fn append_to_log_with_policy(&self, path: &std::path::Path, policy: &crate::LogConfig) {
177 let _ = append_jsonl(self, path);
178 let _ = rotate_log(path, policy);
179 }
180}
181
182fn fmt_count(n: u32) -> String {
183 let s = n.to_string();
185 let mut out = String::with_capacity(s.len() + s.len() / 3);
186 for (i, c) in s.chars().rev().enumerate() {
187 if i > 0 && i % 3 == 0 {
188 out.push(',');
189 }
190 out.push(c);
191 }
192 out.chars().rev().collect()
193}
194
195fn append_jsonl(metrics: &TurnMetrics, path: &std::path::Path) -> std::io::Result<()> {
196 use std::io::Write as _;
197 if let Some(parent) = path.parent() {
198 std::fs::create_dir_all(parent)?;
199 }
200 let mut line = serde_json::to_string(metrics).map_err(std::io::Error::other)?;
201 line.push('\n');
202 let mut f = std::fs::OpenOptions::new()
203 .create(true)
204 .append(true)
205 .open(path)?;
206 f.write_all(line.as_bytes())
207}
208
209fn rotate_log(path: &std::path::Path, policy: &crate::LogConfig) -> std::io::Result<()> {
218 if policy.max_sessions > 0 {
220 let content = std::fs::read_to_string(path)?;
221 let lines: Vec<&str> = content.lines().collect();
222 if lines.len() > policy.max_sessions {
223 let kept = lines[lines.len() - policy.max_sessions..].join("\n");
224 std::fs::write(path, format!("{kept}\n"))?;
225 }
226 }
228
229 if policy.max_size_mb > 0 {
231 let meta = std::fs::metadata(path)?;
232 let limit_bytes = policy.max_size_mb * 1024 * 1024;
233 if meta.len() > limit_bytes {
234 for i in (1..=policy.keep_rotated).rev() {
236 let older = path.with_extension(format!("jsonl.{i}"));
237 let newer = if i == 1 {
238 path.to_path_buf()
239 } else {
240 path.with_extension(format!("jsonl.{}", i - 1))
241 };
242 if newer.exists() {
243 if i == policy.keep_rotated && older.exists() {
244 let _ = std::fs::remove_file(&older);
245 }
246 let _ = std::fs::rename(&newer, &older);
247 }
248 }
249 std::fs::File::create(path)?;
251 }
252 }
253
254 Ok(())
255}
256
257#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn metrics(elapsed_ms: u64, in_tok: u32, out_tok: u32, cost: Option<f64>) -> TurnMetrics {
266 TurnMetrics {
267 elapsed_ms,
268 usage: Some(TokenUsage {
269 input_tokens: in_tok,
270 output_tokens: out_tok,
271 }),
272 cost_usd: cost,
273 model_id: "gemma4:e2b".into(),
274 endpoint: "http://REDACTED-HOST:11434".into(),
275 ..Default::default()
276 }
277 }
278
279 #[test]
280 fn display_free_local() {
281 let m = metrics(3200, 847, 312, Some(0.0));
282 let line = m.display_line();
283 assert!(line.starts_with("3.2s"), "got: {line}");
284 assert!(line.contains("847"), "got: {line}");
285 assert!(line.contains("312"), "got: {line}");
286 assert!(line.contains("free (local)"), "got: {line}");
287 }
288
289 #[test]
290 fn display_with_cost() {
291 let m = metrics(8700, 1204, 892, Some(0.0041));
292 let line = m.display_line();
293 assert!(line.contains("$0.0041"), "got: {line}");
294 assert!(line.contains("1,204"), "got: {line}");
295 }
296
297 #[test]
298 fn display_tokens_unavailable() {
299 let m = TurnMetrics {
300 elapsed_ms: 5100,
301 usage: None,
302 cost_usd: None,
303 model_id: "gpt-4o".into(),
304 endpoint: "https://api.openai.com".into(),
305 ..Default::default()
306 };
307 let line = m.display_line();
308 assert!(line.contains("tokens unavailable"), "got: {line}");
309 }
310
311 #[test]
312 fn display_end_reason_flags_anomalous_endings() {
313 let mut m = metrics(3200, 100, 50, Some(0.0));
314 assert!(!m.display_line().contains("narration"), "unset stays quiet");
315 m.end_reason = Some(TurnEndReason::Completed);
316 assert!(
317 !m.display_line().contains("narration"),
318 "a normal completion stays quiet"
319 );
320 m.end_reason = Some(TurnEndReason::NarrationCapExhausted);
321 assert!(m
322 .display_line()
323 .contains("ended on narration (rescue budget spent)"));
324 m.end_reason = Some(TurnEndReason::NarrationFinalRound);
325 assert!(m
326 .display_line()
327 .contains("ended on narration (final round)"));
328 m.end_reason = Some(TurnEndReason::RoundCap);
329 assert!(m.display_line().contains("round cap"));
330 m.end_reason = Some(TurnEndReason::Empty);
331 assert!(m.display_line().contains("empty response"));
332 }
333
334 #[test]
335 fn end_reason_serializes_snake_case_and_skips_when_absent() {
336 let mut m = metrics(1, 1, 1, None);
337 let j = serde_json::to_string(&m).unwrap();
338 assert!(!j.contains("end_reason"), "{j}");
339 m.end_reason = Some(TurnEndReason::NarrationCapExhausted);
340 let j = serde_json::to_string(&m).unwrap();
341 assert!(
342 j.contains("\"end_reason\":\"narration_cap_exhausted\""),
343 "{j}"
344 );
345 }
346
347 #[test]
348 fn display_milliseconds_under_one_second() {
349 let m = metrics(850, 100, 50, None);
350 assert!(
351 m.display_line().starts_with("850ms"),
352 "got: {}",
353 m.display_line()
354 );
355 }
356
357 #[test]
358 fn fmt_count_thousands() {
359 assert_eq!(fmt_count(1000), "1,000");
360 assert_eq!(fmt_count(1234567), "1,234,567");
361 assert_eq!(fmt_count(42), "42");
362 }
363
364 #[test]
365 fn token_usage_total() {
366 let u = TokenUsage {
367 input_tokens: 300,
368 output_tokens: 150,
369 };
370 assert_eq!(u.total(), 450);
371 }
372
373 #[test]
374 fn token_usage_saturating_add() {
375 let a = TokenUsage {
376 input_tokens: 100,
377 output_tokens: 50,
378 };
379 let b = TokenUsage {
380 input_tokens: 200,
381 output_tokens: 75,
382 };
383 let sum = a.saturating_add(b);
384 assert_eq!(sum.input_tokens, 300);
385 assert_eq!(sum.output_tokens, 125);
386 let big = TokenUsage {
388 input_tokens: u32::MAX,
389 output_tokens: u32::MAX,
390 };
391 let sat = big.saturating_add(b);
392 assert_eq!(sat.input_tokens, u32::MAX);
393 }
394
395 #[test]
396 fn display_with_hallucinations() {
397 let mut m = metrics(3200, 847, 312, Some(0.0));
398 m.hallucinations = 2;
399 let line = m.display_line();
400 assert!(line.contains("2 hallucination(s) corrected"), "got: {line}");
401 }
402
403 #[test]
404 fn display_no_hallucinations_omits_warning() {
405 let m = metrics(3200, 847, 312, Some(0.0));
406 assert_eq!(m.hallucinations, 0);
407 assert!(
408 !m.display_line().contains("hallucination"),
409 "zero hallucinations must not appear in display"
410 );
411 }
412
413 #[test]
414 fn hallucinations_zero_skipped_in_json() {
415 let m = metrics(1000, 10, 5, Some(0.0));
416 let json = serde_json::to_string(&m).unwrap();
417 assert!(
418 !json.contains("hallucination"),
419 "zero hallucinations must be omitted from JSON"
420 );
421 }
422
423 #[test]
424 fn hallucinations_nonzero_in_json() {
425 let mut m = metrics(1000, 10, 5, Some(0.0));
426 m.hallucinations = 3;
427 let json = serde_json::to_string(&m).unwrap();
428 assert!(json.contains("\"hallucinations\":3"), "got: {json}");
429 }
430
431 #[test]
432 fn append_to_log_creates_file() {
433 let dir = tempfile::tempdir().unwrap();
434 let path = dir.path().join("usage.jsonl");
435 let m = metrics(1000, 10, 5, Some(0.0));
436 m.append_to_log(&path);
437 let content = std::fs::read_to_string(&path).unwrap();
438 assert!(content.contains("gemma4:e2b"));
439 assert!(content.ends_with('\n'));
440 m.append_to_log(&path);
442 let raw = std::fs::read_to_string(&path).unwrap();
443 let lines: Vec<_> = raw.lines().collect();
444 assert_eq!(lines.len(), 2);
445 }
446
447 #[test]
448 fn rotation_session_limit_trims_oldest() {
449 let dir = tempfile::tempdir().unwrap();
450 let path = dir.path().join("usage.jsonl");
451 let m = metrics(1000, 10, 5, Some(0.0));
452 for _ in 0..10 {
454 m.append_to_log(&path);
455 }
456 let policy = crate::LogConfig {
457 max_sessions: 7,
458 ..Default::default()
459 };
460 rotate_log(&path, &policy).unwrap();
461 let n = std::fs::read_to_string(&path).unwrap().lines().count();
462 assert_eq!(n, 7, "should keep exactly max_sessions entries");
463 }
464
465 #[test]
466 fn rotation_session_limit_noop_when_under_cap() {
467 let dir = tempfile::tempdir().unwrap();
468 let path = dir.path().join("usage.jsonl");
469 let m = metrics(1000, 10, 5, Some(0.0));
470 for _ in 0..5 {
471 m.append_to_log(&path);
472 }
473 let policy = crate::LogConfig {
474 max_sessions: 7,
475 ..Default::default()
476 };
477 rotate_log(&path, &policy).unwrap();
478 let lines = std::fs::read_to_string(&path).unwrap().lines().count();
479 assert_eq!(lines, 5, "under cap — no entries should be dropped");
480 }
481
482 #[test]
483 fn rotation_size_limit_renames_and_resets() {
484 let dir = tempfile::tempdir().unwrap();
485 let path = dir.path().join("usage.jsonl");
486 let m = metrics(1000, 10, 5, Some(0.0));
487 m.append_to_log(&path);
489 let policy = crate::LogConfig {
490 max_sessions: 0,
491 max_size_mb: 0, keep_rotated: 2,
493 ..Default::default()
494 };
495 let policy_tiny = crate::LogConfig {
497 max_size_mb: 0, ..policy
499 };
500 rotate_log(&path, &policy_tiny).unwrap();
502 assert!(path.exists(), "file must still exist when size limit is 0");
503 }
504
505 #[test]
506 fn log_config_default_is_7_sessions() {
507 let cfg = crate::LogConfig::default();
508 assert_eq!(cfg.max_sessions, 7);
509 assert_eq!(cfg.max_size_mb, 0);
510 assert_eq!(cfg.max_age_days, 0);
511 assert_eq!(cfg.keep_rotated, 3);
512 }
513}