Iter 9 scrum flagged routing.rs with OffByOne + NullableConfusion risks
on the glob matcher. Two real bugs in one function:
1. The `else if parts.len() == 1` branch was dead AND panic-hazardous:
split('*') on a string containing '*' always yields ≥2 parts, so
the branch was unreachable — but if ever reached (via future
caller or split-behavior change), `parts[1]` would index out of
bounds and panic.
2. Multi-* patterns like `gpt-*-large*` fell through to exact-match
because the `parts.len() == 2` branch only handled single-*. Result:
a rule like `model_pattern: "gpt-*-oss-*"` would only match the
literal string "gpt-*-oss-*", never an actual gpt-4-oss-120b.
Fix walks parts left-to-right: prefix check, suffix check, each
interior segment must appear in order. Cursor-advance logic ensures
a mid-segment that appears before cursor (duplicate prefix) can't
falsely match.
8 new tests cover: exact match, exact mismatch, leading/trailing/bare
wildcards, multi-* in-order, multi-* wrong-order (regression guard),
and the old panic-hazard case ("a*b*c" variants) as an explicit check.
Workspace warnings unchanged at 11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
4.0 KiB
Rust
119 lines
4.0 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct RoutingRule {
|
|
pub model_pattern: String,
|
|
pub provider: String,
|
|
pub max_tokens: Option<u32>,
|
|
pub temperature: Option<f64>,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub struct RoutingEngine {
|
|
rules: Vec<RoutingRule>,
|
|
fallback_chain: Vec<String>,
|
|
}
|
|
|
|
impl RoutingEngine {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn with_rules(mut self, rules: Vec<RoutingRule>) -> Self {
|
|
self.rules = rules;
|
|
self
|
|
}
|
|
|
|
pub fn with_fallback(mut self, chain: Vec<String>) -> 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<u32>,
|
|
pub temperature: Option<f64>,
|
|
}
|
|
|
|
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"));
|
|
}
|
|
} |