1use std::collections::HashMap;
14
15use crate::embeddings::{Embedder, EmbeddingError};
16use crate::vector_store::VectorStore;
17
18#[derive(Debug, Clone)]
20pub struct RoutingDecision {
21 pub tier: u8,
23 pub model: String,
25 pub reason: String,
27 pub complexity_score: f32,
29}
30
31#[derive(Debug, Clone)]
33pub struct CostRecord {
34 pub model: String,
36 pub tokens: u64,
38 pub cost: f32,
40 pub latency_ms: u64,
42 pub timestamp: u64,
44}
45
46#[derive(Debug, Clone)]
48pub struct ModelStats {
49 pub total_calls: u64,
51 pub total_cost: f32,
53 pub avg_latency_ms: f64,
55}
56
57#[derive(Debug, Clone, Default)]
59pub struct RoutingContext {
60 pub tags: Vec<String>,
62}
63
64#[non_exhaustive]
66#[derive(Debug)]
67pub enum RoutingError {
68 Embedding(EmbeddingError),
70}
71
72impl std::fmt::Display for RoutingError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 RoutingError::Embedding(e) => write!(f, "routing embedding error: {e}"),
76 }
77 }
78}
79
80impl std::error::Error for RoutingError {}
81
82impl From<EmbeddingError> for RoutingError {
83 fn from(e: EmbeddingError) -> Self {
84 RoutingError::Embedding(e)
85 }
86}
87
88pub struct IntelligentRouter {
93 policy_store: VectorStore,
94 embedder: Box<dyn Embedder>,
95 cost_records: Vec<CostRecord>,
96}
97
98impl IntelligentRouter {
99 pub async fn new(embedder: Box<dyn Embedder>) -> Self {
101 Self {
102 policy_store: VectorStore::new(),
103 embedder,
104 cost_records: Vec::new(),
105 }
106 }
107
108 pub async fn route_request(
115 &self,
116 prompt: &str,
117 context: &RoutingContext,
118 ) -> Result<RoutingDecision, RoutingError> {
119 if context.tags.iter().any(|t| t == "AGENT_BOOSTER_AVAILABLE") {
121 return Ok(RoutingDecision {
122 tier: 1,
123 model: "agent-booster-wasm".into(),
124 reason: "AGENT_BOOSTER_AVAILABLE tag present".into(),
125 complexity_score: 0.0,
126 });
127 }
128
129 let prompt_embedding = self.embedder.embed(prompt).await?;
131 let policy_results = self.policy_store.search(&prompt_embedding, 5);
132
133 if let Some(best) = policy_results.first()
134 && best.score > 0.85
135 && let Some(tier_tag) = best.tags.first()
136 && let Ok(tier) = tier_tag.parse::<u8>()
137 {
138 let model = tier_to_model(tier);
139 return Ok(RoutingDecision {
140 tier,
141 model,
142 reason: format!(
143 "cached policy match (score={:.3}): {}",
144 best.score, best.text
145 ),
146 complexity_score: compute_complexity(prompt),
147 });
148 }
149
150 let complexity = compute_complexity(prompt);
152 if complexity < 0.30 {
153 Ok(RoutingDecision {
154 tier: 2,
155 model: "claude-haiku-3.5".into(),
156 reason: format!("low complexity ({complexity:.3}) -> Tier 2"),
157 complexity_score: complexity,
158 })
159 } else {
160 Ok(RoutingDecision {
161 tier: 3,
162 model: "claude-sonnet-4.5".into(),
163 reason: format!("high complexity ({complexity:.3}) -> Tier 3"),
164 complexity_score: complexity,
165 })
166 }
167 }
168
169 pub async fn record_cost(&mut self, model: &str, tokens: u64, cost: f32, latency_ms: u64) {
171 let timestamp = std::time::SystemTime::now()
172 .duration_since(std::time::UNIX_EPOCH)
173 .unwrap_or_default()
174 .as_secs();
175
176 self.cost_records.push(CostRecord {
177 model: model.into(),
178 tokens,
179 cost,
180 latency_ms,
181 timestamp,
182 });
183 }
184
185 pub async fn update_policy(&mut self, pattern: &str, tier: u8, success: bool) {
190 if !success {
191 return;
193 }
194
195 let embedding = match self.embedder.embed(pattern).await {
196 Ok(e) => e,
197 Err(_) => return,
198 };
199
200 let id = format!("policy-{}", self.policy_store.len());
201 self.policy_store.add(
202 id,
203 pattern.into(),
204 embedding,
205 vec![tier.to_string()],
206 HashMap::new(),
207 );
208 }
209
210 pub fn get_model_stats(&self, model: &str) -> ModelStats {
212 let matching: Vec<&CostRecord> = self
213 .cost_records
214 .iter()
215 .filter(|r| r.model == model)
216 .collect();
217
218 let total_calls = matching.len() as u64;
219 let total_cost: f32 = matching.iter().map(|r| r.cost).sum();
220 let avg_latency_ms = if matching.is_empty() {
221 0.0
222 } else {
223 matching.iter().map(|r| r.latency_ms as f64).sum::<f64>() / matching.len() as f64
224 };
225
226 ModelStats {
227 total_calls,
228 total_cost,
229 avg_latency_ms,
230 }
231 }
232}
233
234pub fn compute_complexity(prompt: &str) -> f32 {
243 let lower = prompt.to_lowercase();
244 let word_count = lower.split_whitespace().count();
245
246 let base = (word_count as f32 / 1000.0).min(0.3);
248
249 let code_bonus = if lower.contains("```") { 0.2 } else { 0.0 };
251
252 let reasoning_words = ["analyze", "design", "architect", "explain", "compare"];
254 let reasoning_bonus = if reasoning_words.iter().any(|w| lower.contains(w)) {
255 0.1
256 } else {
257 0.0
258 };
259
260 let step_words = ["first", "then", "finally", "step 1"];
262 let step_bonus = if step_words.iter().any(|w| lower.contains(w)) {
263 0.2
264 } else {
265 0.0
266 };
267
268 (base + code_bonus + reasoning_bonus + step_bonus).clamp(0.0, 1.0)
269}
270
271fn tier_to_model(tier: u8) -> String {
273 match tier {
274 1 => "agent-booster-wasm".into(),
275 2 => "claude-haiku-3.5".into(),
276 _ => "claude-sonnet-4.5".into(),
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::embeddings::hash_embedder::HashEmbedder;
284
285 fn make_embedder() -> Box<dyn Embedder> {
286 Box::new(HashEmbedder::new(64))
287 }
288
289 #[tokio::test]
292 async fn tier1_agent_booster_available() {
293 let router = IntelligentRouter::new(make_embedder()).await;
294 let ctx = RoutingContext {
295 tags: vec!["AGENT_BOOSTER_AVAILABLE".into()],
296 };
297
298 let decision = router.route_request("simple task", &ctx).await.unwrap();
299 assert_eq!(decision.tier, 1);
300 assert_eq!(decision.model, "agent-booster-wasm");
301 assert!(decision.reason.contains("AGENT_BOOSTER_AVAILABLE"));
302 }
303
304 #[tokio::test]
305 async fn tier1_booster_takes_priority_over_complexity() {
306 let router = IntelligentRouter::new(make_embedder()).await;
307 let ctx = RoutingContext {
308 tags: vec!["AGENT_BOOSTER_AVAILABLE".into()],
309 };
310
311 let decision = router
313 .route_request(
314 "Analyze and design a complex architecture with ```code``` first then finally",
315 &ctx,
316 )
317 .await
318 .unwrap();
319 assert_eq!(decision.tier, 1);
320 }
321
322 #[tokio::test]
325 async fn tier2_simple_prompt() {
326 let router = IntelligentRouter::new(make_embedder()).await;
327 let ctx = RoutingContext::default();
328
329 let decision = router.route_request("hello", &ctx).await.unwrap();
330 assert_eq!(decision.tier, 2);
331 assert_eq!(decision.model, "claude-haiku-3.5");
332 assert!(decision.complexity_score < 0.30);
333 }
334
335 #[tokio::test]
336 async fn tier2_short_question() {
337 let router = IntelligentRouter::new(make_embedder()).await;
338 let ctx = RoutingContext::default();
339
340 let decision = router.route_request("what is 2 + 2?", &ctx).await.unwrap();
341 assert_eq!(decision.tier, 2);
342 }
343
344 #[tokio::test]
347 async fn tier3_complex_prompt_with_code() {
348 let router = IntelligentRouter::new(make_embedder()).await;
349 let ctx = RoutingContext::default();
350
351 let decision = router
352 .route_request("explain this ```rust\nfn main() {}```", &ctx)
353 .await
354 .unwrap();
355 assert_eq!(decision.tier, 3);
356 assert_eq!(decision.model, "claude-sonnet-4.5");
357 assert!(decision.complexity_score >= 0.30);
358 }
359
360 #[tokio::test]
361 async fn tier3_complex_prompt_with_reasoning_and_code() {
362 let router = IntelligentRouter::new(make_embedder()).await;
363 let ctx = RoutingContext::default();
364
365 let decision = router
367 .route_request(
368 "analyze the performance of this ```rust\nfn main() {}``` and compare approaches",
369 &ctx,
370 )
371 .await
372 .unwrap();
373 assert_eq!(decision.tier, 3);
374 assert!(decision.complexity_score >= 0.30);
375 }
376
377 #[tokio::test]
378 async fn tier3_complex_prompt_with_steps_and_code() {
379 let router = IntelligentRouter::new(make_embedder()).await;
380 let ctx = RoutingContext::default();
381
382 let decision = router
384 .route_request(
385 "first set up the database schema ```sql\nCREATE TABLE users```, then create the API, finally deploy",
386 &ctx,
387 )
388 .await
389 .unwrap();
390 assert_eq!(decision.tier, 3);
391 assert!(decision.complexity_score >= 0.30);
392 }
393
394 #[tokio::test]
397 async fn policy_cache_update_and_match() {
398 let mut router = IntelligentRouter::new(make_embedder()).await;
399 let ctx = RoutingContext::default();
400
401 router
403 .update_policy("convert temperature from celsius to fahrenheit", 2, true)
404 .await;
405
406 let decision = router
408 .route_request("convert temperature from celsius to fahrenheit", &ctx)
409 .await
410 .unwrap();
411
412 assert_eq!(decision.tier, 2);
414 assert!(decision.reason.contains("cached policy match"));
415 }
416
417 #[tokio::test]
418 async fn policy_cache_failure_not_stored() {
419 let mut router = IntelligentRouter::new(make_embedder()).await;
420
421 router
423 .update_policy("test pattern that failed", 2, false)
424 .await;
425
426 assert!(router.policy_store.is_empty());
428 }
429
430 #[tokio::test]
433 async fn cost_recording_and_stats() {
434 let mut router = IntelligentRouter::new(make_embedder()).await;
435
436 router
437 .record_cost("claude-haiku-3.5", 100, 0.001, 500)
438 .await;
439 router
440 .record_cost("claude-haiku-3.5", 200, 0.002, 600)
441 .await;
442 router
443 .record_cost("claude-sonnet-4.5", 500, 0.01, 2000)
444 .await;
445
446 let haiku_stats = router.get_model_stats("claude-haiku-3.5");
447 assert_eq!(haiku_stats.total_calls, 2);
448 assert!((haiku_stats.total_cost - 0.003).abs() < 0.0001);
449 assert!((haiku_stats.avg_latency_ms - 550.0).abs() < 0.01);
450
451 let sonnet_stats = router.get_model_stats("claude-sonnet-4.5");
452 assert_eq!(sonnet_stats.total_calls, 1);
453 assert!((sonnet_stats.total_cost - 0.01).abs() < 0.0001);
454 assert!((sonnet_stats.avg_latency_ms - 2000.0).abs() < 0.01);
455 }
456
457 #[tokio::test]
458 async fn stats_for_unknown_model() {
459 let router = IntelligentRouter::new(make_embedder()).await;
460 let stats = router.get_model_stats("unknown-model");
461 assert_eq!(stats.total_calls, 0);
462 assert!((stats.total_cost - 0.0).abs() < f32::EPSILON);
463 assert!((stats.avg_latency_ms - 0.0).abs() < f64::EPSILON);
464 }
465
466 #[test]
469 fn complexity_empty_prompt() {
470 let c = compute_complexity("");
471 assert!((c - 0.0).abs() < f32::EPSILON);
472 }
473
474 #[test]
475 fn complexity_short_prompt() {
476 let c = compute_complexity("hello world");
477 assert!(c < 0.30, "short prompt should be low complexity: {c}");
478 }
479
480 #[test]
481 fn complexity_code_fences() {
482 let c = compute_complexity("look at this ```code here```");
483 assert!(c >= 0.2, "code fences should add 0.2: {c}");
484 }
485
486 #[test]
487 fn complexity_reasoning_words() {
488 let c = compute_complexity("analyze this data");
489 assert!(c >= 0.1, "reasoning words should add 0.1: {c}");
490 }
491
492 #[test]
493 fn complexity_multi_step() {
494 let c = compute_complexity("first do this then do that");
495 assert!(c >= 0.2, "multi-step indicators should add 0.2: {c}");
496 }
497
498 #[test]
499 fn complexity_all_heuristics() {
500 let c = compute_complexity(
501 "first analyze the code ```fn main()``` then design the architecture finally deploy",
502 );
503 assert!(c >= 0.5, "all heuristics should sum up: {c}");
505 assert!(c <= 1.0, "should be clamped to 1.0: {c}");
506 }
507
508 #[test]
509 fn complexity_clamped_to_max() {
510 let c = compute_complexity(
512 "first analyze then design the architect explain compare ```code``` finally step 1",
513 );
514 assert!(c <= 1.0, "must be clamped to 1.0: {c}");
515 }
516
517 #[test]
518 fn complexity_case_insensitive() {
519 let c1 = compute_complexity("ANALYZE this");
520 let c2 = compute_complexity("analyze this");
521 assert!(
522 (c1 - c2).abs() < f32::EPSILON,
523 "should be case-insensitive: {c1} vs {c2}"
524 );
525 }
526
527 #[test]
530 fn tier_to_model_mapping() {
531 assert_eq!(tier_to_model(1), "agent-booster-wasm");
532 assert_eq!(tier_to_model(2), "claude-haiku-3.5");
533 assert_eq!(tier_to_model(3), "claude-sonnet-4.5");
534 assert_eq!(tier_to_model(99), "claude-sonnet-4.5"); }
536
537 #[test]
540 fn routing_context_default() {
541 let ctx = RoutingContext::default();
542 assert!(ctx.tags.is_empty());
543 }
544
545 #[test]
548 fn routing_error_display() {
549 let err = RoutingError::Embedding(EmbeddingError::Internal("test".into()));
550 assert!(format!("{err}").contains("test"));
551 }
552}