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};
use crate::retry;
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;
}
let retries = retry::prepare_retries(
options.max_retries,
m.retry_config(),
options.abort_signal.clone(),
);
let child_options = options.for_step(self.step_label(i));
match retries
.retry(|| {
if let Some(context) = &child_options.recording_context {
let _ = context.start_attempt();
}
m.do_generate(&child_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 step_label(&self, idx: usize) -> String {
let m = &self.models[idx];
format!("router[{idx}]:{}/{}", m.provider(), m.model_id())
}
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
}
fn retry_config(&self) -> retry::RetryConfig {
retry::RetryConfig {
max_retries: 0,
..retry::RetryConfig::default()
}
}
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")?;
let model = &self.models[idx];
let retries = retry::prepare_retries(
options.max_retries,
model.retry_config(),
options.abort_signal.clone(),
);
let child_options = options.for_step(self.step_label(idx));
match retries
.retry(|| {
if let Some(context) = &child_options.recording_context {
let _ = context.start_attempt();
}
model.do_generate(&child_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")?;
let model = &self.models[idx];
let retries = retry::prepare_retries(
options.max_retries,
model.retry_config(),
options.abort_signal.clone(),
);
let child_options = options.for_step(self.step_label(idx));
retries
.retry(|| {
if let Some(context) = &child_options.recording_context {
let _ = context.start_attempt();
}
model.do_stream(&child_options)
})
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::recording::{
HttpExchange, OutcomeRecord, ProviderRecord, Recorder, RecordingContext, ResponseRecord,
};
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;
use std::sync::atomic::{AtomicUsize, Ordering};
struct MockChild {
name: &'static str,
text: String,
fail: bool,
retry_failures: usize,
calls: Arc<AtomicUsize>,
}
#[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> {
let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
if self.fail {
return Err(AiMuxError::Other(format!("{} always fails", self.name)));
}
if attempt <= self.retry_failures {
return Err(retryable_error(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> {
let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
if self.fail {
return Err(AiMuxError::Other(format!("{} always fails", self.name)));
}
if attempt <= self.retry_failures {
return Err(retryable_error(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 retryable_error(name: &str) -> AiMuxError {
AiMuxError::ApiCall(Box::new(crate::ApiCallError {
status_code: Some(503),
response_headers: Some(std::collections::HashMap::from([(
"retry-after-ms".into(),
"0".into(),
)])),
is_retryable: true,
..crate::ApiCallError::new(
format!("{name} retryable failure"),
"https://example.test",
serde_json::json!({}),
)
}))
}
fn retry_child(
name: &'static str,
text: &str,
retry_failures: usize,
) -> (ChildModel, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
(
Arc::new(MockChild {
name,
text: text.into(),
fail: false,
retry_failures,
calls: calls.clone(),
}),
calls,
)
}
struct CountingRuleRouter(Arc<AtomicUsize>);
impl Router for CountingRuleRouter {
fn route(
&self,
prompt: &LanguageModelPrompt,
models: &[ChildModel],
) -> Result<usize, AiMuxError> {
self.0.fetch_add(1, Ordering::SeqCst);
RuleRouter.route(prompt, models)
}
}
fn child(name: &'static str, text: &str, fail: bool) -> ChildModel {
Arc::new(MockChild {
name,
text: text.into(),
fail,
retry_failures: 0,
calls: Arc::new(AtomicUsize::new(0)),
})
}
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");
}
#[tokio::test]
async fn retries_each_child_before_falling_back_without_rerouting() {
let (primary, primary_calls) = retry_child("primary", "primary", usize::MAX);
let (backup, backup_calls) = retry_child("backup", "backup", 1);
let route_calls = Arc::new(AtomicUsize::new(0));
let router = RouterModel::new(
vec![primary, backup],
Box::new(CountingRuleRouter(route_calls.clone())),
FallbackPolicy::OnError,
RouterConfig::default(),
);
assert_eq!(router.retry_config().max_retries, 0);
let result = crate::generate::generate_text(
&router,
"hello",
crate::generate::GenerateTextOptions {
max_retries: Some(2),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(result.text, "backup");
assert_eq!(route_calls.load(Ordering::SeqCst), 1);
assert_eq!(primary_calls.load(Ordering::SeqCst), 3);
assert_eq!(backup_calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn children_receive_step_labeled_recording_contexts() {
struct StepProbe {
seen: std::sync::Mutex<Vec<Option<String>>>,
}
#[async_trait]
impl LanguageModel for StepProbe {
fn provider(&self) -> &str {
"mock"
}
fn model_id(&self) -> &str {
"probe"
}
async fn do_generate(
&self,
options: &CallOptions,
) -> Result<GenerateResult, AiMuxError> {
self.seen.lock().unwrap().push(
options
.recording_context
.as_ref()
.and_then(RecordingContext::step),
);
Err(AiMuxError::Other("probe fails".into()))
}
async fn do_stream(&self, _: &CallOptions) -> Result<StreamResult, AiMuxError> {
unreachable!()
}
}
struct NoopRecorder;
impl Recorder for NoopRecorder {
fn record_input(&self, _: &str, _: &CallOptions, _: &str, _: &str) {}
fn record_provider(&self, _: &str, _: &ProviderRecord) {}
fn record_exchange(&self, _: &str, _: &HttpExchange) {}
fn record_exchange_update(
&self,
_: &str,
_: u32,
_: u32,
_: &ResponseRecord,
_: Option<String>,
) {
}
fn record_outcome(&self, _: &str, _: &OutcomeRecord) {}
fn flush(&self) {}
}
let probe = Arc::new(StepProbe {
seen: std::sync::Mutex::new(Vec::new()),
});
let router = RouterModel::new(
vec![probe.clone()],
Box::new(CountingRuleRouter(Arc::new(AtomicUsize::new(0)))),
FallbackPolicy::OnError,
RouterConfig::default(),
);
let options = CallOptions {
recording_context: Some(RecordingContext::new("call-1", Arc::new(NoopRecorder))),
..CallOptions::default()
};
let _ = router.do_generate(&options).await;
let seen = probe.seen.lock().unwrap();
assert!(!seen.is_empty());
assert_eq!(seen[0].as_deref(), Some("router[0]:mock/probe"));
}
#[tokio::test]
async fn all_children_failing_does_not_replay_the_composite() {
let (primary, primary_calls) = retry_child("primary", "primary", usize::MAX);
let (backup, backup_calls) = retry_child("backup", "backup", usize::MAX);
let route_calls = Arc::new(AtomicUsize::new(0));
let router = RouterModel::new(
vec![primary, backup],
Box::new(CountingRuleRouter(route_calls.clone())),
FallbackPolicy::OnError,
RouterConfig::default(),
);
let error = crate::generate::generate_text(
&router,
"hello",
crate::generate::GenerateTextOptions {
max_retries: Some(2),
..Default::default()
},
)
.await
.unwrap_err();
assert!(matches!(error, crate::AiMuxError::Retry(_)), "{error:?}");
assert_eq!(route_calls.load(Ordering::SeqCst), 1);
assert_eq!(primary_calls.load(Ordering::SeqCst), 3);
assert_eq!(backup_calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn retries_stream_setup_on_selected_child_without_fallback_or_rerouting() {
let (primary, primary_stream_calls) = retry_child("primary", "primary", 1);
let (backup, backup_stream_calls) = retry_child("backup", "backup", 0);
let route_calls = Arc::new(AtomicUsize::new(0));
let router = RouterModel::new(
vec![primary, backup],
Box::new(CountingRuleRouter(route_calls.clone())),
FallbackPolicy::OnError,
RouterConfig::default(),
);
let _result = crate::generate::stream_text(
&router,
"hello",
crate::generate::GenerateTextOptions {
max_retries: Some(2),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(route_calls.load(Ordering::SeqCst), 1);
assert_eq!(primary_stream_calls.load(Ordering::SeqCst), 2);
assert_eq!(backup_stream_calls.load(Ordering::SeqCst), 0);
}
}