Skip to main content

tokenmiser_router/
replay.rs

1//! Backs `tokenmiser policy test`: replays a JSONL request log through a
2//! candidate Rhai policy and tallies the projected routing distribution.
3
4use 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    /// Unix-seconds range covered by entries carrying a `ts`.
20    pub time_range: Option<(i64, i64)>,
21}
22
23#[derive(Debug, Clone, Copy, Default)]
24pub struct ReplayFilter {
25    /// Inclusive lower-bound (unix seconds). Entries without `ts` always pass.
26    pub since: Option<i64>,
27    /// Inclusive upper-bound (unix seconds).
28    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        // Apply time filter.
60        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    /// Optional so hand-written fixtures need no timestamp.
100    #[serde(default)]
101    ts: Option<i64>,
102    model: String,
103    /// Scoping tenant, exposed to Rhai scripts as `req.tenant`.
104    #[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        // ts and tenant drive replay output.
181        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}