use async_trait::async_trait;
use crate::composite::ChildModel;
use crate::error::AiMuxError;
use crate::language_model::LanguageModel;
use crate::language_model_message::LanguageModelPrompt;
use crate::options::CallOptions;
use crate::result::{GenerateResult, StreamResult};
pub trait Router: Send + Sync {
fn route(
&self,
prompt: &LanguageModelPrompt,
models: &[ChildModel],
) -> Result<usize, AiMuxError>;
}
pub struct RuleRouter;
impl Router for RuleRouter {
fn route(
&self,
_prompt: &LanguageModelPrompt,
models: &[ChildModel],
) -> Result<usize, AiMuxError> {
if models.is_empty() {
return Err(AiMuxError::Other("router: no models configured".into()));
}
Ok(0)
}
}
pub struct WeightedRouter {
weights: Vec<f64>,
}
impl WeightedRouter {
#[must_use]
pub fn new(weights: Vec<f64>) -> Self {
Self { weights }
}
}
impl Router for WeightedRouter {
fn route(
&self,
_prompt: &LanguageModelPrompt,
models: &[ChildModel],
) -> Result<usize, AiMuxError> {
if models.is_empty() {
return Err(AiMuxError::Other("router: no models configured".into()));
}
let mut best_idx = 0;
let mut best_weight = *self.weights.first().unwrap_or(&0.0);
for i in 1..models.len() {
let w = *self.weights.get(i).unwrap_or(&0.0);
if w > best_weight {
best_idx = i;
best_weight = w;
}
}
Ok(best_idx)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FallbackPolicy {
#[default]
OnError,
None,
}
#[derive(Debug, Clone)]
pub struct RouterConfig {
pub provider_name: String,
pub model_id: String,
}
impl Default for RouterConfig {
fn default() -> Self {
Self {
provider_name: "router".into(),
model_id: "router".into(),
}
}
}
pub struct RouterModel {
models: Vec<ChildModel>,
router: Box<dyn Router>,
fallback: FallbackPolicy,
config: RouterConfig,
}
impl RouterModel {
#[must_use]
pub fn new(
models: Vec<ChildModel>,
router: Box<dyn Router>,
fallback: FallbackPolicy,
config: RouterConfig,
) -> Self {
Self {
models,
router,
fallback,
config,
}
}
async fn fallback_generate(
&self,
exclude: usize,
options: &CallOptions,
primary_err: AiMuxError,
) -> Result<GenerateResult, AiMuxError> {
let mut last_err = Some(primary_err);
for (i, m) in self.models.iter().enumerate() {
if i == exclude {
continue;
}
match m.do_generate(options).await {
Ok(r) => return Ok(r),
Err(e) => last_err = Some(e),
}
}
Err(last_err.expect("seeded primary_err makes last_err always Some"))
}
fn check_index(&self, idx: usize, from: &str) -> Result<usize, AiMuxError> {
if idx < self.models.len() {
Ok(idx)
} else {
Err(AiMuxError::InvalidArgument(format!(
"{from}: router returned out-of-bounds index {idx} (models: {})",
self.models.len()
)))
}
}
}
#[async_trait]
impl LanguageModel for RouterModel {
fn provider(&self) -> &str {
&self.config.provider_name
}
fn model_id(&self) -> &str {
&self.config.model_id
}
async fn do_generate(&self, options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
let raw = self.router.route(&options.prompt, &self.models)?;
let idx = self.check_index(raw, "router")?;
match self.models[idx].do_generate(options).await {
Ok(r) => Ok(r),
Err(e) => {
if self.fallback == FallbackPolicy::OnError {
self.fallback_generate(idx, options, e).await
} else {
Err(e)
}
}
}
}
async fn do_stream(&self, options: &CallOptions) -> Result<StreamResult, AiMuxError> {
let raw = self.router.route(&options.prompt, &self.models)?;
let idx = self.check_index(raw, "router")?;
self.models[idx].do_stream(options).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::result::GenerateResult;
use crate::stream_part::StreamPart;
use crate::types::{FinishReason, FinishReasonUnified, Usage};
use async_trait::async_trait;
use futures::stream;
use std::sync::Arc;
struct MockChild {
name: &'static str,
text: String,
fail: bool,
}
#[async_trait]
impl LanguageModel for MockChild {
fn provider(&self) -> &str {
"mock"
}
fn model_id(&self) -> &str {
self.name
}
async fn do_generate(&self, _options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
if self.fail {
return Err(AiMuxError::Other(format!("{} always fails", self.name)));
}
Ok(GenerateResult {
content: vec![crate::result::GenerateContent::Text {
text: self.text.clone(),
provider_metadata: None,
}],
finish_reason: FinishReason {
unified: FinishReasonUnified::Stop,
raw: None,
},
usage: Usage::default(),
warnings: vec![],
provider_metadata: None,
response: Default::default(),
request_body: None,
response_headers: None,
})
}
async fn do_stream(&self, _options: &CallOptions) -> Result<StreamResult, AiMuxError> {
if self.fail {
return Err(AiMuxError::Other(format!("{} always fails", self.name)));
}
let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
Ok(StreamPart::StreamStart { warnings: vec![] }),
Ok(StreamPart::TextDelta {
id: "t1".into(),
delta: self.text.clone(),
provider_metadata: None,
}),
Ok(StreamPart::Finish {
finish_reason: FinishReason {
unified: FinishReasonUnified::Stop,
raw: None,
},
usage: Usage::default(),
provider_metadata: None,
}),
];
Ok(StreamResult {
stream: Box::pin(stream::iter(parts)),
request_body: None,
response_headers: None,
})
}
}
fn child(name: &'static str, text: &str, fail: bool) -> ChildModel {
Arc::new(MockChild {
name,
text: text.into(),
fail,
})
}
fn opts() -> CallOptions {
CallOptions {
prompt: vec![],
..Default::default()
}
}
#[tokio::test]
async fn routes_to_first_with_rule_router() {
let router = RouterModel::new(
vec![child("a", "from-a", false), child("b", "from-b", false)],
Box::new(RuleRouter),
FallbackPolicy::OnError,
RouterConfig::default(),
);
let r = router.do_generate(&opts()).await.unwrap();
let text = crate::composite::extract_text(&r.content);
assert_eq!(text, "from-a");
}
#[tokio::test]
async fn weighted_router_picks_max() {
let router = RouterModel::new(
vec![child("a", "from-a", false), child("b", "from-b", false)],
Box::new(WeightedRouter::new(vec![1.0, 5.0])),
FallbackPolicy::None,
RouterConfig::default(),
);
let r = router.do_generate(&opts()).await.unwrap();
assert_eq!(crate::composite::extract_text(&r.content), "from-b");
}
#[tokio::test]
async fn fallback_on_error_walks_remaining() {
let router = RouterModel::new(
vec![
child("a", "from-a", true),
child("b", "from-b", true),
child("c", "from-c", false),
],
Box::new(RuleRouter),
FallbackPolicy::OnError,
RouterConfig::default(),
);
let r = router.do_generate(&opts()).await.unwrap();
assert_eq!(crate::composite::extract_text(&r.content), "from-c");
}
#[tokio::test]
async fn fallback_none_propagates_error() {
let router = RouterModel::new(
vec![child("a", "from-a", true), child("b", "from-b", false)],
Box::new(RuleRouter),
FallbackPolicy::None,
RouterConfig::default(),
);
let err = router.do_generate(&opts()).await.unwrap_err();
assert!(err.to_string().contains("always fails"));
}
#[tokio::test]
async fn all_fail_returns_last_error() {
let router = RouterModel::new(
vec![child("a", "from-a", true), child("b", "from-b", true)],
Box::new(RuleRouter),
FallbackPolicy::OnError,
RouterConfig::default(),
);
let err = router.do_generate(&opts()).await.unwrap_err();
assert!(err.to_string().contains("always fails"));
}
#[tokio::test]
async fn stream_routes_without_fallback() {
let router = RouterModel::new(
vec![child("a", "from-a", false), child("b", "from-b", false)],
Box::new(RuleRouter),
FallbackPolicy::OnError,
RouterConfig::default(),
);
let result = router.do_stream(&opts()).await.unwrap();
use futures::StreamExt;
let mut s = result.stream;
let mut text = String::new();
while let Some(part) = s.next().await {
if let Ok(StreamPart::TextDelta { delta, .. }) = part {
text.push_str(&delta);
}
}
assert_eq!(text, "from-a");
}
#[tokio::test]
async fn single_child_failure_preserves_real_error() {
let router = RouterModel::new(
vec![child("solo", "solo-out", true)],
Box::new(RuleRouter),
FallbackPolicy::OnError,
RouterConfig::default(),
);
let err = router.do_generate(&opts()).await.unwrap_err();
assert!(
err.to_string().contains("solo always fails"),
"expected the real child error, got: {err}"
);
}
#[tokio::test]
async fn single_child_success_works() {
let router = RouterModel::new(
vec![child("solo", "solo-out", false)],
Box::new(RuleRouter),
FallbackPolicy::OnError,
RouterConfig::default(),
);
let r = router.do_generate(&opts()).await.unwrap();
assert_eq!(crate::composite::extract_text(&r.content), "solo-out");
}
#[tokio::test]
async fn out_of_bounds_router_index_is_invalid_argument() {
struct OobRouter;
impl Router for OobRouter {
fn route(
&self,
_prompt: &LanguageModelPrompt,
_models: &[ChildModel],
) -> Result<usize, AiMuxError> {
Ok(99)
}
}
let router = RouterModel::new(
vec![child("a", "from-a", false)],
Box::new(OobRouter),
FallbackPolicy::OnError,
RouterConfig::default(),
);
let err = router.do_generate(&opts()).await.unwrap_err();
match err {
AiMuxError::InvalidArgument(msg) => {
assert!(msg.contains("out-of-bounds"), "got: {msg}");
}
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
#[tokio::test]
async fn weighted_router_ties_pick_earliest() {
let router = RouterModel::new(
vec![child("a", "from-a", false), child("b", "from-b", false)],
Box::new(WeightedRouter::new(vec![1.0, 1.0])),
FallbackPolicy::None,
RouterConfig::default(),
);
let r = router.do_generate(&opts()).await.unwrap();
assert_eq!(crate::composite::extract_text(&r.content), "from-a");
}
#[tokio::test]
async fn empty_models_is_error() {
let router = RouterModel::new(
vec![],
Box::new(RuleRouter),
FallbackPolicy::OnError,
RouterConfig::default(),
);
let err = router.do_generate(&opts()).await.unwrap_err();
assert!(err.to_string().contains("no models"));
}
#[tokio::test]
async fn stream_does_not_fallback_when_routed_child_fails() {
let router = RouterModel::new(
vec![
child("primary", "primary-out", true),
child("backup", "backup-out", false),
],
Box::new(RuleRouter),
FallbackPolicy::OnError, RouterConfig::default(),
);
let err = router.do_stream(&opts()).await.unwrap_err();
assert!(
err.to_string().contains("primary always fails"),
"do_stream must surface the primary error, not fall back; got: {err}"
);
}
#[tokio::test]
async fn usable_as_dyn_language_model_via_generate_text() {
let router = RouterModel::new(
vec![child("a", "works through trait object", false)],
Box::new(RuleRouter),
FallbackPolicy::OnError,
RouterConfig::default(),
);
let r = crate::generate::generate_text(
&router,
"hello",
crate::generate::GenerateTextOptions::default(),
)
.await
.unwrap();
assert_eq!(r.text, "works through trait object");
}
}