1use async_trait::async_trait;
11
12use crate::composite::ChildModel;
13use crate::error::AiMuxError;
14use crate::language_model::LanguageModel;
15use crate::language_model_message::LanguageModelPrompt;
16use crate::options::CallOptions;
17use crate::result::{GenerateResult, StreamResult};
18use crate::retry;
19
20pub trait Router: Send + Sync {
31 fn route(
38 &self,
39 prompt: &LanguageModelPrompt,
40 models: &[ChildModel],
41 ) -> Result<usize, AiMuxError>;
42}
43
44pub struct RuleRouter;
47
48impl Router for RuleRouter {
49 fn route(
50 &self,
51 _prompt: &LanguageModelPrompt,
52 models: &[ChildModel],
53 ) -> Result<usize, AiMuxError> {
54 if models.is_empty() {
55 return Err(AiMuxError::Other("router: no models configured".into()));
56 }
57 Ok(0)
58 }
59}
60
61pub struct WeightedRouter {
68 weights: Vec<f64>,
69}
70
71impl WeightedRouter {
72 #[must_use]
75 pub fn new(weights: Vec<f64>) -> Self {
76 Self { weights }
77 }
78}
79
80impl Router for WeightedRouter {
81 fn route(
82 &self,
83 _prompt: &LanguageModelPrompt,
84 models: &[ChildModel],
85 ) -> Result<usize, AiMuxError> {
86 if models.is_empty() {
87 return Err(AiMuxError::Other("router: no models configured".into()));
88 }
89 let mut best_idx = 0;
93 let mut best_weight = *self.weights.first().unwrap_or(&0.0);
94 for i in 1..models.len() {
95 let w = *self.weights.get(i).unwrap_or(&0.0);
96 if w > best_weight {
97 best_idx = i;
98 best_weight = w;
99 }
100 }
101 Ok(best_idx)
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111pub enum FallbackPolicy {
112 #[default]
114 OnError,
115 None,
117}
118
119#[derive(Debug, Clone)]
120pub struct RouterConfig {
121 pub provider_name: String,
123 pub model_id: String,
125}
126
127impl Default for RouterConfig {
128 fn default() -> Self {
129 Self {
130 provider_name: "router".into(),
131 model_id: "router".into(),
132 }
133 }
134}
135
136pub struct RouterModel {
143 models: Vec<ChildModel>,
144 router: Box<dyn Router>,
145 fallback: FallbackPolicy,
146 config: RouterConfig,
147}
148
149impl RouterModel {
150 #[must_use]
151 pub fn new(
152 models: Vec<ChildModel>,
153 router: Box<dyn Router>,
154 fallback: FallbackPolicy,
155 config: RouterConfig,
156 ) -> Self {
157 Self {
158 models,
159 router,
160 fallback,
161 config,
162 }
163 }
164
165 async fn fallback_generate(
170 &self,
171 exclude: usize,
172 options: &CallOptions,
173 primary_err: AiMuxError,
174 ) -> Result<GenerateResult, AiMuxError> {
175 let mut last_err = Some(primary_err);
176 for (i, m) in self.models.iter().enumerate() {
177 if i == exclude {
178 continue;
179 }
180 let retries = retry::prepare_retries(
181 options.max_retries,
182 m.retry_config(),
183 options.abort_signal.clone(),
184 );
185 let child_options = options.for_step(self.step_label(i));
186 match retries
187 .retry(|| {
188 if let Some(context) = &child_options.recording_context {
189 let _ = context.start_attempt();
190 }
191 m.do_generate(&child_options)
192 })
193 .await
194 {
195 Ok(r) => return Ok(r),
196 Err(e) => last_err = Some(e),
197 }
198 }
199 Err(last_err.expect("seeded primary_err makes last_err always Some"))
200 }
201
202 fn step_label(&self, idx: usize) -> String {
203 let m = &self.models[idx];
204 format!("router[{idx}]:{}/{}", m.provider(), m.model_id())
205 }
206
207 fn check_index(&self, idx: usize, from: &str) -> Result<usize, AiMuxError> {
211 if idx < self.models.len() {
212 Ok(idx)
213 } else {
214 Err(AiMuxError::InvalidArgument(format!(
215 "{from}: router returned out-of-bounds index {idx} (models: {})",
216 self.models.len()
217 )))
218 }
219 }
220}
221
222#[async_trait]
223impl LanguageModel for RouterModel {
224 fn provider(&self) -> &str {
225 &self.config.provider_name
226 }
227
228 fn model_id(&self) -> &str {
229 &self.config.model_id
230 }
231
232 fn retry_config(&self) -> retry::RetryConfig {
233 retry::RetryConfig {
236 max_retries: 0,
237 ..retry::RetryConfig::default()
238 }
239 }
240
241 async fn do_generate(&self, options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
242 let raw = self.router.route(&options.prompt, &self.models)?;
243 let idx = self.check_index(raw, "router")?;
244 let model = &self.models[idx];
245 let retries = retry::prepare_retries(
246 options.max_retries,
247 model.retry_config(),
248 options.abort_signal.clone(),
249 );
250 let child_options = options.for_step(self.step_label(idx));
251 match retries
252 .retry(|| {
253 if let Some(context) = &child_options.recording_context {
254 let _ = context.start_attempt();
255 }
256 model.do_generate(&child_options)
257 })
258 .await
259 {
260 Ok(r) => Ok(r),
261 Err(e) => {
262 if self.fallback == FallbackPolicy::OnError {
263 self.fallback_generate(idx, options, e).await
264 } else {
265 Err(e)
266 }
267 }
268 }
269 }
270
271 async fn do_stream(&self, options: &CallOptions) -> Result<StreamResult, AiMuxError> {
272 let raw = self.router.route(&options.prompt, &self.models)?;
273 let idx = self.check_index(raw, "router")?;
274 let model = &self.models[idx];
275 let retries = retry::prepare_retries(
276 options.max_retries,
277 model.retry_config(),
278 options.abort_signal.clone(),
279 );
280 let child_options = options.for_step(self.step_label(idx));
281 retries
284 .retry(|| {
285 if let Some(context) = &child_options.recording_context {
286 let _ = context.start_attempt();
287 }
288 model.do_stream(&child_options)
289 })
290 .await
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use crate::recording::{
298 HttpExchange, OutcomeRecord, ProviderRecord, Recorder, RecordingContext, ResponseRecord,
299 };
300 use crate::result::GenerateResult;
301 use crate::stream_part::StreamPart;
302 use crate::types::{FinishReason, FinishReasonUnified, Usage};
303 use async_trait::async_trait;
304 use futures::stream;
305 use std::sync::Arc;
306 use std::sync::atomic::{AtomicUsize, Ordering};
307
308 struct MockChild {
310 name: &'static str,
311 text: String,
312 fail: bool,
313 retry_failures: usize,
314 calls: Arc<AtomicUsize>,
315 }
316
317 #[async_trait]
318 impl LanguageModel for MockChild {
319 fn provider(&self) -> &str {
320 "mock"
321 }
322 fn model_id(&self) -> &str {
323 self.name
324 }
325 async fn do_generate(&self, _options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
326 let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
327 if self.fail {
328 return Err(AiMuxError::Other(format!("{} always fails", self.name)));
329 }
330 if attempt <= self.retry_failures {
331 return Err(retryable_error(self.name));
332 }
333 Ok(GenerateResult {
334 content: vec![crate::result::GenerateContent::Text {
335 text: self.text.clone(),
336 provider_metadata: None,
337 }],
338 finish_reason: FinishReason {
339 unified: FinishReasonUnified::Stop,
340 raw: None,
341 },
342 usage: Usage::default(),
343 warnings: vec![],
344 provider_metadata: None,
345 response: Default::default(),
346 request_body: None,
347 response_headers: None,
348 })
349 }
350 async fn do_stream(&self, _options: &CallOptions) -> Result<StreamResult, AiMuxError> {
351 let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
352 if self.fail {
353 return Err(AiMuxError::Other(format!("{} always fails", self.name)));
354 }
355 if attempt <= self.retry_failures {
356 return Err(retryable_error(self.name));
357 }
358 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
359 Ok(StreamPart::StreamStart { warnings: vec![] }),
360 Ok(StreamPart::TextDelta {
361 id: "t1".into(),
362 delta: self.text.clone(),
363 provider_metadata: None,
364 }),
365 Ok(StreamPart::Finish {
366 finish_reason: FinishReason {
367 unified: FinishReasonUnified::Stop,
368 raw: None,
369 },
370 usage: Usage::default(),
371 provider_metadata: None,
372 }),
373 ];
374 Ok(StreamResult {
375 stream: Box::pin(stream::iter(parts)),
376 request_body: None,
377 response_headers: None,
378 })
379 }
380 }
381
382 fn retryable_error(name: &str) -> AiMuxError {
383 AiMuxError::ApiCall(Box::new(crate::ApiCallError {
384 status_code: Some(503),
385 response_headers: Some(std::collections::HashMap::from([(
386 "retry-after-ms".into(),
387 "0".into(),
388 )])),
389 is_retryable: true,
390 ..crate::ApiCallError::new(
391 format!("{name} retryable failure"),
392 "https://example.test",
393 serde_json::json!({}),
394 )
395 }))
396 }
397
398 fn retry_child(
399 name: &'static str,
400 text: &str,
401 retry_failures: usize,
402 ) -> (ChildModel, Arc<AtomicUsize>) {
403 let calls = Arc::new(AtomicUsize::new(0));
404 (
405 Arc::new(MockChild {
406 name,
407 text: text.into(),
408 fail: false,
409 retry_failures,
410 calls: calls.clone(),
411 }),
412 calls,
413 )
414 }
415
416 struct CountingRuleRouter(Arc<AtomicUsize>);
417
418 impl Router for CountingRuleRouter {
419 fn route(
420 &self,
421 prompt: &LanguageModelPrompt,
422 models: &[ChildModel],
423 ) -> Result<usize, AiMuxError> {
424 self.0.fetch_add(1, Ordering::SeqCst);
425 RuleRouter.route(prompt, models)
426 }
427 }
428
429 fn child(name: &'static str, text: &str, fail: bool) -> ChildModel {
430 Arc::new(MockChild {
431 name,
432 text: text.into(),
433 fail,
434 retry_failures: 0,
435 calls: Arc::new(AtomicUsize::new(0)),
436 })
437 }
438
439 fn opts() -> CallOptions {
440 CallOptions {
441 prompt: vec![],
442 ..Default::default()
443 }
444 }
445
446 #[tokio::test]
447 async fn routes_to_first_with_rule_router() {
448 let router = RouterModel::new(
449 vec![child("a", "from-a", false), child("b", "from-b", false)],
450 Box::new(RuleRouter),
451 FallbackPolicy::OnError,
452 RouterConfig::default(),
453 );
454 let r = router.do_generate(&opts()).await.unwrap();
455 let text = crate::composite::extract_text(&r.content);
456 assert_eq!(text, "from-a");
457 }
458
459 #[tokio::test]
460 async fn weighted_router_picks_max() {
461 let router = RouterModel::new(
462 vec![child("a", "from-a", false), child("b", "from-b", false)],
463 Box::new(WeightedRouter::new(vec![1.0, 5.0])),
464 FallbackPolicy::None,
465 RouterConfig::default(),
466 );
467 let r = router.do_generate(&opts()).await.unwrap();
468 assert_eq!(crate::composite::extract_text(&r.content), "from-b");
469 }
470
471 #[tokio::test]
472 async fn fallback_on_error_walks_remaining() {
473 let router = RouterModel::new(
475 vec![
476 child("a", "from-a", true),
477 child("b", "from-b", true),
478 child("c", "from-c", false),
479 ],
480 Box::new(RuleRouter),
481 FallbackPolicy::OnError,
482 RouterConfig::default(),
483 );
484 let r = router.do_generate(&opts()).await.unwrap();
485 assert_eq!(crate::composite::extract_text(&r.content), "from-c");
486 }
487
488 #[tokio::test]
489 async fn fallback_none_propagates_error() {
490 let router = RouterModel::new(
491 vec![child("a", "from-a", true), child("b", "from-b", false)],
492 Box::new(RuleRouter),
493 FallbackPolicy::None,
494 RouterConfig::default(),
495 );
496 let err = router.do_generate(&opts()).await.unwrap_err();
497 assert!(err.to_string().contains("always fails"));
498 }
499
500 #[tokio::test]
501 async fn all_fail_returns_last_error() {
502 let router = RouterModel::new(
503 vec![child("a", "from-a", true), child("b", "from-b", true)],
504 Box::new(RuleRouter),
505 FallbackPolicy::OnError,
506 RouterConfig::default(),
507 );
508 let err = router.do_generate(&opts()).await.unwrap_err();
509 assert!(err.to_string().contains("always fails"));
510 }
511
512 #[tokio::test]
513 async fn stream_routes_without_fallback() {
514 let router = RouterModel::new(
517 vec![child("a", "from-a", false), child("b", "from-b", false)],
518 Box::new(RuleRouter),
519 FallbackPolicy::OnError,
520 RouterConfig::default(),
521 );
522 let result = router.do_stream(&opts()).await.unwrap();
523 use futures::StreamExt;
524 let mut s = result.stream;
525 let mut text = String::new();
526 while let Some(part) = s.next().await {
527 if let Ok(StreamPart::TextDelta { delta, .. }) = part {
528 text.push_str(&delta);
529 }
530 }
531 assert_eq!(text, "from-a");
532 }
533
534 #[tokio::test]
535 async fn single_child_failure_preserves_real_error() {
536 let router = RouterModel::new(
539 vec![child("solo", "solo-out", true)],
540 Box::new(RuleRouter),
541 FallbackPolicy::OnError,
542 RouterConfig::default(),
543 );
544 let err = router.do_generate(&opts()).await.unwrap_err();
545 assert!(
546 err.to_string().contains("solo always fails"),
547 "expected the real child error, got: {err}"
548 );
549 }
550
551 #[tokio::test]
552 async fn single_child_success_works() {
553 let router = RouterModel::new(
554 vec![child("solo", "solo-out", false)],
555 Box::new(RuleRouter),
556 FallbackPolicy::OnError,
557 RouterConfig::default(),
558 );
559 let r = router.do_generate(&opts()).await.unwrap();
560 assert_eq!(crate::composite::extract_text(&r.content), "solo-out");
561 }
562
563 #[tokio::test]
564 async fn out_of_bounds_router_index_is_invalid_argument() {
565 struct OobRouter;
568 impl Router for OobRouter {
569 fn route(
570 &self,
571 _prompt: &LanguageModelPrompt,
572 _models: &[ChildModel],
573 ) -> Result<usize, AiMuxError> {
574 Ok(99)
575 }
576 }
577 let router = RouterModel::new(
578 vec![child("a", "from-a", false)],
579 Box::new(OobRouter),
580 FallbackPolicy::OnError,
581 RouterConfig::default(),
582 );
583 let err = router.do_generate(&opts()).await.unwrap_err();
584 match err {
585 AiMuxError::InvalidArgument(msg) => {
586 assert!(msg.contains("out-of-bounds"), "got: {msg}");
587 }
588 other => panic!("expected InvalidArgument, got {other:?}"),
589 }
590 }
591
592 #[tokio::test]
593 async fn weighted_router_ties_pick_earliest() {
594 let router = RouterModel::new(
596 vec![child("a", "from-a", false), child("b", "from-b", false)],
597 Box::new(WeightedRouter::new(vec![1.0, 1.0])),
598 FallbackPolicy::None,
599 RouterConfig::default(),
600 );
601 let r = router.do_generate(&opts()).await.unwrap();
602 assert_eq!(crate::composite::extract_text(&r.content), "from-a");
603 }
604
605 #[tokio::test]
606 async fn empty_models_is_error() {
607 let router = RouterModel::new(
608 vec![],
609 Box::new(RuleRouter),
610 FallbackPolicy::OnError,
611 RouterConfig::default(),
612 );
613 let err = router.do_generate(&opts()).await.unwrap_err();
614 assert!(err.to_string().contains("no models"));
615 }
616
617 #[tokio::test]
618 async fn stream_does_not_fallback_when_routed_child_fails() {
619 let router = RouterModel::new(
623 vec![
624 child("primary", "primary-out", true),
625 child("backup", "backup-out", false),
626 ],
627 Box::new(RuleRouter),
628 FallbackPolicy::OnError, RouterConfig::default(),
630 );
631 let err = router.do_stream(&opts()).await.unwrap_err();
632 assert!(
633 err.to_string().contains("primary always fails"),
634 "do_stream must surface the primary error, not fall back; got: {err}"
635 );
636 }
637
638 #[tokio::test]
639 async fn usable_as_dyn_language_model_via_generate_text() {
640 let router = RouterModel::new(
643 vec![child("a", "works through trait object", false)],
644 Box::new(RuleRouter),
645 FallbackPolicy::OnError,
646 RouterConfig::default(),
647 );
648 let r = crate::generate::generate_text(
649 &router,
650 "hello",
651 crate::generate::GenerateTextOptions::default(),
652 )
653 .await
654 .unwrap();
655 assert_eq!(r.text, "works through trait object");
656 }
657
658 #[tokio::test]
659 async fn retries_each_child_before_falling_back_without_rerouting() {
660 let (primary, primary_calls) = retry_child("primary", "primary", usize::MAX);
661 let (backup, backup_calls) = retry_child("backup", "backup", 1);
662 let route_calls = Arc::new(AtomicUsize::new(0));
663 let router = RouterModel::new(
664 vec![primary, backup],
665 Box::new(CountingRuleRouter(route_calls.clone())),
666 FallbackPolicy::OnError,
667 RouterConfig::default(),
668 );
669 assert_eq!(router.retry_config().max_retries, 0);
670
671 let result = crate::generate::generate_text(
672 &router,
673 "hello",
674 crate::generate::GenerateTextOptions {
675 max_retries: Some(2),
676 ..Default::default()
677 },
678 )
679 .await
680 .unwrap();
681
682 assert_eq!(result.text, "backup");
683 assert_eq!(route_calls.load(Ordering::SeqCst), 1);
684 assert_eq!(primary_calls.load(Ordering::SeqCst), 3);
685 assert_eq!(backup_calls.load(Ordering::SeqCst), 2);
686 }
687
688 #[tokio::test]
691 async fn children_receive_step_labeled_recording_contexts() {
692 struct StepProbe {
693 seen: std::sync::Mutex<Vec<Option<String>>>,
694 }
695
696 #[async_trait]
697 impl LanguageModel for StepProbe {
698 fn provider(&self) -> &str {
699 "mock"
700 }
701 fn model_id(&self) -> &str {
702 "probe"
703 }
704 async fn do_generate(
705 &self,
706 options: &CallOptions,
707 ) -> Result<GenerateResult, AiMuxError> {
708 self.seen.lock().unwrap().push(
709 options
710 .recording_context
711 .as_ref()
712 .and_then(RecordingContext::step),
713 );
714 Err(AiMuxError::Other("probe fails".into()))
715 }
716 async fn do_stream(&self, _: &CallOptions) -> Result<StreamResult, AiMuxError> {
717 unreachable!()
718 }
719 }
720
721 struct NoopRecorder;
722 impl Recorder for NoopRecorder {
723 fn record_input(&self, _: &str, _: &CallOptions, _: &str, _: &str) {}
724 fn record_provider(&self, _: &str, _: &ProviderRecord) {}
725 fn record_exchange(&self, _: &str, _: &HttpExchange) {}
726 fn record_exchange_update(
727 &self,
728 _: &str,
729 _: u32,
730 _: u32,
731 _: &ResponseRecord,
732 _: Option<String>,
733 ) {
734 }
735 fn record_outcome(&self, _: &str, _: &OutcomeRecord) {}
736 fn flush(&self) {}
737 }
738
739 let probe = Arc::new(StepProbe {
740 seen: std::sync::Mutex::new(Vec::new()),
741 });
742 let router = RouterModel::new(
743 vec![probe.clone()],
744 Box::new(CountingRuleRouter(Arc::new(AtomicUsize::new(0)))),
745 FallbackPolicy::OnError,
746 RouterConfig::default(),
747 );
748
749 let options = CallOptions {
750 recording_context: Some(RecordingContext::new("call-1", Arc::new(NoopRecorder))),
751 ..CallOptions::default()
752 };
753 let _ = router.do_generate(&options).await;
754
755 let seen = probe.seen.lock().unwrap();
756 assert!(!seen.is_empty());
757 assert_eq!(seen[0].as_deref(), Some("router[0]:mock/probe"));
758 }
759
760 #[tokio::test]
765 async fn all_children_failing_does_not_replay_the_composite() {
766 let (primary, primary_calls) = retry_child("primary", "primary", usize::MAX);
767 let (backup, backup_calls) = retry_child("backup", "backup", usize::MAX);
768 let route_calls = Arc::new(AtomicUsize::new(0));
769 let router = RouterModel::new(
770 vec![primary, backup],
771 Box::new(CountingRuleRouter(route_calls.clone())),
772 FallbackPolicy::OnError,
773 RouterConfig::default(),
774 );
775
776 let error = crate::generate::generate_text(
777 &router,
778 "hello",
779 crate::generate::GenerateTextOptions {
780 max_retries: Some(2),
781 ..Default::default()
782 },
783 )
784 .await
785 .unwrap_err();
786
787 assert!(matches!(error, crate::AiMuxError::Retry(_)), "{error:?}");
788 assert_eq!(route_calls.load(Ordering::SeqCst), 1);
789 assert_eq!(primary_calls.load(Ordering::SeqCst), 3);
790 assert_eq!(backup_calls.load(Ordering::SeqCst), 3);
791 }
792
793 #[tokio::test]
794 async fn retries_stream_setup_on_selected_child_without_fallback_or_rerouting() {
795 let (primary, primary_stream_calls) = retry_child("primary", "primary", 1);
796 let (backup, backup_stream_calls) = retry_child("backup", "backup", 0);
797 let route_calls = Arc::new(AtomicUsize::new(0));
798 let router = RouterModel::new(
799 vec![primary, backup],
800 Box::new(CountingRuleRouter(route_calls.clone())),
801 FallbackPolicy::OnError,
802 RouterConfig::default(),
803 );
804
805 let _result = crate::generate::stream_text(
806 &router,
807 "hello",
808 crate::generate::GenerateTextOptions {
809 max_retries: Some(2),
810 ..Default::default()
811 },
812 )
813 .await
814 .unwrap();
815
816 assert_eq!(route_calls.load(Ordering::SeqCst), 1);
817 assert_eq!(primary_stream_calls.load(Ordering::SeqCst), 2);
818 assert_eq!(backup_stream_calls.load(Ordering::SeqCst), 0);
819 }
820}