1use std::collections::HashMap;
5use std::path::Path;
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use tokenmiser_providers::ChatRequest;
10
11use crate::dsl::PolicyEngine;
12
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14pub struct ReplayResult {
15 pub total: u64,
16 pub by_target: HashMap<String, u64>,
17 pub failed: u64,
18 pub by_tenant: HashMap<String, u64>,
19 pub time_range: Option<(i64, i64)>,
21}
22
23#[derive(Debug, Clone, Copy, Default)]
24pub struct ReplayFilter {
25 pub since: Option<i64>,
27 pub until: Option<i64>,
29}
30
31pub fn replay<P: AsRef<Path>>(log_path: P, policy: &PolicyEngine) -> Result<ReplayResult> {
32 replay_filtered(log_path, policy, ReplayFilter::default())
33}
34
35pub fn replay_filtered<P: AsRef<Path>>(
36 log_path: P,
37 policy: &PolicyEngine,
38 filter: ReplayFilter,
39) -> Result<ReplayResult> {
40 let raw = std::fs::read_to_string(&log_path)
41 .with_context(|| format!("read log {}", log_path.as_ref().display()))?;
42
43 let mut out = ReplayResult::default();
44 let mut min_ts: Option<i64> = None;
45 let mut max_ts: Option<i64> = None;
46
47 for (line_no, line) in raw.lines().enumerate() {
48 if line.trim().is_empty() {
49 continue;
50 }
51 let entry: LogEntry = match serde_json::from_str(line) {
52 Ok(e) => e,
53 Err(e) => {
54 tracing::warn!(line = line_no, error = %e, "replay: skip malformed log line");
55 out.failed += 1;
56 continue;
57 }
58 };
59 if let Some(ts) = entry.ts {
61 if let Some(since) = filter.since {
62 if ts < since {
63 continue;
64 }
65 }
66 if let Some(until) = filter.until {
67 if ts > until {
68 continue;
69 }
70 }
71 min_ts = Some(min_ts.map_or(ts, |m| m.min(ts)));
72 max_ts = Some(max_ts.map_or(ts, |m| m.max(ts)));
73 }
74
75 out.total += 1;
76 let tenant = entry.tenant.clone().unwrap_or_else(|| "default".into());
77 *out.by_tenant.entry(tenant.clone()).or_default() += 1;
78
79 let req = entry.into_request();
80 match policy.route(&req, &tenant) {
81 Ok(target) => {
82 let key = format!("{}::{}", target.provider, target.model);
83 *out.by_target.entry(key).or_default() += 1;
84 }
85 Err(e) => {
86 tracing::warn!(line = line_no, error = %e, "replay: policy failed");
87 out.failed += 1;
88 }
89 }
90 }
91 if let (Some(lo), Some(hi)) = (min_ts, max_ts) {
92 out.time_range = Some((lo, hi));
93 }
94 Ok(out)
95}
96
97#[derive(Debug, Deserialize)]
98struct LogEntry {
99 #[serde(default)]
101 ts: Option<i64>,
102 model: String,
103 #[serde(default)]
105 tenant: Option<String>,
106 messages: Vec<serde_json::Value>,
107}
108
109impl LogEntry {
110 fn into_request(self) -> ChatRequest {
111 let messages = self
112 .messages
113 .into_iter()
114 .filter_map(|v| serde_json::from_value(v).ok())
115 .collect();
116 ChatRequest {
117 model: self.model,
118 messages,
119 temperature: None,
120 max_tokens: None,
121 top_p: None,
122 stream: None,
123 extra: Default::default(),
124 }
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use std::io::Write;
132
133 #[test]
134 fn replays_and_tallies() {
135 let mut tmp = tempfile_like();
136 writeln!(
137 tmp.file,
138 r#"{{"ts":1700000000,"tenant":"t-a","model":"auto","messages":[{{"role":"user","content":"hello"}}]}}"#
139 )
140 .unwrap();
141 writeln!(
142 tmp.file,
143 r#"{{"ts":1700000100,"tenant":"t-b","model":"auto","messages":[{{"role":"user","content":"refactor this"}}]}}"#
144 )
145 .unwrap();
146 tmp.file.flush().unwrap();
147
148 let policy_path = std::env::temp_dir().join(format!(
149 "replay-policy-{}.rhai",
150 std::time::SystemTime::now()
151 .duration_since(std::time::UNIX_EPOCH)
152 .unwrap()
153 .as_nanos()
154 ));
155 std::fs::write(
156 &policy_path,
157 r#"
158 fn route(req) {
159 if req.has_keyword("refactor") {
160 return #{ provider: "anthropic", model: "claude-opus-4-7" };
161 }
162 #{ provider: "ollama", model: "ollama:qwen2.5:7b" }
163 }
164 "#,
165 )
166 .unwrap();
167
168 let p = PolicyEngine::load(policy_path.clone()).unwrap();
169 let r = replay(&tmp.path, &p).unwrap();
170 assert_eq!(r.total, 2);
171 assert_eq!(r.failed, 0);
172 assert_eq!(
173 r.by_target.get("ollama::ollama:qwen2.5:7b").copied(),
174 Some(1)
175 );
176 assert_eq!(
177 r.by_target.get("anthropic::claude-opus-4-7").copied(),
178 Some(1)
179 );
180 assert_eq!(r.by_tenant.get("t-a").copied(), Some(1));
182 assert_eq!(r.by_tenant.get("t-b").copied(), Some(1));
183 assert_eq!(r.time_range, Some((1700000000, 1700000100)));
184
185 let _ = std::fs::remove_file(&tmp.path);
186 let _ = std::fs::remove_file(&policy_path);
187 }
188
189 #[test]
190 fn replay_filter_excludes_by_time() {
191 let mut tmp = tempfile_like();
192 writeln!(
193 tmp.file,
194 r#"{{"ts":1000,"model":"auto","messages":[{{"role":"user","content":"a"}}]}}"#
195 )
196 .unwrap();
197 writeln!(
198 tmp.file,
199 r#"{{"ts":2000,"model":"auto","messages":[{{"role":"user","content":"b"}}]}}"#
200 )
201 .unwrap();
202 writeln!(
203 tmp.file,
204 r#"{{"ts":3000,"model":"auto","messages":[{{"role":"user","content":"c"}}]}}"#
205 )
206 .unwrap();
207 tmp.file.flush().unwrap();
208
209 let policy_path = std::env::temp_dir().join(format!(
210 "replay-policy-filter-{}.rhai",
211 std::time::SystemTime::now()
212 .duration_since(std::time::UNIX_EPOCH)
213 .unwrap()
214 .as_nanos()
215 ));
216 std::fs::write(
217 &policy_path,
218 r#"fn route(req) { #{ provider: "x", model: "y" } }"#,
219 )
220 .unwrap();
221 let p = PolicyEngine::load(policy_path.clone()).unwrap();
222
223 let r = replay_filtered(
224 &tmp.path,
225 &p,
226 ReplayFilter {
227 since: Some(1500),
228 until: Some(2500),
229 },
230 )
231 .unwrap();
232 assert_eq!(r.total, 1);
233 assert_eq!(r.time_range, Some((2000, 2000)));
234
235 let _ = std::fs::remove_file(&tmp.path);
236 let _ = std::fs::remove_file(&policy_path);
237 }
238
239 struct TmpFile {
240 file: std::fs::File,
241 path: std::path::PathBuf,
242 }
243 fn tempfile_like() -> TmpFile {
244 let path = std::env::temp_dir().join(format!(
245 "replay-{}.jsonl",
246 std::time::SystemTime::now()
247 .duration_since(std::time::UNIX_EPOCH)
248 .unwrap()
249 .as_nanos()
250 ));
251 let file = std::fs::File::create(&path).unwrap();
252 TmpFile { file, path }
253 }
254}