diff --git a/src/routing/classify/mod.rs b/src/routing/classify/mod.rs index 9556ca6..3cb9f67 100644 --- a/src/routing/classify/mod.rs +++ b/src/routing/classify/mod.rs @@ -312,14 +312,32 @@ impl Router { } } - // 3. Auto-mapping (model name transformation, after background check) + // 3. Auto-mapping (model name transformation, after background check). + // + // If the user has an explicit `[[models]]` entry whose name matches + // the incoming request model, that entry takes precedence — auto-map + // is a "catch-all rewrite for unconfigured models", not a forced + // override. Without this guard, a request for `claude-sonnet-4-6` + // would be rewritten to `router.default` even when the user defined + // a virtual `claude-sonnet-4-6` model with its own fallback chain; + // the chain would never run and pass-through would leak the virtual + // name "default-model" to the upstream provider. if let Some(ref mapper) = self.auto_mapper { if mapper.is_match(&request.model) { - info!( - "Auto-mapped model '{}' → '{}'", - request.model, self.config.router.default - ); - request.model.clone_from(&self.config.router.default); + let has_explicit_virtual = + self.config.models.iter().any(|m| m.name == request.model); + if has_explicit_virtual { + info!( + "Auto-map skipped for '{}' — explicit [[models]] entry takes precedence", + request.model + ); + } else { + info!( + "Auto-mapped model '{}' → '{}'", + request.model, self.config.router.default + ); + request.model.clone_from(&self.config.router.default); + } } } diff --git a/src/routing/classify/tests.rs b/src/routing/classify/tests.rs index 75a68da..e75c6da 100644 --- a/src/routing/classify/tests.rs +++ b/src/routing/classify/tests.rs @@ -200,6 +200,50 @@ fn test_auto_map_custom_regex() { assert_eq!(decision.model_name, "default.model"); // Auto-mapped to default } +#[test] +fn test_auto_map_skips_explicit_virtual_model() { + // Regression: a request that matches the auto_map regex must NOT be + // rewritten to `router.default` when the user has an explicit + // `[[models]]` entry with the same name. Without this guard the + // virtual entry's fallback chain would be bypassed entirely and the + // virtual name would leak to a pass-through provider downstream. + use crate::cli::ModelConfig; + + let mut config = create_test_config(); + // Add a virtual model entry with the same name as the incoming model. + config.models.push(ModelConfig { + name: "claude-sonnet-4-6".to_string(), + mappings: vec![], + budget_usd: None, + strategy: Default::default(), + fan_out: None, + deprecated: None, + }); + let router = Router::new(config); + + let mut request = create_simple_request("Hello"); + request.model = "claude-sonnet-4-6".to_string(); + + let decision = router.route(&mut request).unwrap(); + assert_eq!(decision.route_type, RouteType::Default); + // Must use the explicit virtual name, NOT the auto-mapped default. + assert_eq!(decision.model_name, "claude-sonnet-4-6"); +} + +#[test] +fn test_auto_map_still_rewrites_unmapped_claude() { + // Counter-test: when the user has no `[[models]]` entry for the + // incoming claude-* name, auto-map continues to rewrite as before. + let config = create_test_config(); + let router = Router::new(config); + + let mut request = create_simple_request("Hello"); + request.model = "claude-some-unmapped-variant".to_string(); + + let decision = router.route(&mut request).unwrap(); + assert_eq!(decision.model_name, "default.model"); +} + #[test] fn test_no_auto_map_non_matching() { let config = create_test_config();