Skip to main content

aimux_core/
router.rs

1//! `RouterModel` — a composite model that routes each call to one child and
2//! optionally falls back (RFC-0021).
3//!
4//! `RouterModel` implements [`LanguageModel`], so `generate_text` /
5//! `stream_text` and every binding use it unchanged. Routing decisions live in
6//! the pluggable [`Router`] trait (pure decision: prompt + child list → index);
7//! execution + fallback live here. Built-in strategies: [`RuleRouter`],
8//! [`WeightedRouter`].
9
10use 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};
18
19// ─────────────────────────────────────────────────────────────────────────────
20// Router trait
21// ─────────────────────────────────────────────────────────────────────────────
22
23/// Routing strategy: a pure decision over the prompt + child list. It does NOT
24/// execute the call — [`RouterModel`] owns execution and fallback.
25///
26/// Built-in implementations: [`RuleRouter`], [`WeightedRouter`]. Users can
27/// implement this to inject learned classifiers (e.g. RouteLLM via `ort`) —
28/// learned routing is intentionally out of core (see RFC-0021 §6.1).
29pub trait Router: Send + Sync {
30    /// Choose a child-model index. `Err` means "no child can serve this prompt".
31    ///
32    /// # Errors
33    ///
34    /// `Err` means no child model can serve this prompt (all children filtered
35    /// out).
36    fn route(
37        &self,
38        prompt: &LanguageModelPrompt,
39        models: &[ChildModel],
40    ) -> Result<usize, AiMuxError>;
41}
42
43/// Static-priority router: always pick child 0 (the primary); fallback walks the
44/// rest in array order. Equivalent to "primary + backups".
45pub struct RuleRouter;
46
47impl Router for RuleRouter {
48    fn route(
49        &self,
50        _prompt: &LanguageModelPrompt,
51        models: &[ChildModel],
52    ) -> Result<usize, AiMuxError> {
53        if models.is_empty() {
54            return Err(AiMuxError::Other("router: no models configured".into()));
55        }
56        Ok(0)
57    }
58}
59
60/// Weighted router: pick the child with the highest weight. On ties the
61/// **earliest** index wins (so all-equal weights behave like `RuleRouter` —
62/// always child 0). Missing trailing weights default to `0.0`. NaN at index > 0
63/// loses to any finite weight (NaN at index 0 wins only because nothing
64/// compares greater than NaN — avoid NaN weights). To route by cost
65/// (lowest-cost first), pass reciprocals or negative weights.
66pub struct WeightedRouter {
67    weights: Vec<f64>,
68}
69
70impl WeightedRouter {
71    /// Weights are positional — `weights[i]` applies to `models[i]`. Missing
72    /// trailing weights default to `0.0`.
73    #[must_use]
74    pub fn new(weights: Vec<f64>) -> Self {
75        Self { weights }
76    }
77}
78
79impl Router for WeightedRouter {
80    fn route(
81        &self,
82        _prompt: &LanguageModelPrompt,
83        models: &[ChildModel],
84    ) -> Result<usize, AiMuxError> {
85        if models.is_empty() {
86            return Err(AiMuxError::Other("router: no models configured".into()));
87        }
88        // Pick the child with the highest weight. On ties, prefer the earliest
89        // index (matches `RuleRouter`'s "child 0 first" expectation when all
90        // weights are equal). NaN weights lose to any finite weight.
91        let mut best_idx = 0;
92        let mut best_weight = *self.weights.first().unwrap_or(&0.0);
93        for i in 1..models.len() {
94            let w = *self.weights.get(i).unwrap_or(&0.0);
95            if w > best_weight {
96                best_idx = i;
97                best_weight = w;
98            }
99        }
100        Ok(best_idx)
101    }
102}
103
104// ─────────────────────────────────────────────────────────────────────────────
105// RouterModel
106// ─────────────────────────────────────────────────────────────────────────────
107
108/// When a routed call fails, should we try the next child?
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110pub enum FallbackPolicy {
111    /// On error, try the remaining children in array order (default).
112    #[default]
113    OnError,
114    /// The chosen child's failure is final.
115    None,
116}
117
118#[derive(Debug, Clone)]
119pub struct RouterConfig {
120    /// `provider()` value (default `"router"`).
121    pub provider_name: String,
122    /// `model_id()` value (default `"router"`).
123    pub model_id: String,
124}
125
126impl Default for RouterConfig {
127    fn default() -> Self {
128        Self {
129            provider_name: "router".into(),
130            model_id: "router".into(),
131        }
132    }
133}
134
135/// A composite model that routes each call to one child and (optionally) falls
136/// back across the rest on error.
137///
138/// Streaming does not fall back: by the time `do_stream` returns, `StreamStart`
139/// has been emitted to the user and a retry would duplicate tokens. This
140/// matches Hermes / LiteLLM (route first, delegate second, no mid-stream
141/// fallback). See RFC-0021 §3.3.
142pub 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    /// Try every child except `exclude` in array order; return the first `Ok`,
166    /// else the last error. `primary_err` seeds the error returned when there
167    /// are no fallback candidates (e.g. a single-child router) so the real
168    /// failure is never lost to a generic "all models failed".
169    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            match m.do_generate(options).await {
181                Ok(r) => return Ok(r),
182                Err(e) => last_err = Some(e),
183            }
184        }
185        Err(last_err.expect("seeded primary_err makes last_err always Some"))
186    }
187
188    /// Validate a `Router`-returned index before indexing `self.models`. A
189    /// buggy/hostile `Router` (user-implementable trait) must not panic the
190    /// process — surface it as `InvalidArgument` instead.
191    fn check_index(&self, idx: usize, from: &str) -> Result<usize, AiMuxError> {
192        if idx < self.models.len() {
193            Ok(idx)
194        } else {
195            Err(AiMuxError::InvalidArgument(format!(
196                "{from}: router returned out-of-bounds index {idx} (models: {})",
197                self.models.len()
198            )))
199        }
200    }
201}
202
203#[async_trait]
204impl LanguageModel for RouterModel {
205    fn provider(&self) -> &str {
206        &self.config.provider_name
207    }
208
209    fn model_id(&self) -> &str {
210        &self.config.model_id
211    }
212
213    async fn do_generate(&self, options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
214        let raw = self.router.route(&options.prompt, &self.models)?;
215        let idx = self.check_index(raw, "router")?;
216        match self.models[idx].do_generate(options).await {
217            Ok(r) => Ok(r),
218            Err(e) => {
219                if self.fallback == FallbackPolicy::OnError {
220                    self.fallback_generate(idx, options, e).await
221                } else {
222                    Err(e)
223                }
224            }
225        }
226    }
227
228    async fn do_stream(&self, options: &CallOptions) -> Result<StreamResult, AiMuxError> {
229        let raw = self.router.route(&options.prompt, &self.models)?;
230        let idx = self.check_index(raw, "router")?;
231        // Route first, delegate second. No mid-stream fallback (StreamStart is
232        // already emitted by the time we'd want to retry).
233        self.models[idx].do_stream(options).await
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::result::GenerateResult;
241    use crate::stream_part::StreamPart;
242    use crate::types::{FinishReason, FinishReasonUnified, Usage};
243    use async_trait::async_trait;
244    use futures::stream;
245    use std::sync::Arc;
246
247    /// A mock child that either succeeds with `text` or always fails.
248    struct MockChild {
249        name: &'static str,
250        text: String,
251        fail: bool,
252    }
253
254    #[async_trait]
255    impl LanguageModel for MockChild {
256        fn provider(&self) -> &str {
257            "mock"
258        }
259        fn model_id(&self) -> &str {
260            self.name
261        }
262        async fn do_generate(&self, _options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
263            if self.fail {
264                return Err(AiMuxError::Other(format!("{} always fails", self.name)));
265            }
266            Ok(GenerateResult {
267                content: vec![crate::result::GenerateContent::Text {
268                    text: self.text.clone(),
269                    provider_metadata: None,
270                }],
271                finish_reason: FinishReason {
272                    unified: FinishReasonUnified::Stop,
273                    raw: None,
274                },
275                usage: Usage::default(),
276                warnings: vec![],
277                provider_metadata: None,
278                response: Default::default(),
279                request_body: None,
280                response_headers: None,
281            })
282        }
283        async fn do_stream(&self, _options: &CallOptions) -> Result<StreamResult, AiMuxError> {
284            if self.fail {
285                return Err(AiMuxError::Other(format!("{} always fails", self.name)));
286            }
287            let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
288                Ok(StreamPart::StreamStart { warnings: vec![] }),
289                Ok(StreamPart::TextDelta {
290                    id: "t1".into(),
291                    delta: self.text.clone(),
292                    provider_metadata: None,
293                }),
294                Ok(StreamPart::Finish {
295                    finish_reason: FinishReason {
296                        unified: FinishReasonUnified::Stop,
297                        raw: None,
298                    },
299                    usage: Usage::default(),
300                    provider_metadata: None,
301                }),
302            ];
303            Ok(StreamResult {
304                stream: Box::pin(stream::iter(parts)),
305                request_body: None,
306                response_headers: None,
307            })
308        }
309    }
310
311    fn child(name: &'static str, text: &str, fail: bool) -> ChildModel {
312        Arc::new(MockChild {
313            name,
314            text: text.into(),
315            fail,
316        })
317    }
318
319    fn opts() -> CallOptions {
320        CallOptions {
321            prompt: vec![],
322            ..Default::default()
323        }
324    }
325
326    #[tokio::test]
327    async fn routes_to_first_with_rule_router() {
328        let router = RouterModel::new(
329            vec![child("a", "from-a", false), child("b", "from-b", false)],
330            Box::new(RuleRouter),
331            FallbackPolicy::OnError,
332            RouterConfig::default(),
333        );
334        let r = router.do_generate(&opts()).await.unwrap();
335        let text = crate::composite::extract_text(&r.content);
336        assert_eq!(text, "from-a");
337    }
338
339    #[tokio::test]
340    async fn weighted_router_picks_max() {
341        let router = RouterModel::new(
342            vec![child("a", "from-a", false), child("b", "from-b", false)],
343            Box::new(WeightedRouter::new(vec![1.0, 5.0])),
344            FallbackPolicy::None,
345            RouterConfig::default(),
346        );
347        let r = router.do_generate(&opts()).await.unwrap();
348        assert_eq!(crate::composite::extract_text(&r.content), "from-b");
349    }
350
351    #[tokio::test]
352    async fn fallback_on_error_walks_remaining() {
353        // Primary fails, second fails, third succeeds.
354        let router = RouterModel::new(
355            vec![
356                child("a", "from-a", true),
357                child("b", "from-b", true),
358                child("c", "from-c", false),
359            ],
360            Box::new(RuleRouter),
361            FallbackPolicy::OnError,
362            RouterConfig::default(),
363        );
364        let r = router.do_generate(&opts()).await.unwrap();
365        assert_eq!(crate::composite::extract_text(&r.content), "from-c");
366    }
367
368    #[tokio::test]
369    async fn fallback_none_propagates_error() {
370        let router = RouterModel::new(
371            vec![child("a", "from-a", true), child("b", "from-b", false)],
372            Box::new(RuleRouter),
373            FallbackPolicy::None,
374            RouterConfig::default(),
375        );
376        let err = router.do_generate(&opts()).await.unwrap_err();
377        assert!(err.to_string().contains("always fails"));
378    }
379
380    #[tokio::test]
381    async fn all_fail_returns_last_error() {
382        let router = RouterModel::new(
383            vec![child("a", "from-a", true), child("b", "from-b", true)],
384            Box::new(RuleRouter),
385            FallbackPolicy::OnError,
386            RouterConfig::default(),
387        );
388        let err = router.do_generate(&opts()).await.unwrap_err();
389        assert!(err.to_string().contains("always fails"));
390    }
391
392    #[tokio::test]
393    async fn stream_routes_without_fallback() {
394        // Primary is selected by RuleRouter; even though it would fail, stream
395        // does not fall back. Use a healthy primary to confirm the happy path.
396        let router = RouterModel::new(
397            vec![child("a", "from-a", false), child("b", "from-b", false)],
398            Box::new(RuleRouter),
399            FallbackPolicy::OnError,
400            RouterConfig::default(),
401        );
402        let result = router.do_stream(&opts()).await.unwrap();
403        use futures::StreamExt;
404        let mut s = result.stream;
405        let mut text = String::new();
406        while let Some(part) = s.next().await {
407            if let Ok(StreamPart::TextDelta { delta, .. }) = part {
408                text.push_str(&delta);
409            }
410        }
411        assert_eq!(text, "from-a");
412    }
413
414    #[tokio::test]
415    async fn single_child_failure_preserves_real_error() {
416        // B1 regression guard: a single failing child must surface its real
417        // error, not a generic "all models failed".
418        let router = RouterModel::new(
419            vec![child("solo", "solo-out", true)],
420            Box::new(RuleRouter),
421            FallbackPolicy::OnError,
422            RouterConfig::default(),
423        );
424        let err = router.do_generate(&opts()).await.unwrap_err();
425        assert!(
426            err.to_string().contains("solo always fails"),
427            "expected the real child error, got: {err}"
428        );
429    }
430
431    #[tokio::test]
432    async fn single_child_success_works() {
433        let router = RouterModel::new(
434            vec![child("solo", "solo-out", false)],
435            Box::new(RuleRouter),
436            FallbackPolicy::OnError,
437            RouterConfig::default(),
438        );
439        let r = router.do_generate(&opts()).await.unwrap();
440        assert_eq!(crate::composite::extract_text(&r.content), "solo-out");
441    }
442
443    #[tokio::test]
444    async fn out_of_bounds_router_index_is_invalid_argument() {
445        // A buggy/hostile Router returning an OOB index must not panic; it
446        // surfaces as InvalidArgument.
447        struct OobRouter;
448        impl Router for OobRouter {
449            fn route(
450                &self,
451                _prompt: &LanguageModelPrompt,
452                _models: &[ChildModel],
453            ) -> Result<usize, AiMuxError> {
454                Ok(99)
455            }
456        }
457        let router = RouterModel::new(
458            vec![child("a", "from-a", false)],
459            Box::new(OobRouter),
460            FallbackPolicy::OnError,
461            RouterConfig::default(),
462        );
463        let err = router.do_generate(&opts()).await.unwrap_err();
464        match err {
465            AiMuxError::InvalidArgument(msg) => {
466                assert!(msg.contains("out-of-bounds"), "got: {msg}");
467            }
468            other => panic!("expected InvalidArgument, got {other:?}"),
469        }
470    }
471
472    #[tokio::test]
473    async fn weighted_router_ties_pick_earliest() {
474        // S2 regression guard: equal weights resolve to the lowest index.
475        let router = RouterModel::new(
476            vec![child("a", "from-a", false), child("b", "from-b", false)],
477            Box::new(WeightedRouter::new(vec![1.0, 1.0])),
478            FallbackPolicy::None,
479            RouterConfig::default(),
480        );
481        let r = router.do_generate(&opts()).await.unwrap();
482        assert_eq!(crate::composite::extract_text(&r.content), "from-a");
483    }
484
485    #[tokio::test]
486    async fn empty_models_is_error() {
487        let router = RouterModel::new(
488            vec![],
489            Box::new(RuleRouter),
490            FallbackPolicy::OnError,
491            RouterConfig::default(),
492        );
493        let err = router.do_generate(&opts()).await.unwrap_err();
494        assert!(err.to_string().contains("no models"));
495    }
496
497    #[tokio::test]
498    async fn stream_does_not_fallback_when_routed_child_fails() {
499        // G3: the key streaming invariant — even with FallbackPolicy::OnError,
500        // a failing primary is NOT retried via the backup during do_stream.
501        // (StreamStart is already emitted by the time a retry would matter.)
502        let router = RouterModel::new(
503            vec![
504                child("primary", "primary-out", true),
505                child("backup", "backup-out", false),
506            ],
507            Box::new(RuleRouter),
508            FallbackPolicy::OnError, // would fall back in do_generate, must NOT here
509            RouterConfig::default(),
510        );
511        let err = router.do_stream(&opts()).await.unwrap_err();
512        assert!(
513            err.to_string().contains("primary always fails"),
514            "do_stream must surface the primary error, not fall back; got: {err}"
515        );
516    }
517
518    #[tokio::test]
519    async fn usable_as_dyn_language_model_via_generate_text() {
520        // G7: the RFCs' headline claim — composite models drop into the
521        // generate_text free function unchanged via &dyn LanguageModel.
522        let router = RouterModel::new(
523            vec![child("a", "works through trait object", false)],
524            Box::new(RuleRouter),
525            FallbackPolicy::OnError,
526            RouterConfig::default(),
527        );
528        let r = crate::generate::generate_text(
529            &router,
530            "hello",
531            crate::generate::GenerateTextOptions::default(),
532        )
533        .await
534        .unwrap();
535        assert_eq!(r.text, "works through trait object");
536    }
537}