use crate::cli::AppConfig;
use crate::models::{AnthropicRequest, RouteDecision, RouteType, SystemPrompt};
use anyhow::Result;
use regex::Regex;
use tracing::{debug, info};
#[derive(Clone)]
pub struct Router {
config: AppConfig,
auto_map_regex: Option<Regex>,
background_regex: Option<Regex>,
}
impl Router {
pub fn new(config: AppConfig) -> Self {
let auto_map_regex = config
.router
.auto_map_regex
.as_ref()
.and_then(|pattern| {
if pattern.is_empty() {
Some(Regex::new(r"^claude-").expect("Invalid default Claude regex"))
} else {
match Regex::new(pattern) {
Ok(regex) => Some(regex),
Err(e) => {
eprintln!(
"Warning: Invalid auto_map_regex pattern '{}': {}",
pattern, e
);
eprintln!("Falling back to default Claude pattern");
Some(Regex::new(r"^claude-").expect("Invalid default Claude regex"))
}
}
}
})
.or_else(|| {
Some(Regex::new(r"^claude-").expect("Invalid default Claude regex"))
});
let background_regex = config
.router
.background_regex
.as_ref()
.and_then(|pattern| {
if pattern.is_empty() {
Some(
Regex::new(r"(?i)claude.*haiku").expect("Invalid default background regex"),
)
} else {
match Regex::new(pattern) {
Ok(regex) => Some(regex),
Err(e) => {
eprintln!(
"Warning: Invalid background_regex pattern '{}': {}",
pattern, e
);
eprintln!("Falling back to default claude-haiku pattern");
Some(
Regex::new(r"(?i)claude.*haiku")
.expect("Invalid default background regex"),
)
}
}
}
})
.or_else(|| {
Some(Regex::new(r"(?i)claude.*haiku").expect("Invalid default background regex"))
});
Self {
config,
auto_map_regex,
background_regex,
}
}
pub fn route(&self, request: &mut AnthropicRequest) -> Result<RouteDecision> {
let original_model = request.model.clone();
if let Some(ref regex) = self.auto_map_regex {
if regex.is_match(&request.model) {
let old = request.model.clone();
request.model = self.config.router.default.clone();
debug!("🔀 Auto-mapped model '{}' → '{}'", old, request.model);
}
}
if let Some(ref websearch_model) = self.config.router.websearch {
if self.has_web_search_tool(request) {
info!("🔍 Routing to websearch model (web_search tool detected)");
return Ok(RouteDecision {
model_name: websearch_model.clone(),
route_type: RouteType::WebSearch,
});
}
}
if let Some(model) = self.extract_subagent_model(request) {
info!(
"🤖 Routing to subagent model (CCM-SUBAGENT-MODEL tag): {}",
model
);
return Ok(RouteDecision {
model_name: model,
route_type: RouteType::Default, });
}
if let Some(ref think_model) = self.config.router.think {
if self.is_plan_mode(request) {
info!("🧠 Routing to think model (Plan Mode detected)");
return Ok(RouteDecision {
model_name: think_model.clone(),
route_type: RouteType::Think,
});
}
}
if let Some(ref background_model) = self.config.router.background {
if self.is_background_task(&original_model) {
debug!("🔄 Routing to background model");
return Ok(RouteDecision {
model_name: background_model.clone(),
route_type: RouteType::Background,
});
}
}
debug!("✅ Using model: {}", request.model);
Ok(RouteDecision {
model_name: request.model.clone(),
route_type: RouteType::Default,
})
}
fn has_web_search_tool(&self, request: &AnthropicRequest) -> bool {
if let Some(ref tools) = request.tools {
tools.iter().any(|tool| {
tool.r#type
.as_ref()
.map(|t| t.starts_with("web_search"))
.unwrap_or(false)
})
} else {
false
}
}
fn is_plan_mode(&self, request: &AnthropicRequest) -> bool {
request
.thinking
.as_ref()
.map(|t| t.r#type == "enabled")
.unwrap_or(false)
}
fn is_background_task(&self, model: &str) -> bool {
if let Some(ref regex) = self.background_regex {
regex.is_match(model)
} else {
false
}
}
fn extract_subagent_model(&self, request: &mut AnthropicRequest) -> Option<String> {
let system = request.system.as_mut()?;
if let SystemPrompt::Blocks(blocks) = system {
if blocks.len() < 2 {
return None;
}
let second_block = &mut blocks[1];
if !second_block.text.contains("<CCM-SUBAGENT-MODEL>") {
return None;
}
let re = Regex::new(r"<CCM-SUBAGENT-MODEL>(.*?)</CCM-SUBAGENT-MODEL>")
.expect("Invalid regex pattern");
if let Some(captures) = re.captures(&second_block.text) {
if let Some(model_match) = captures.get(1) {
let model_name = model_match.as_str().to_string();
second_block.text = re.replace_all(&second_block.text, "").to_string();
return Some(model_name);
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::{RouterConfig, ServerConfig};
use crate::models::{Message, MessageContent, ThinkingConfig};
fn create_test_config() -> AppConfig {
AppConfig {
server: ServerConfig::default(),
router: RouterConfig {
default: "default.model".to_string(),
background: Some("background.model".to_string()),
think: Some("think.model".to_string()),
websearch: Some("websearch.model".to_string()),
auto_map_regex: None, background_regex: None, },
providers: vec![],
models: vec![],
}
}
fn create_simple_request(text: &str) -> AnthropicRequest {
AnthropicRequest {
model: "claude-opus-4".to_string(),
messages: vec![Message {
role: "user".to_string(),
content: MessageContent::Text(text.to_string()),
}],
max_tokens: 1024,
thinking: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
stream: None,
metadata: None,
system: None,
tools: None,
}
}
#[test]
fn test_plan_mode_detection() {
let config = create_test_config();
let router = Router::new(config);
let mut request = create_simple_request("Explain quantum computing");
request.thinking = Some(ThinkingConfig {
r#type: "enabled".to_string(),
budget_tokens: Some(10_000),
});
let decision = router.route(&mut request).unwrap();
assert_eq!(decision.route_type, RouteType::Think);
assert_eq!(decision.model_name, "think.model");
}
#[test]
fn test_background_task_detection() {
let config = create_test_config();
let router = Router::new(config);
let mut request = create_simple_request("Hello");
request.model = "claude-3-5-haiku-20241022".to_string();
let decision = router.route(&mut request).unwrap();
assert_eq!(decision.route_type, RouteType::Background);
assert_eq!(decision.model_name, "background.model");
}
#[test]
fn test_default_routing() {
let mut config = create_test_config();
config.router.background = None; let router = Router::new(config);
let mut request = create_simple_request("Write a function to sort an array");
let decision = router.route(&mut request).unwrap();
assert_eq!(decision.route_type, RouteType::Default);
assert_eq!(decision.model_name, "default.model");
}
#[test]
fn test_routing_priority() {
let config = create_test_config();
let router = Router::new(config);
let mut request = create_simple_request("Explain complex topic");
request.thinking = Some(ThinkingConfig {
r#type: "enabled".to_string(),
budget_tokens: Some(10_000),
});
let decision = router.route(&mut request).unwrap();
assert_eq!(decision.route_type, RouteType::Think); }
#[test]
fn test_websearch_tool_detection() {
let config = create_test_config();
let router = Router::new(config);
let mut request = create_simple_request("Search the web for latest news");
request.tools = Some(vec![crate::models::Tool {
r#type: Some("web_search_2025_04".to_string()),
name: Some("web_search".to_string()),
description: Some("Search the web".to_string()),
input_schema: Some(serde_json::json!({
"type": "object",
"properties": {}
})),
}]);
let decision = router.route(&mut request).unwrap();
assert_eq!(decision.route_type, RouteType::WebSearch);
assert_eq!(decision.model_name, "websearch.model");
}
#[test]
fn test_websearch_has_highest_priority() {
let config = create_test_config();
let router = Router::new(config);
let mut request = create_simple_request("Search and explain");
request.thinking = Some(ThinkingConfig {
r#type: "enabled".to_string(),
budget_tokens: Some(10_000),
});
request.tools = Some(vec![crate::models::Tool {
r#type: Some("web_search".to_string()),
name: None,
description: None,
input_schema: None,
}]);
let decision = router.route(&mut request).unwrap();
assert_eq!(decision.route_type, RouteType::WebSearch); assert_eq!(decision.model_name, "websearch.model");
}
#[test]
fn test_auto_map_claude_models() {
let config = create_test_config();
let router = Router::new(config);
let mut request = create_simple_request("Hello");
request.model = "claude-3-5-sonnet-20241022".to_string();
let decision = router.route(&mut request).unwrap();
assert_eq!(decision.route_type, RouteType::Default);
assert_eq!(decision.model_name, "default.model"); }
#[test]
fn test_auto_map_custom_regex() {
let mut config = create_test_config();
config.router.auto_map_regex = Some("^(claude-|gpt-)".to_string());
let router = Router::new(config);
let mut request = create_simple_request("Hello");
request.model = "gpt-4".to_string();
let decision = router.route(&mut request).unwrap();
assert_eq!(decision.route_type, RouteType::Default);
assert_eq!(decision.model_name, "default.model"); }
#[test]
fn test_no_auto_map_non_matching() {
let config = create_test_config();
let router = Router::new(config);
let mut request = create_simple_request("Hello");
request.model = "glm-4.6".to_string();
let decision = router.route(&mut request).unwrap();
assert_eq!(decision.route_type, RouteType::Default);
assert_eq!(decision.model_name, "glm-4.6"); }
}