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};
18
19pub trait Router: Send + Sync {
30 fn route(
37 &self,
38 prompt: &LanguageModelPrompt,
39 models: &[ChildModel],
40 ) -> Result<usize, AiMuxError>;
41}
42
43pub 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
60pub struct WeightedRouter {
67 weights: Vec<f64>,
68}
69
70impl WeightedRouter {
71 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110pub enum FallbackPolicy {
111 #[default]
113 OnError,
114 None,
116}
117
118#[derive(Debug, Clone)]
119pub struct RouterConfig {
120 pub provider_name: String,
122 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
135pub 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 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 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 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 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 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 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 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 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 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 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, 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 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}