1use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum TurnComplexity {
10 Simple,
12 Strong,
14}
15
16impl TurnComplexity {
17 pub fn as_str(&self) -> &'static str {
18 match self {
19 TurnComplexity::Simple => "simple",
20 TurnComplexity::Strong => "strong",
21 }
22 }
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RoutingConfig {
28 pub simple_model: Option<String>,
30 pub strong_model: Option<String>,
32 pub enabled: bool,
34 pub cache_weight: f64,
36 pub fresh_weight: f64,
38}
39
40impl Default for RoutingConfig {
41 fn default() -> Self {
42 Self {
43 simple_model: None,
44 strong_model: None,
45 enabled: false,
46 cache_weight: 0.4,
47 fresh_weight: 0.6,
48 }
49 }
50}
51
52const REASONING_KEYWORDS: &[&str] = &[
53 "analyze",
54 "design",
55 "refactor",
56 "debug",
57 "architecture",
58 "implement",
59 "optimize",
60 "review",
61];
62
63pub fn classify_turn(prompt: &str) -> TurnComplexity {
67 let has_code = prompt.contains("```");
68 let has_reasoning = REASONING_KEYWORDS
69 .iter()
70 .any(|kw| prompt.to_lowercase().contains(kw));
71 let is_long = prompt.chars().count() >= 200;
72
73 if has_code || has_reasoning || is_long {
74 TurnComplexity::Strong
75 } else {
76 TurnComplexity::Simple
77 }
78}
79
80#[derive(Debug, Clone, Default, Serialize, Deserialize)]
82pub struct RoutingStats {
83 pub total_turns: usize,
84 pub simple_turns: usize,
85 pub strong_turns: usize,
86 pub escalations: usize,
87 pub estimated_savings: f64,
88}
89
90pub struct SmartRouter {
92 config: RoutingConfig,
93 stats: parking_lot::Mutex<RoutingStats>,
94}
95
96impl SmartRouter {
97 pub fn new(config: RoutingConfig) -> Self {
99 Self {
100 config,
101 stats: parking_lot::Mutex::new(RoutingStats::default()),
102 }
103 }
104
105 pub fn route(&self, prompt: &str, current_model: &str) -> String {
109 let complexity = classify_turn(prompt);
110 let mut stats = self.stats.lock();
111 stats.total_turns += 1;
112 match complexity {
113 TurnComplexity::Simple => stats.simple_turns += 1,
114 TurnComplexity::Strong => stats.strong_turns += 1,
115 }
116
117 if !self.config.enabled {
118 return current_model.to_string();
119 }
120
121 let target = match complexity {
122 TurnComplexity::Simple => self.config.simple_model.as_deref(),
123 TurnComplexity::Strong => self.config.strong_model.as_deref(),
124 };
125
126 match target {
127 Some(model) => {
128 if matches!(complexity, TurnComplexity::Strong)
129 && self
130 .config
131 .simple_model
132 .as_deref()
133 .map(|s| s == current_model)
134 .unwrap_or(false)
135 {
136 stats.escalations += 1;
137 }
138 model.to_string()
139 }
140 None => current_model.to_string(),
141 }
142 }
143
144 pub fn route_batch(&self, prompts: &[&str], current_model: &str) -> Vec<String> {
146 prompts
147 .iter()
148 .map(|p| self.route(p, current_model))
149 .collect()
150 }
151
152 pub fn stats(&self) -> RoutingStats {
154 self.stats.lock().clone()
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct AgentRoute {
161 pub agent_name: String,
162 pub model: Option<String>,
163 pub provider: Option<String>,
164}
165
166pub struct AgentRouter {
168 routes: HashMap<String, AgentRoute>,
169 default_model: String,
170}
171
172impl AgentRouter {
173 pub fn new(default_model: String) -> Self {
175 Self {
176 routes: HashMap::new(),
177 default_model,
178 }
179 }
180
181 pub fn register(&mut self, route: AgentRoute) {
183 self.routes.insert(route.agent_name.clone(), route);
184 }
185
186 pub fn route_for_agent(&self, agent_name: &str) -> &str {
188 match self.routes.get(agent_name) {
189 Some(route) => route.model.as_deref().unwrap_or(&self.default_model),
190 None => &self.default_model,
191 }
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn classifies_simple_turn() {
201 assert_eq!(classify_turn("hello"), TurnComplexity::Simple);
202 assert_eq!(classify_turn("what is 2+2?"), TurnComplexity::Simple);
203 }
204
205 #[test]
206 fn classifies_long_turn_as_strong() {
207 let prompt = "x".repeat(200);
208 assert_eq!(classify_turn(&prompt), TurnComplexity::Strong);
209 }
210
211 #[test]
212 fn classifies_code_block_as_strong() {
213 assert_eq!(
214 classify_turn("fix this:\n```rust\nfn main() {}\n```"),
215 TurnComplexity::Strong
216 );
217 }
218
219 #[test]
220 fn classifies_reasoning_keyword_as_strong() {
221 assert_eq!(classify_turn("analyze the data"), TurnComplexity::Strong);
222 assert_eq!(
223 classify_turn("refactor this module"),
224 TurnComplexity::Strong
225 );
226 assert_eq!(
227 classify_turn("design the architecture"),
228 TurnComplexity::Strong
229 );
230 }
231
232 #[test]
233 fn router_returns_current_model_when_disabled() {
234 let router = SmartRouter::new(RoutingConfig::default());
235 let model = router.route("analyze this", "gpt-4o");
236 assert_eq!(model, "gpt-4o");
237 }
238
239 #[test]
240 fn router_routes_simple_to_simple_model() {
241 let config = RoutingConfig {
242 enabled: true,
243 simple_model: Some("gpt-4o-mini".to_string()),
244 strong_model: Some("gpt-4o".to_string()),
245 ..Default::default()
246 };
247 let router = SmartRouter::new(config);
248 assert_eq!(router.route("hello", "gpt-4o"), "gpt-4o-mini");
249 }
250
251 #[test]
252 fn router_routes_strong_to_strong_model() {
253 let config = RoutingConfig {
254 enabled: true,
255 simple_model: Some("gpt-4o-mini".to_string()),
256 strong_model: Some("gpt-4o".to_string()),
257 ..Default::default()
258 };
259 let router = SmartRouter::new(config);
260 assert_eq!(
261 router.route("analyze the architecture", "gpt-4o-mini"),
262 "gpt-4o"
263 );
264 }
265
266 #[test]
267 fn router_falls_back_when_target_unset() {
268 let config = RoutingConfig {
269 enabled: true,
270 simple_model: None,
271 ..Default::default()
272 };
273 let router = SmartRouter::new(config);
274 assert_eq!(router.route("hello", "gpt-4o"), "gpt-4o");
275 }
276
277 #[test]
278 fn router_tracks_stats() {
279 let config = RoutingConfig {
280 enabled: true,
281 simple_model: Some("gpt-4o-mini".to_string()),
282 strong_model: Some("gpt-4o".to_string()),
283 ..Default::default()
284 };
285 let router = SmartRouter::new(config);
286 router.route("hello", "gpt-4o");
287 router.route("analyze this", "gpt-4o");
288 router.route("refactor that", "gpt-4o");
289 let stats = router.stats();
290 assert_eq!(stats.total_turns, 3);
291 assert_eq!(stats.simple_turns, 1);
292 assert_eq!(stats.strong_turns, 2);
293 }
294
295 #[test]
296 fn router_route_batch() {
297 let config = RoutingConfig {
298 enabled: true,
299 simple_model: Some("gpt-4o-mini".to_string()),
300 strong_model: Some("gpt-4o".to_string()),
301 ..Default::default()
302 };
303 let router = SmartRouter::new(config);
304 let models = router.route_batch(&["hello", "analyze this"], "gpt-4o");
305 assert_eq!(models, vec!["gpt-4o-mini", "gpt-4o"]);
306 }
307
308 #[test]
309 fn agent_router_falls_back_to_default() {
310 let router = AgentRouter::new("gpt-4o".to_string());
311 assert_eq!(router.route_for_agent("unknown"), "gpt-4o");
312 }
313
314 #[test]
315 fn agent_router_returns_registered_model() {
316 let mut router = AgentRouter::new("gpt-4o".to_string());
317 router.register(AgentRoute {
318 agent_name: "researcher".to_string(),
319 model: Some("o1".to_string()),
320 provider: Some("openai".to_string()),
321 });
322 assert_eq!(router.route_for_agent("researcher"), "o1");
323 }
324
325 #[test]
326 fn agent_router_falls_back_when_model_unset() {
327 let mut router = AgentRouter::new("gpt-4o".to_string());
328 router.register(AgentRoute {
329 agent_name: "researcher".to_string(),
330 model: None,
331 provider: None,
332 });
333 assert_eq!(router.route_for_agent("researcher"), "gpt-4o");
334 }
335
336 #[test]
337 fn turn_complexity_as_str() {
338 assert_eq!(TurnComplexity::Simple.as_str(), "simple");
339 assert_eq!(TurnComplexity::Strong.as_str(), "strong");
340 }
341}