use serde::{Deserialize, Serialize};
use crate::core::config::{RoutingRules, parse_route_target};
use crate::core::gain::model_pricing::{ModelPricing, PricingMatchKind};
use crate::core::intent_engine::{classify, route_intent};
use super::suite::EvalSuite;
#[derive(Debug, Clone)]
pub struct RoutingEvalConfig {
pub requested_model: String,
pub rules: RoutingRules,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutingTaskRecord {
pub task_id: String,
pub tier: String,
pub serving_model: String,
pub routed: bool,
pub requested_input_rate: f64,
pub serving_input_rate: f64,
pub input_rate_saving_per_mtok: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutingEvalReport {
pub suite: String,
pub requested_model: String,
pub records: Vec<RoutingTaskRecord>,
pub routed_count: usize,
pub kept_count: usize,
pub premium_downgrades: usize,
pub mean_input_rate_saving_per_mtok: f64,
}
impl RoutingEvalReport {
#[must_use]
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).expect("routing report serializes")
}
#[must_use]
pub fn determinism_digest(&self) -> String {
super::sha256_hex(self.to_json().as_bytes())
}
#[must_use]
pub fn gate_passes(&self) -> bool {
self.premium_downgrades == 0
}
#[must_use]
pub fn render(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(
out,
"routing off-vs-on — suite '{}', requested model '{}'",
self.suite, self.requested_model
);
let _ = writeln!(
out,
"{:<28} {:<9} {:<26} {:>12}",
"task", "tier", "serving model", "Δ USD/MTok-in"
);
for r in &self.records {
let _ = writeln!(
out,
"{:<28} {:<9} {:<26} {:>12.3}",
r.task_id, r.tier, r.serving_model, r.input_rate_saving_per_mtok
);
}
let _ = writeln!(
out,
"\nrouted {}/{} tasks · mean saving {:.3} USD per 1M input tokens · premium downgrades: {}",
self.routed_count,
self.routed_count + self.kept_count,
self.mean_input_rate_saving_per_mtok,
self.premium_downgrades
);
out
}
}
#[must_use]
pub fn routing_saving_usd(
pricing: &ModelPricing,
requested: &str,
serving: &str,
input_tokens: u64,
) -> f64 {
let from = pricing.quote(Some(requested)).cost.input_per_m;
let to = pricing.quote(Some(serving)).cost.input_per_m;
#[allow(clippy::cast_precision_loss)]
let tokens = input_tokens as f64;
(from - to) / 1_000_000.0 * tokens
}
pub fn run_routing_eval(
suite: &EvalSuite,
suite_name: &str,
pricing: &ModelPricing,
cfg: &RoutingEvalConfig,
) -> anyhow::Result<RoutingEvalReport> {
if !cfg.rules.is_active() {
anyhow::bail!(
"routing rules are inactive (enabled + at least one alias/tier required) — \
configure [proxy.routing] or pass explicit rules"
);
}
if suite.tasks.is_empty() {
anyhow::bail!("suite has no tasks");
}
let requested_quote = pricing.quote(Some(&cfg.requested_model));
let mut records = Vec::with_capacity(suite.tasks.len());
let mut premium_downgrades = 0usize;
for task in &suite.tasks {
let query = task.query();
let classification = classify(query);
let tier = route_intent(query, &classification).model_tier;
let tier_label = tier.as_str().to_string();
let target = cfg
.rules
.aliases
.get(&cfg.requested_model)
.cloned()
.or_else(|| {
cfg.rules
.tiers
.get(&tier_label)
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
});
let serving_model = target
.as_deref()
.and_then(parse_route_target)
.map(|(_, model)| model.to_string())
.filter(|m| m != &cfg.requested_model)
.filter(|m| pricing.quote(Some(m)).match_kind != PricingMatchKind::Fallback);
let routed = serving_model.is_some();
if routed && tier_label == "premium" {
premium_downgrades += 1;
}
let serving_model = serving_model.unwrap_or_else(|| cfg.requested_model.clone());
let serving_rate = pricing.quote(Some(&serving_model)).cost.input_per_m;
records.push(RoutingTaskRecord {
task_id: task.id.clone(),
tier: tier_label,
serving_model,
routed,
requested_input_rate: requested_quote.cost.input_per_m,
serving_input_rate: serving_rate,
input_rate_saving_per_mtok: requested_quote.cost.input_per_m - serving_rate,
});
}
let routed_count = records.iter().filter(|r| r.routed).count();
#[allow(clippy::cast_precision_loss)]
let mean = records
.iter()
.map(|r| r.input_rate_saving_per_mtok)
.sum::<f64>()
/ records.len() as f64;
Ok(RoutingEvalReport {
suite: suite_name.to_string(),
requested_model: cfg.requested_model.clone(),
records,
routed_count,
kept_count: suite.tasks.len() - routed_count,
premium_downgrades,
mean_input_rate_saving_per_mtok: mean,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn suite_with(prompts: &[(&str, &str)]) -> (tempfile::TempDir, EvalSuite) {
let root = tempfile::tempdir().unwrap();
let ws = root.path().join("corpus");
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(ws.join("readme.md"), "fixture corpus").unwrap();
let raw = prompts
.iter()
.map(|(id, prompt)| {
format!(
r#"{{"id":"{id}","domain":"qa","prompt":"{prompt}","workspace":"corpus","answers":["x"]}}"#
)
})
.collect::<Vec<_>>()
.join("\n");
let suite = EvalSuite::parse(&raw, root.path().to_path_buf()).unwrap();
(root, suite)
}
fn rules(tiers: &[(&str, &str)]) -> RoutingRules {
RoutingRules {
enabled: Some(true),
aliases: std::collections::BTreeMap::default(),
tiers: tiers
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
#[test]
fn off_vs_on_routes_cheap_tiers_and_never_premium() {
let (_root, suite) = suite_with(&[
(
"explore-q",
"how does the session cache work in this project?",
),
(
"premium-gen",
"implement a new distributed lock manager with leader election and fencing tokens",
),
]);
let cfg = RoutingEvalConfig {
requested_model: "claude-opus-4.5".into(),
rules: rules(&[("fast", "foundry:Phi-4"), ("standard", "foundry:Phi-4")]),
};
let pricing = ModelPricing::embedded();
let report = run_routing_eval(&suite, "fixture", &pricing, &cfg).unwrap();
assert!(report.gate_passes(), "premium must never be downgraded");
assert_eq!(report.routed_count, 1, "the explore query routes");
let routed = report.records.iter().find(|r| r.routed).unwrap();
assert!((routed.input_rate_saving_per_mtok - 4.875).abs() < 1e-9);
let premium = &report.records[1];
assert_eq!(premium.tier, "premium");
assert!(!premium.routed);
assert_eq!(premium.input_rate_saving_per_mtok, 0.0);
let again = run_routing_eval(&suite, "fixture", &pricing, &cfg).unwrap();
assert_eq!(report.determinism_digest(), again.determinism_digest());
}
#[test]
fn unpriced_target_claims_no_saving() {
let (_root, suite) = suite_with(&[("q", "how does the config loader work?")]);
let cfg = RoutingEvalConfig {
requested_model: "claude-opus-4.5".into(),
rules: rules(&[
("fast", "foundry:totally-unknown-model"),
("standard", "foundry:totally-unknown-model"),
]),
};
let report = run_routing_eval(&suite, "s", &ModelPricing::embedded(), &cfg).unwrap();
assert_eq!(report.routed_count, 0, "unpriced target must not route");
assert_eq!(report.mean_input_rate_saving_per_mtok, 0.0);
}
#[test]
fn inactive_rules_error_instead_of_empty_claim() {
let (_root, suite) = suite_with(&[("q", "anything")]);
let cfg = RoutingEvalConfig {
requested_model: "gpt-5.4".into(),
rules: RoutingRules::default(),
};
assert!(run_routing_eval(&suite, "s", &ModelPricing::embedded(), &cfg).is_err());
}
#[test]
fn ledger_formula_prices_measured_tokens() {
let pricing = ModelPricing::embedded();
let usd = routing_saving_usd(&pricing, "claude-opus-4.5", "phi-4", 2_000_000);
assert!((usd - 9.75).abs() < 1e-9);
assert!(routing_saving_usd(&pricing, "phi-4", "claude-opus-4.5", 1_000_000) < 0.0);
}
}