Skip to main content

rx4/
routing.rs

1//! Smart routing: classify each user turn as simple or strong and route to
2//! different models (OpenClaude pattern), plus per-agent model routing.
3
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Classification of a single user turn.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum TurnComplexity {
10    /// Short, no code, no reasoning keywords — cheap model is sufficient.
11    Simple,
12    /// Long, code blocks, or reasoning keywords — strong model required.
13    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/// Configuration for smart routing between a simple and a strong model.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RoutingConfig {
28    /// Model used for simple turns (e.g., "gpt-4o-mini").
29    pub simple_model: Option<String>,
30    /// Model used for strong turns (e.g., "gpt-4o").
31    pub strong_model: Option<String>,
32    /// Whether smart routing is active.
33    pub enabled: bool,
34    /// Weight applied to cached context when estimating savings.
35    pub cache_weight: f64,
36    /// Weight applied to fresh input when estimating savings.
37    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
63/// Heuristic classifier: simple turns are short, code-free, and lack
64/// reasoning keywords; strong turns are long, contain code blocks, or
65/// mention reasoning keywords.
66pub 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/// Aggregate statistics produced by a [`SmartRouter`].
81#[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
90/// Routes prompts to a simple or strong model based on turn complexity.
91pub struct SmartRouter {
92    config: RoutingConfig,
93    stats: parking_lot::Mutex<RoutingStats>,
94}
95
96impl SmartRouter {
97    /// Create a new router with the given configuration.
98    pub fn new(config: RoutingConfig) -> Self {
99        Self {
100            config,
101            stats: parking_lot::Mutex::new(RoutingStats::default()),
102        }
103    }
104
105    /// Route a single prompt, returning the model to use. When routing is
106    /// disabled or the target model is unset, the `current_model` is
107    /// returned unchanged.
108    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    /// Route a batch of prompts, returning the model to use for each.
145    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    /// Return a snapshot of the router's aggregate statistics.
153    pub fn stats(&self) -> RoutingStats {
154        self.stats.lock().clone()
155    }
156}
157
158/// A per-agent model/provider override (OpenClaude agentModels pattern).
159#[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
166/// Routes agent names to configured models, falling back to a default.
167pub struct AgentRouter {
168    routes: HashMap<String, AgentRoute>,
169    default_model: String,
170}
171
172impl AgentRouter {
173    /// Create a new agent router with the given default model.
174    pub fn new(default_model: String) -> Self {
175        Self {
176            routes: HashMap::new(),
177            default_model,
178        }
179    }
180
181    /// Register a route for an agent.
182    pub fn register(&mut self, route: AgentRoute) {
183        self.routes.insert(route.agent_name.clone(), route);
184    }
185
186    /// Return the model for the named agent, falling back to the default.
187    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}