Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions src/routing/classify/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

Expand Down
44 changes: 44 additions & 0 deletions src/routing/classify/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading