use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::canonical::{ChatResponse, Message};
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RouterEvent {
Start {
id: u64,
ts_ms: u64,
model: String,
in_flight: u64,
},
Classified {
id: u64,
ts_ms: u64,
tags: Vec<String>,
},
Routed {
id: u64,
ts_ms: u64,
provider: String,
model: String,
},
Complete {
id: u64,
#[serde(flatten)]
entry: LogEntry,
},
}
#[derive(Serialize, Deserialize)]
pub struct LogEntry {
pub ts_ms: u64,
pub provider: String,
pub requested_model: String,
pub sent_model: String,
pub duration_ms: u64,
pub tags: Vec<String>,
pub plugins: Vec<String>,
pub system: Option<String>,
pub messages: Vec<Message>,
pub response: Option<ChatResponse>,
pub error: Option<String>,
}
impl LogEntry {
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
}
pub struct RequestLogger {
file: Mutex<std::fs::File>,
}
impl RequestLogger {
pub fn new(path: &str) -> anyhow::Result<Self> {
let path = Path::new(path);
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let file = OpenOptions::new().create(true).append(true).open(path)?;
Ok(RequestLogger {
file: Mutex::new(file),
})
}
pub fn log_line(&self, line: &str) {
let mut file = self.file.lock().unwrap_or_else(|e| e.into_inner());
if let Err(err) = writeln!(file, "{line}") {
tracing::warn!("failed to write log entry: {err}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn router_event_complete_roundtrip() {
let json = r#"{"type":"complete","id":0,"ts_ms":1781573245225,"provider":"ollama","requested_model":"llama3.1:8b","sent_model":"llama3.1:8b","duration_ms":5978,"tags":[],"plugins":[],"system":null,"messages":[{"role":"user","content":"hi"}],"response":null,"error":null}"#;
let event: RouterEvent = serde_json::from_str(json).expect("deser failed");
match event {
RouterEvent::Complete { id, entry } => {
assert_eq!(id, 0);
assert_eq!(entry.provider, "ollama");
}
_ => panic!("wrong variant"),
}
}
#[test]
fn router_event_start_roundtrip() {
let json = r#"{"type":"start","id":1,"ts_ms":1781573328452,"model":"llama3.1:8b","in_flight":2}"#;
let event: RouterEvent = serde_json::from_str(json).unwrap();
assert!(matches!(event, RouterEvent::Start { id: 1, .. }));
}
}