1use serde::{Deserialize, Serialize};
28
29use crate::core::config::{RoutingRules, parse_route_target};
30use crate::core::gain::model_pricing::{ModelPricing, PricingMatchKind};
31use crate::core::intent_engine::{classify, route_intent};
32
33use super::suite::EvalSuite;
34
35#[derive(Debug, Clone)]
37pub struct RoutingEvalConfig {
38 pub requested_model: String,
41 pub rules: RoutingRules,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct RoutingTaskRecord {
48 pub task_id: String,
49 pub tier: String,
51 pub serving_model: String,
53 pub routed: bool,
55 pub requested_input_rate: f64,
57 pub serving_input_rate: f64,
59 pub input_rate_saving_per_mtok: f64,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct RoutingEvalReport {
67 pub suite: String,
68 pub requested_model: String,
69 pub records: Vec<RoutingTaskRecord>,
70 pub routed_count: usize,
71 pub kept_count: usize,
72 pub premium_downgrades: usize,
75 pub mean_input_rate_saving_per_mtok: f64,
77}
78
79impl RoutingEvalReport {
80 #[must_use]
85 pub fn to_json(&self) -> String {
86 serde_json::to_string_pretty(self).expect("routing report serializes")
87 }
88
89 #[must_use]
91 pub fn determinism_digest(&self) -> String {
92 super::sha256_hex(self.to_json().as_bytes())
93 }
94
95 #[must_use]
97 pub fn gate_passes(&self) -> bool {
98 self.premium_downgrades == 0
99 }
100
101 #[must_use]
103 pub fn render(&self) -> String {
104 use std::fmt::Write;
105 let mut out = String::new();
106 let _ = writeln!(
107 out,
108 "routing off-vs-on — suite '{}', requested model '{}'",
109 self.suite, self.requested_model
110 );
111 let _ = writeln!(
112 out,
113 "{:<28} {:<9} {:<26} {:>12}",
114 "task", "tier", "serving model", "Δ USD/MTok-in"
115 );
116 for r in &self.records {
117 let _ = writeln!(
118 out,
119 "{:<28} {:<9} {:<26} {:>12.3}",
120 r.task_id, r.tier, r.serving_model, r.input_rate_saving_per_mtok
121 );
122 }
123 let _ = writeln!(
124 out,
125 "\nrouted {}/{} tasks · mean saving {:.3} USD per 1M input tokens · premium downgrades: {}",
126 self.routed_count,
127 self.routed_count + self.kept_count,
128 self.mean_input_rate_saving_per_mtok,
129 self.premium_downgrades
130 );
131 out
132 }
133}
134
135#[must_use]
140pub fn routing_saving_usd(
141 pricing: &ModelPricing,
142 requested: &str,
143 serving: &str,
144 input_tokens: u64,
145) -> f64 {
146 let from = pricing.quote(Some(requested)).cost.input_per_m;
147 let to = pricing.quote(Some(serving)).cost.input_per_m;
148 #[allow(clippy::cast_precision_loss)]
149 let tokens = input_tokens as f64;
150 (from - to) / 1_000_000.0 * tokens
151}
152
153pub fn run_routing_eval(
163 suite: &EvalSuite,
164 suite_name: &str,
165 pricing: &ModelPricing,
166 cfg: &RoutingEvalConfig,
167) -> anyhow::Result<RoutingEvalReport> {
168 if !cfg.rules.is_active() {
169 anyhow::bail!(
170 "routing rules are inactive (enabled + at least one alias/tier required) — \
171 configure [proxy.routing] or pass explicit rules"
172 );
173 }
174 if suite.tasks.is_empty() {
175 anyhow::bail!("suite has no tasks");
176 }
177
178 let requested_quote = pricing.quote(Some(&cfg.requested_model));
179 let mut records = Vec::with_capacity(suite.tasks.len());
180 let mut premium_downgrades = 0usize;
181
182 for task in &suite.tasks {
183 let query = task.query();
184 let classification = classify(query);
185 let tier = route_intent(query, &classification).model_tier;
186 let tier_label = tier.as_str().to_string();
187
188 let target = cfg
190 .rules
191 .aliases
192 .get(&cfg.requested_model)
193 .cloned()
194 .or_else(|| {
195 cfg.rules
196 .tiers
197 .get(&tier_label)
198 .map(|t| t.trim().to_string())
199 .filter(|t| !t.is_empty())
200 });
201
202 let serving_model = target
203 .as_deref()
204 .and_then(parse_route_target)
205 .map(|(_, model)| model.to_string())
206 .filter(|m| m != &cfg.requested_model)
207 .filter(|m| pricing.quote(Some(m)).match_kind != PricingMatchKind::Fallback);
209
210 let routed = serving_model.is_some();
211 if routed && tier_label == "premium" {
212 premium_downgrades += 1;
213 }
214 let serving_model = serving_model.unwrap_or_else(|| cfg.requested_model.clone());
215 let serving_rate = pricing.quote(Some(&serving_model)).cost.input_per_m;
216
217 records.push(RoutingTaskRecord {
218 task_id: task.id.clone(),
219 tier: tier_label,
220 serving_model,
221 routed,
222 requested_input_rate: requested_quote.cost.input_per_m,
223 serving_input_rate: serving_rate,
224 input_rate_saving_per_mtok: requested_quote.cost.input_per_m - serving_rate,
225 });
226 }
227
228 let routed_count = records.iter().filter(|r| r.routed).count();
229 #[allow(clippy::cast_precision_loss)]
230 let mean = records
231 .iter()
232 .map(|r| r.input_rate_saving_per_mtok)
233 .sum::<f64>()
234 / records.len() as f64;
235
236 Ok(RoutingEvalReport {
237 suite: suite_name.to_string(),
238 requested_model: cfg.requested_model.clone(),
239 records,
240 routed_count,
241 kept_count: suite.tasks.len() - routed_count,
242 premium_downgrades,
243 mean_input_rate_saving_per_mtok: mean,
244 })
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn suite_with(prompts: &[(&str, &str)]) -> (tempfile::TempDir, EvalSuite) {
252 let root = tempfile::tempdir().unwrap();
253 let ws = root.path().join("corpus");
254 std::fs::create_dir_all(&ws).unwrap();
255 std::fs::write(ws.join("readme.md"), "fixture corpus").unwrap();
256 let raw = prompts
257 .iter()
258 .map(|(id, prompt)| {
259 format!(
260 r#"{{"id":"{id}","domain":"qa","prompt":"{prompt}","workspace":"corpus","answers":["x"]}}"#
261 )
262 })
263 .collect::<Vec<_>>()
264 .join("\n");
265 let suite = EvalSuite::parse(&raw, root.path().to_path_buf()).unwrap();
266 (root, suite)
267 }
268
269 fn rules(tiers: &[(&str, &str)]) -> RoutingRules {
270 RoutingRules {
271 enabled: Some(true),
272 aliases: std::collections::BTreeMap::default(),
273 tiers: tiers
274 .iter()
275 .map(|(k, v)| (k.to_string(), v.to_string()))
276 .collect(),
277 }
278 }
279
280 #[test]
281 fn off_vs_on_routes_cheap_tiers_and_never_premium() {
282 let (_root, suite) = suite_with(&[
283 (
284 "explore-q",
285 "how does the session cache work in this project?",
286 ),
287 (
288 "premium-gen",
289 "implement a new distributed lock manager with leader election and fencing tokens",
290 ),
291 ]);
292 let cfg = RoutingEvalConfig {
293 requested_model: "claude-opus-4.5".into(),
294 rules: rules(&[("fast", "foundry:Phi-4"), ("standard", "foundry:Phi-4")]),
295 };
296 let pricing = ModelPricing::embedded();
297 let report = run_routing_eval(&suite, "fixture", &pricing, &cfg).unwrap();
298
299 assert!(report.gate_passes(), "premium must never be downgraded");
300 assert_eq!(report.routed_count, 1, "the explore query routes");
301 let routed = report.records.iter().find(|r| r.routed).unwrap();
302 assert!((routed.input_rate_saving_per_mtok - 4.875).abs() < 1e-9);
304 let premium = &report.records[1];
305 assert_eq!(premium.tier, "premium");
306 assert!(!premium.routed);
307 assert_eq!(premium.input_rate_saving_per_mtok, 0.0);
308
309 let again = run_routing_eval(&suite, "fixture", &pricing, &cfg).unwrap();
311 assert_eq!(report.determinism_digest(), again.determinism_digest());
312 }
313
314 #[test]
315 fn unpriced_target_claims_no_saving() {
316 let (_root, suite) = suite_with(&[("q", "how does the config loader work?")]);
317 let cfg = RoutingEvalConfig {
318 requested_model: "claude-opus-4.5".into(),
319 rules: rules(&[
320 ("fast", "foundry:totally-unknown-model"),
321 ("standard", "foundry:totally-unknown-model"),
322 ]),
323 };
324 let report = run_routing_eval(&suite, "s", &ModelPricing::embedded(), &cfg).unwrap();
325 assert_eq!(report.routed_count, 0, "unpriced target must not route");
326 assert_eq!(report.mean_input_rate_saving_per_mtok, 0.0);
327 }
328
329 #[test]
330 fn inactive_rules_error_instead_of_empty_claim() {
331 let (_root, suite) = suite_with(&[("q", "anything")]);
332 let cfg = RoutingEvalConfig {
333 requested_model: "gpt-5.4".into(),
334 rules: RoutingRules::default(),
335 };
336 assert!(run_routing_eval(&suite, "s", &ModelPricing::embedded(), &cfg).is_err());
337 }
338
339 #[test]
340 fn ledger_formula_prices_measured_tokens() {
341 let pricing = ModelPricing::embedded();
342 let usd = routing_saving_usd(&pricing, "claude-opus-4.5", "phi-4", 2_000_000);
344 assert!((usd - 9.75).abs() < 1e-9);
345 assert!(routing_saving_usd(&pricing, "phi-4", "claude-opus-4.5", 1_000_000) < 0.0);
347 }
348}