use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct RoutingRule { pub model_pattern: String, pub provider: String, pub max_tokens: Option, pub temperature: Option, } #[derive(Clone, Default)] pub struct RoutingEngine { rules: Vec, fallback_chain: Vec, } impl RoutingEngine { pub fn new() -> Self { Self::default() } pub fn with_rules(mut self, rules: Vec) -> Self { self.rules = rules; self } pub fn with_fallback(mut self, chain: Vec) -> Self { self.fallback_chain = chain; self } pub fn route(&self, model: &str) -> RouteDecision { let lower = model.to_lowercase(); for rule in &self.rules { if glob_match(&rule.model_pattern.to_lowercase(), &lower) { return RouteDecision { provider: rule.provider.clone(), model: model.to_string(), max_tokens: rule.max_tokens, temperature: rule.temperature, }; } } if let Some(first) = self.fallback_chain.first() { RouteDecision { provider: first.clone(), model: model.to_string(), max_tokens: None, temperature: None, } } else { RouteDecision { provider: "ollama".to_string(), model: model.to_string(), max_tokens: None, temperature: None, } } } } #[derive(Clone, Debug)] pub struct RouteDecision { pub provider: String, pub model: String, pub max_tokens: Option, pub temperature: Option, } fn glob_match(pattern: &str, name: &str) -> bool { if !pattern.contains('*') { return pattern == name; } let parts: Vec<&str> = pattern.split('*').collect(); // Multi-* support: first must be prefix, last must be suffix, each // interior piece must appear in order. Fixes the iter-9 finding // where gpt-*-large* silently fell through to an exact-match path. // Also removes the dead `parts.len() == 1` branch that accessed // parts[1] and would panic if ever reached (unreachable today // since split('*') on a string containing '*' always yields ≥2). if !name.starts_with(parts[0]) || !name.ends_with(parts.last().unwrap()) { return false; } let mut cursor = parts[0].len(); parts[1..parts.len() - 1].iter().all(|mid| { name[cursor..].find(mid).map(|pos| { cursor += pos + mid.len(); true }).unwrap_or(false) }) } impl Default for RoutingRule { fn default() -> Self { Self { model_pattern: "*".to_string(), provider: "ollama".to_string(), max_tokens: None, temperature: None, } } } #[cfg(test)] mod glob_match_tests { use super::glob_match; #[test] fn exact_match() { assert!(glob_match("gpt-oss:120b", "gpt-oss:120b")); } #[test] fn exact_mismatch() { assert!(!glob_match("a", "b")); } #[test] fn leading_wildcard() { assert!(glob_match("*:120b", "gpt-oss:120b")); } #[test] fn trailing_wildcard() { assert!(glob_match("gpt-oss:*", "gpt-oss:120b")); } #[test] fn bare_wildcard() { assert!(glob_match("*", "anything")); } #[test] fn multi_wildcard_in_order() { assert!(glob_match("gpt-*-oss-*", "gpt-4-oss-120b")); } #[test] fn multi_wildcard_wrong_order() { assert!(!glob_match("b*a*", "abba")); } #[test] fn multi_wildcard_panic_safety() { // Regression: earlier impl had an unreachable `parts.len() == 1` // branch that indexed parts[1] — would panic if ever hit. Now // the split('*') invariant guarantees ≥2 parts when * present, // and we handle all N-part cases explicitly. assert!(glob_match("a*b*c", "abc")); assert!(glob_match("a*b*c", "axxxbxxxc")); assert!(!glob_match("a*b*c", "xxxbxxx")); } }