1use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34use crate::core::config::{RoutingRules, parse_route_target};
35use crate::core::intent_engine::{self, TaskClassification};
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct RoutingDecision {
41 pub requested_model: String,
43 pub routed_model: String,
45 pub routed_provider: Option<String>,
47 pub tier: String,
49 pub confidence: f64,
51 pub reasoning: String,
53 pub model_changed: bool,
55 pub estimated_cost_ratio: Option<f64>,
57}
58
59pub fn route(body: &Value, rules: &RoutingRules) -> Option<RoutingDecision> {
65 if !rules.is_active() || rules.tiers.is_empty() {
66 return None;
67 }
68
69 let requested_model = extract_model(body)?;
70
71 if rules.aliases.contains_key(&requested_model) {
74 return None;
75 }
76
77 let messages = body.get("messages")?;
78 let last_user_content = extract_last_user_content(messages)?;
79
80 let classification = intent_engine::classify(&last_user_content);
81 let route = intent_engine::route_intent(&last_user_content, &classification);
82
83 let tier_key = route.model_tier.as_str();
84 let target = rules.tiers.get(tier_key)?;
85
86 if target.is_empty() {
88 return Some(RoutingDecision {
89 requested_model: requested_model.clone(),
90 routed_model: requested_model,
91 routed_provider: None,
92 tier: tier_key.to_string(),
93 confidence: route.confidence,
94 reasoning: route.reasoning,
95 model_changed: false,
96 estimated_cost_ratio: None,
97 });
98 }
99
100 let (provider, model) = parse_route_target(target)?;
101
102 let cost_ratio = estimate_cost_ratio(&requested_model, model);
103
104 Some(RoutingDecision {
105 requested_model: requested_model.clone(),
106 routed_model: model.to_string(),
107 routed_provider: provider.map(str::to_string),
108 tier: tier_key.to_string(),
109 confidence: route.confidence,
110 reasoning: route.reasoning,
111 model_changed: requested_model != model,
112 estimated_cost_ratio: cost_ratio,
113 })
114}
115
116pub fn apply_decision(body: &mut Value, decision: &RoutingDecision) {
118 if !decision.model_changed {
119 return;
120 }
121 if let Some(obj) = body.as_object_mut() {
122 obj.insert(
123 "model".to_string(),
124 Value::String(decision.routed_model.clone()),
125 );
126 }
127}
128
129pub fn classify_only(body: &Value) -> Option<(TaskClassification, intent_engine::IntentRoute)> {
131 let messages = body.get("messages")?;
132 let content = extract_last_user_content(messages)?;
133 let classification = intent_engine::classify(&content);
134 let route = intent_engine::route_intent(&content, &classification);
135 Some((classification, route))
136}
137
138fn extract_model(body: &Value) -> Option<String> {
141 body.get("model")
142 .and_then(Value::as_str)
143 .map(str::to_string)
144}
145
146fn extract_last_user_content(messages: &Value) -> Option<String> {
148 let arr = messages.as_array()?;
149 for msg in arr.iter().rev() {
150 let role = msg.get("role").and_then(Value::as_str)?;
151 if role != "user" {
152 continue;
153 }
154 match msg.get("content") {
156 Some(Value::String(s)) => return Some(s.clone()),
157 Some(Value::Array(blocks)) => {
158 let text: String = blocks
159 .iter()
160 .filter_map(|b| {
161 if b.get("type").and_then(Value::as_str) == Some("text") {
162 b.get("text").and_then(Value::as_str)
163 } else {
164 None
165 }
166 })
167 .collect::<Vec<_>>()
168 .join("\n");
169 if !text.is_empty() {
170 return Some(text);
171 }
172 }
173 _ => {}
174 }
175 }
176 None
177}
178
179fn estimate_cost_ratio(original: &str, routed: &str) -> Option<f64> {
182 let orig_cost = model_cost_tier(original)?;
183 let routed_cost = model_cost_tier(routed)?;
184 if orig_cost == 0.0 {
185 return None;
186 }
187 Some(routed_cost / orig_cost)
188}
189
190fn model_cost_tier(model: &str) -> Option<f64> {
194 let m = model.to_lowercase();
195 if m.contains("nano") || m.contains("gpt-4.1-nano") {
196 Some(0.04)
197 } else if m.contains("haiku")
198 || m.contains("flash")
199 || m.contains("4o-mini")
200 || m.contains("4.1-mini")
201 || m.contains("deepseek")
202 {
203 Some(0.2)
204 } else if m.contains("opus") || m.contains("o3-pro") || m.contains("o1-pro") {
205 Some(5.0)
206 } else if m.contains("o3") || m.contains("o1") || m.contains("gpt-5") {
207 Some(2.5)
208 } else if m.contains("sonnet")
209 || m.contains("gpt-4o")
210 || m.contains("gemini-2.5-pro")
211 || m.contains("gemini-2.0-pro")
212 {
213 Some(1.0)
214 } else {
215 None
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use serde_json::json;
223 use std::collections::BTreeMap;
224
225 fn active_rules(tiers: &[(&str, &str)]) -> RoutingRules {
226 RoutingRules {
227 enabled: Some(true),
228 aliases: BTreeMap::new(),
229 tiers: tiers
230 .iter()
231 .map(|(k, v)| (k.to_string(), v.to_string()))
232 .collect(),
233 }
234 }
235
236 fn request_body(model: &str, user_message: &str) -> Value {
237 json!({
238 "model": model,
239 "messages": [
240 {"role": "user", "content": user_message}
241 ]
242 })
243 }
244
245 #[test]
248 fn inactive_routing_returns_none() {
249 let rules = RoutingRules::default();
250 let body = request_body("claude-sonnet-4", "fix the bug");
251 assert!(route(&body, &rules).is_none());
252 }
253
254 #[test]
255 fn empty_tiers_returns_none() {
256 let rules = RoutingRules {
257 enabled: Some(true),
258 aliases: BTreeMap::new(),
259 tiers: BTreeMap::new(),
260 };
261 let body = request_body("claude-sonnet-4", "fix the bug");
262 assert!(route(&body, &rules).is_none());
263 }
264
265 #[test]
266 fn no_model_field_returns_none() {
267 let body = json!({"messages": [{"role": "user", "content": "hi"}]});
268 let rules = active_rules(&[("fast", "x")]);
269 assert!(route(&body, &rules).is_none());
270 }
271
272 #[test]
273 fn no_user_messages_returns_none() {
274 let body = json!({
275 "model": "claude-sonnet-4",
276 "messages": [{"role": "assistant", "content": "hello"}]
277 });
278 let rules = active_rules(&[("fast", "x")]);
279 assert!(route(&body, &rules).is_none());
280 }
281
282 #[test]
283 fn alias_takes_priority_over_tier() {
284 let mut rules = active_rules(&[("fast", "anthropic:claude-haiku-4-5")]);
285 rules
286 .aliases
287 .insert("my-model".to_string(), "openai:gpt-4o".to_string());
288 let body = request_body("my-model", "explain the code");
289 assert!(route(&body, &rules).is_none(), "alias exempts from tiers");
290 }
291
292 #[test]
295 fn fast_tier_downgrades_explore_queries() {
296 let rules = active_rules(&[("fast", "anthropic:claude-haiku-4-5")]);
298 let body = request_body("claude-sonnet-4", "explain how the cache works");
299 let decision = route(&body, &rules).expect("should route");
300
301 assert_eq!(decision.requested_model, "claude-sonnet-4");
302 assert_eq!(decision.routed_model, "claude-haiku-4-5");
303 assert_eq!(decision.routed_provider.as_deref(), Some("anthropic"));
304 assert_eq!(decision.tier, "fast");
305 assert!(decision.model_changed);
306 assert!(decision.confidence > 0.5);
307 }
308
309 #[test]
310 fn premium_tier_upgrades_generation_tasks() {
311 let rules = active_rules(&[("premium", "anthropic:claude-opus-4")]);
312 let body = request_body("claude-sonnet-4", "implement a new auth module with JWT");
313 let decision = route(&body, &rules).expect("should route");
314
315 assert_eq!(decision.routed_model, "claude-opus-4");
316 assert_eq!(decision.tier, "premium");
317 assert!(decision.model_changed);
318 }
319
320 #[test]
321 fn standard_tier_for_fixbug() {
322 let rules = active_rules(&[
324 ("fast", "claude-haiku-4-5"),
325 ("standard", "claude-sonnet-4"),
326 ("premium", "claude-opus-4"),
327 ]);
328 let body = request_body("claude-opus-4", "fix the bug in auth.rs");
329 let decision = route(&body, &rules).expect("should route");
330
331 assert_eq!(decision.tier, "standard");
332 assert_eq!(decision.routed_model, "claude-sonnet-4");
333 }
334
335 #[test]
336 fn missing_tier_key_is_passthrough() {
337 let rules = active_rules(&[("fast", "anthropic:claude-haiku-4-5")]);
339 let body = request_body("claude-sonnet-4", "fix the null pointer bug in auth.rs");
340 assert!(route(&body, &rules).is_none());
341 }
342
343 #[test]
344 fn empty_tier_target_keeps_model() {
345 let rules = active_rules(&[("fast", "")]);
347 let body = request_body("claude-sonnet-4", "explain and describe this function");
348 let decision = route(&body, &rules).expect("should route");
349
350 assert_eq!(decision.routed_model, "claude-sonnet-4");
351 assert!(!decision.model_changed);
352 assert_eq!(decision.tier, "fast");
353 }
354
355 #[test]
356 fn model_only_target_keeps_provider() {
357 let rules = active_rules(&[("fast", "claude-haiku-4-5")]);
358 let body = request_body("claude-sonnet-4", "explain what this function does");
359 let decision = route(&body, &rules).expect("should route");
360
361 assert_eq!(decision.routed_model, "claude-haiku-4-5");
362 assert_eq!(decision.routed_provider, None, "no provider override");
363 assert!(decision.model_changed);
364 }
365
366 #[test]
369 fn apply_decision_rewrites_body() {
370 let mut body = request_body("claude-sonnet-4", "explain");
371 let decision = RoutingDecision {
372 requested_model: "claude-sonnet-4".into(),
373 routed_model: "claude-haiku-4-5".into(),
374 routed_provider: Some("anthropic".into()),
375 tier: "fast".into(),
376 confidence: 0.85,
377 reasoning: "explore(what) + low complexity -> fast".into(),
378 model_changed: true,
379 estimated_cost_ratio: Some(0.2),
380 };
381 apply_decision(&mut body, &decision);
382 assert_eq!(body["model"], "claude-haiku-4-5");
383 }
384
385 #[test]
386 fn apply_decision_noop_when_unchanged() {
387 let mut body = request_body("claude-sonnet-4", "explain");
388 let decision = RoutingDecision {
389 requested_model: "claude-sonnet-4".into(),
390 routed_model: "claude-sonnet-4".into(),
391 routed_provider: None,
392 tier: "standard".into(),
393 confidence: 0.8,
394 reasoning: "fix_bug(how) -> standard".into(),
395 model_changed: false,
396 estimated_cost_ratio: None,
397 };
398 apply_decision(&mut body, &decision);
399 assert_eq!(body["model"], "claude-sonnet-4");
400 }
401
402 #[test]
405 fn cost_ratio_estimates_downgrade_savings() {
406 let rules = active_rules(&[("fast", "claude-haiku-4-5")]);
407 let body = request_body("claude-sonnet-4", "explain what this module does");
408 let decision = route(&body, &rules).expect("should route");
409 let ratio = decision.estimated_cost_ratio.expect("known models");
410 assert!(ratio < 1.0, "haiku cheaper than sonnet: {ratio}");
411 assert!(ratio > 0.0);
412 }
413
414 #[test]
415 fn cost_ratio_none_for_unknown_models() {
416 let rules = active_rules(&[("fast", "custom-local-model")]);
417 let body = request_body("claude-sonnet-4", "explain what this code does");
418 let decision = route(&body, &rules).expect("should route");
419 assert_eq!(decision.estimated_cost_ratio, None);
420 }
421
422 #[test]
423 fn cost_tiers_are_ordered() {
424 assert!(
425 model_cost_tier("claude-opus-4").unwrap() > model_cost_tier("claude-sonnet-4").unwrap()
426 );
427 assert!(
428 model_cost_tier("claude-sonnet-4").unwrap()
429 > model_cost_tier("claude-haiku-4-5").unwrap()
430 );
431 assert!(model_cost_tier("gpt-4o").unwrap() > model_cost_tier("gpt-4o-mini").unwrap());
432 }
433
434 #[test]
437 fn multipart_content_blocks_extracted() {
438 let body = json!({
439 "model": "claude-sonnet-4",
440 "messages": [{
441 "role": "user",
442 "content": [
443 {"type": "text", "text": "explain how the session cache works"},
444 {"type": "image", "source": {"type": "base64"}}
445 ]
446 }]
447 });
448 let rules = active_rules(&[("fast", "claude-haiku-4-5")]);
449 let decision = route(&body, &rules).expect("should extract text blocks");
450 assert_eq!(decision.tier, "fast");
451 }
452
453 #[test]
456 fn decision_is_deterministic() {
457 let rules = active_rules(&[
458 ("fast", "claude-haiku-4-5"),
459 ("standard", "claude-sonnet-4"),
460 ("premium", "claude-opus-4"),
461 ]);
462 let body = request_body("claude-sonnet-4", "explain how the proxy routing works");
463 let d1 = route(&body, &rules);
464 let d2 = route(&body, &rules);
465 assert_eq!(d1, d2, "routing must be deterministic (#498)");
466 }
467}