use futures::stream::BoxStream;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::providers::{LlmError, Result};
use crate::types::{ChatRequest, ChatResponse, StreamChunk};
use crate::LmrsClient;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RoutingStrategy {
#[default]
Ordered,
RoundRobin,
}
fn should_failover(e: &LlmError) -> bool {
match e {
LlmError::Http(_) => true,
LlmError::Stream(_) => true,
LlmError::Api { status, .. } => *status >= 500 || *status == 429,
LlmError::UnknownProvider(_) => true,
LlmError::Parse(_) => false,
LlmError::Unsupported { .. } => false,
}
}
fn no_deployments(group: &str) -> LlmError {
LlmError::UnknownProvider(format!("no deployments configured for group '{}'", group))
}
pub struct Router {
client: Arc<LmrsClient>,
groups: HashMap<String, Vec<String>>,
strategy: RoutingStrategy,
counter: AtomicUsize,
cooldown: Option<Duration>,
cooldown_until: Mutex<HashMap<String, Instant>>,
}
impl Router {
pub fn new(client: Arc<LmrsClient>) -> Self {
Self {
client,
groups: HashMap::new(),
strategy: RoutingStrategy::Ordered,
counter: AtomicUsize::new(0),
cooldown: None,
cooldown_until: Mutex::new(HashMap::new()),
}
}
pub fn with_strategy(mut self, strategy: RoutingStrategy) -> Self {
self.strategy = strategy;
self
}
pub fn with_cooldown(mut self, duration: Duration) -> Self {
if duration > Duration::ZERO {
self.cooldown = Some(duration);
}
self
}
pub fn route<I, S>(mut self, group: impl Into<String>, deployments: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let list: Vec<String> = deployments.into_iter().map(Into::into).collect();
self.groups.insert(group.into(), list);
self
}
fn is_in_cooldown(&self, deployment: &str) -> bool {
if self.cooldown.is_none() {
return false;
}
let guard = self
.cooldown_until
.lock()
.expect("cooldown lock not poisoned");
match guard.get(deployment) {
Some(deadline) => Instant::now() < *deadline,
None => false,
}
}
fn mark_cooldown(&self, deployment: &str) {
let dur = match self.cooldown {
Some(d) => d,
None => return,
};
let mut guard = self
.cooldown_until
.lock()
.expect("cooldown lock not poisoned");
guard.insert(deployment.to_string(), Instant::now() + dur);
}
fn clear_cooldown(&self, deployment: &str) {
let mut guard = self
.cooldown_until
.lock()
.expect("cooldown lock not poisoned");
guard.remove(deployment);
}
fn candidates(&self, group: &str) -> Vec<String> {
let deployments = self.resolve(group);
if self.cooldown.is_none() || deployments.len() <= 1 {
return deployments;
}
let mut ready: Vec<String> = Vec::with_capacity(deployments.len());
let mut cooling: Vec<String> = Vec::with_capacity(deployments.len());
for d in deployments {
if self.is_in_cooldown(&d) {
cooling.push(d);
} else {
ready.push(d);
}
}
if ready.is_empty() {
cooling
} else {
ready
}
}
fn resolve(&self, group: &str) -> Vec<String> {
let base = match self.groups.get(group) {
Some(list) => list.clone(),
None => vec![group.to_string()],
};
if base.len() <= 1 {
return base;
}
match self.strategy {
RoutingStrategy::Ordered => base,
RoutingStrategy::RoundRobin => {
let start = self.counter.fetch_add(1, Ordering::Relaxed) % base.len();
let mut rotated = base[start..].to_vec();
rotated.extend_from_slice(&base[..start]);
rotated
}
}
}
pub async fn chat(&self, group: &str, prompt: &str) -> Result<ChatResponse> {
self.chat_with(group, ChatRequest::new("", prompt)).await
}
pub async fn chat_with(&self, group: &str, request: ChatRequest) -> Result<ChatResponse> {
let deployments = self.candidates(group);
tracing::debug!(group, deployments = ?deployments, "routing chat request");
let mut last_error: Option<LlmError> = None;
for model in &deployments {
match self.client.chat_with(model, request.clone()).await {
Ok(resp) => {
self.clear_cooldown(model);
return Ok(resp);
}
Err(e) if should_failover(&e) => {
tracing::warn!(
group,
model,
error_kind = "api_error",
"failing over to next deployment"
);
self.mark_cooldown(model);
last_error = Some(e);
}
Err(e) => return Err(e),
}
}
Err(last_error.unwrap_or_else(|| no_deployments(group)))
}
pub async fn stream(
&self,
group: &str,
prompt: &str,
) -> Result<BoxStream<'static, Result<StreamChunk>>> {
self.stream_with(group, ChatRequest::new("", prompt)).await
}
pub async fn stream_with(
&self,
group: &str,
request: ChatRequest,
) -> Result<BoxStream<'static, Result<StreamChunk>>> {
let deployments = self.candidates(group);
tracing::debug!(group, deployments = ?deployments, "routing stream request");
let mut last_error: Option<LlmError> = None;
for model in &deployments {
match self.client.stream_with(model, request.clone()).await {
Ok(s) => {
self.clear_cooldown(model);
return Ok(s);
}
Err(e) if should_failover(&e) => {
tracing::warn!(
group,
model,
error_kind = "api_error",
"failing over to next deployment"
);
self.mark_cooldown(model);
last_error = Some(e);
}
Err(e) => return Err(e),
}
}
Err(last_error.unwrap_or_else(|| no_deployments(group)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use futures::stream;
use futures::StreamExt;
struct FailingProvider {
status: u16,
}
impl FailingProvider {
fn new(status: u16) -> Self {
Self { status }
}
}
#[async_trait]
impl crate::providers::Provider for FailingProvider {
async fn chat(&self, _req: &ChatRequest) -> Result<ChatResponse> {
Err(LlmError::Api {
status: self.status,
message: "fail".to_string(),
})
}
async fn stream(
&self,
_req: &ChatRequest,
) -> Result<BoxStream<'static, Result<StreamChunk>>> {
Err(LlmError::Api {
status: self.status,
message: "fail".to_string(),
})
}
}
struct OkProvider {
name: String,
}
impl OkProvider {
fn new(name: impl Into<String>) -> Self {
Self { name: name.into() }
}
}
#[async_trait]
impl crate::providers::Provider for OkProvider {
async fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
Ok(ChatResponse {
content: self.name.clone(),
model: req.model.clone(),
..Default::default()
})
}
async fn stream(
&self,
_req: &ChatRequest,
) -> Result<BoxStream<'static, Result<StreamChunk>>> {
let chunk = StreamChunk {
delta: self.name.clone(),
done: true,
..Default::default()
};
Ok(Box::pin(stream::once(async move { Ok(chunk) })))
}
}
#[tokio::test]
async fn falls_back_on_transient_error() {
let client = Arc::new(LmrsClient::new());
let bad = Arc::new(FailingProvider::new(503));
let good = Arc::new(OkProvider::new("good"));
client.set_custom("bad", bad).await;
client.set_custom("good", good).await;
let router = Router::new(client).route("grp", ["bad/m1", "good/m2"]);
let resp = router.chat("grp", "hi").await.unwrap();
assert_eq!(resp.content, "good");
}
#[tokio::test]
async fn permanent_error_is_not_retried() {
let client = Arc::new(LmrsClient::new());
let bad = Arc::new(FailingProvider::new(400));
let good = Arc::new(OkProvider::new("good"));
client.set_custom("bad", bad).await;
client.set_custom("good", good).await;
let router = Router::new(client).route("grp", ["bad/m1", "good/m2"]);
let err = router.chat("grp", "hi").await.unwrap_err();
assert!(matches!(err, LlmError::Api { status: 400, .. }));
}
#[tokio::test]
async fn all_deployments_fail_returns_last_error() {
let client = Arc::new(LmrsClient::new());
let bad1 = Arc::new(FailingProvider::new(500));
let bad2 = Arc::new(FailingProvider::new(503));
client.set_custom("b1", bad1).await;
client.set_custom("b2", bad2).await;
let router = Router::new(client).route("grp", ["b1/m", "b2/m"]);
let err = router.chat("grp", "hi").await.unwrap_err();
assert!(matches!(err, LlmError::Api { status: 503, .. }));
}
#[tokio::test]
async fn unknown_group_routes_directly() {
let client = Arc::new(LmrsClient::new());
let good = Arc::new(OkProvider::new("good"));
client.set_custom("good", good).await;
let router = Router::new(client);
let resp = router.chat("good/gpt", "hi").await.unwrap();
assert_eq!(resp.content, "good");
}
#[tokio::test]
async fn round_robin_rotates_start() {
let client = Arc::new(LmrsClient::new());
let a = Arc::new(OkProvider::new("a"));
let b = Arc::new(OkProvider::new("b"));
client.set_custom("a", a).await;
client.set_custom("b", b).await;
let router = Router::new(client)
.with_strategy(RoutingStrategy::RoundRobin)
.route("grp", ["a/m", "b/m"]);
let r0 = router.chat("grp", "hi").await.unwrap();
let r1 = router.chat("grp", "hi").await.unwrap();
let r2 = router.chat("grp", "hi").await.unwrap();
assert_eq!(r0.content, "a");
assert_eq!(r1.content, "b");
assert_eq!(r2.content, "a");
}
#[tokio::test]
async fn stream_falls_back_on_transient_error() {
let client = Arc::new(LmrsClient::new());
let bad = Arc::new(FailingProvider::new(502));
let good = Arc::new(OkProvider::new("stream-ok"));
client.set_custom("bad", bad).await;
client.set_custom("good", good).await;
let router = Router::new(client).route("grp", ["bad/m", "good/m"]);
let mut s = router.stream("grp", "hi").await.unwrap();
let chunk = s.next().await.unwrap().unwrap();
assert_eq!(chunk.delta, "stream-ok");
}
struct CountedProvider {
inner: Arc<dyn crate::providers::Provider>,
calls: AtomicUsize,
}
impl CountedProvider {
fn new(inner: Arc<dyn crate::providers::Provider>) -> Self {
Self {
inner,
calls: AtomicUsize::new(0),
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::Relaxed)
}
}
#[async_trait]
impl crate::providers::Provider for CountedProvider {
async fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
self.calls.fetch_add(1, Ordering::Relaxed);
self.inner.chat(req).await
}
async fn stream(
&self,
req: &ChatRequest,
) -> Result<BoxStream<'static, Result<StreamChunk>>> {
self.calls.fetch_add(1, Ordering::Relaxed);
self.inner.stream(req).await
}
}
struct ParseFailingProvider;
#[async_trait]
impl crate::providers::Provider for ParseFailingProvider {
async fn chat(&self, _req: &ChatRequest) -> Result<ChatResponse> {
Err(LlmError::Parse("bad model response".into()))
}
async fn stream(
&self,
_req: &ChatRequest,
) -> Result<BoxStream<'static, Result<StreamChunk>>> {
Err(LlmError::Parse("bad model response".into()))
}
}
#[tokio::test]
async fn default_router_does_not_skip_failed_primary() {
let client = Arc::new(LmrsClient::new());
let primary = Arc::new(CountedProvider::new(Arc::new(FailingProvider::new(500))));
let secondary = Arc::new(CountedProvider::new(Arc::new(OkProvider::new("secondary"))));
client.set_custom("p", primary.clone()).await;
client.set_custom("s", secondary.clone()).await;
let router = Router::new(client).route("grp", ["p/m", "s/m"]);
let r1 = router.chat("grp", "hi").await.unwrap();
assert_eq!(r1.content, "secondary");
assert_eq!(primary.calls(), 1, "primary should have been called once");
assert_eq!(
secondary.calls(),
1,
"secondary should have been called once"
);
let r2 = router.chat("grp", "hi").await.unwrap();
assert_eq!(r2.content, "secondary");
assert_eq!(primary.calls(), 2, "primary should have been called again");
assert_eq!(
secondary.calls(),
2,
"secondary should have been called again"
);
}
#[tokio::test]
async fn cooldown_skips_recently_failed_primary() {
let client = Arc::new(LmrsClient::new());
let primary = Arc::new(CountedProvider::new(Arc::new(FailingProvider::new(500))));
let secondary = Arc::new(CountedProvider::new(Arc::new(OkProvider::new("secondary"))));
client.set_custom("p", primary.clone()).await;
client.set_custom("s", secondary.clone()).await;
let router = Router::new(client)
.with_cooldown(Duration::from_secs(60))
.route("grp", ["p/m", "s/m"]);
let r1 = router.chat("grp", "hi").await.unwrap();
assert_eq!(r1.content, "secondary");
assert_eq!(primary.calls(), 1);
assert_eq!(secondary.calls(), 1);
let r2 = router.chat("grp", "hi").await.unwrap();
assert_eq!(r2.content, "secondary");
assert_eq!(
primary.calls(),
1,
"primary must be skipped while in cooldown"
);
assert_eq!(secondary.calls(), 2);
}
#[tokio::test]
async fn cooldown_expires_and_retries_primary() {
let client = Arc::new(LmrsClient::new());
struct RecoveryProvider {
name: String,
failed: Mutex<bool>,
}
#[async_trait]
impl crate::providers::Provider for RecoveryProvider {
async fn chat(&self, _req: &ChatRequest) -> Result<ChatResponse> {
let mut guard = self.failed.lock().unwrap();
if !*guard {
*guard = true;
Err(LlmError::Api {
status: 500,
message: "simulated failure".into(),
})
} else {
Ok(ChatResponse {
content: self.name.clone(),
model: "m".into(),
..Default::default()
})
}
}
async fn stream(
&self,
_req: &ChatRequest,
) -> Result<BoxStream<'static, Result<StreamChunk>>> {
let mut guard = self.failed.lock().unwrap();
if !*guard {
*guard = true;
Err(LlmError::Api {
status: 500,
message: "simulated failure".into(),
})
} else {
let name = self.name.clone();
Ok(Box::pin(stream::once(async move {
Ok(StreamChunk {
delta: name,
done: true,
..Default::default()
})
})))
}
}
}
let primary = Arc::new(RecoveryProvider {
name: "primary-ok".into(),
failed: Mutex::new(false),
});
let secondary = Arc::new(OkProvider::new("secondary"));
client.set_custom("p", primary).await;
client.set_custom("s", secondary).await;
let router = Router::new(client)
.with_cooldown(Duration::from_millis(30))
.route("grp", ["p/m", "s/m"]);
let r1 = router.chat("grp", "hi").await.unwrap();
assert_eq!(r1.content, "secondary");
tokio::time::sleep(Duration::from_millis(50)).await;
let r2 = router.chat("grp", "hi").await.unwrap();
assert_eq!(r2.content, "primary-ok");
}
#[tokio::test]
async fn cooldown_not_marked_for_api_400() {
let client = Arc::new(LmrsClient::new());
let bad = Arc::new(CountedProvider::new(Arc::new(FailingProvider::new(400))));
let good = Arc::new(CountedProvider::new(Arc::new(OkProvider::new("secondary"))));
client.set_custom("bad", bad.clone()).await;
client.set_custom("good", good.clone()).await;
let router = Router::new(client)
.with_cooldown(Duration::from_secs(60))
.route("grp", ["bad/m", "good/m"]);
let err = router.chat("grp", "hi").await.unwrap_err();
assert!(matches!(err, LlmError::Api { status: 400, .. }));
assert_eq!(bad.calls(), 1, "400 is permanent, called once");
assert_eq!(good.calls(), 0, "should not fail over to secondary on 400");
}
#[tokio::test]
async fn cooldown_not_marked_for_parse_error() {
let client = Arc::new(LmrsClient::new());
let bad = Arc::new(CountedProvider::new(Arc::new(ParseFailingProvider)));
let good = Arc::new(CountedProvider::new(Arc::new(OkProvider::new("secondary"))));
client.set_custom("bad", bad.clone()).await;
client.set_custom("good", good.clone()).await;
let router = Router::new(client)
.with_cooldown(Duration::from_secs(60))
.route("grp", ["bad/m", "good/m"]);
let err = router.chat("grp", "hi").await.unwrap_err();
assert!(matches!(err, LlmError::Parse(_)));
assert_eq!(bad.calls(), 1, "Parse is permanent, called once");
assert_eq!(
good.calls(),
0,
"should not fail over to secondary on Parse"
);
}
#[tokio::test]
async fn cooldown_fail_open_when_all_deployments_are_cooling() {
let client = Arc::new(LmrsClient::new());
let p1 = Arc::new(CountedProvider::new(Arc::new(FailingProvider::new(503))));
let p2 = Arc::new(CountedProvider::new(Arc::new(FailingProvider::new(502))));
let p3 = Arc::new(CountedProvider::new(Arc::new(FailingProvider::new(500))));
client.set_custom("p1", p1.clone()).await;
client.set_custom("p2", p2.clone()).await;
client.set_custom("p3", p3.clone()).await;
let router = Router::new(client)
.with_cooldown(Duration::from_secs(60))
.route("grp", ["p1/m", "p2/m", "p3/m"]);
let err1 = router.chat("grp", "hi").await.unwrap_err();
assert!(matches!(err1, LlmError::Api { .. }));
assert_eq!(p1.calls(), 1);
assert_eq!(p2.calls(), 1);
assert_eq!(p3.calls(), 1);
let err2 = router.chat("grp", "hi").await.unwrap_err();
assert!(matches!(err2, LlmError::Api { .. }));
assert_eq!(p1.calls(), 2, "fail-open: p1 must be retried");
assert_eq!(p2.calls(), 2, "fail-open: p2 must be retried");
assert_eq!(p3.calls(), 2, "fail-open: p3 must be retried");
}
#[tokio::test]
async fn stream_initial_failure_marks_cooldown() {
let client = Arc::new(LmrsClient::new());
let bad = Arc::new(CountedProvider::new(Arc::new(FailingProvider::new(502))));
let good = Arc::new(CountedProvider::new(Arc::new(OkProvider::new(
"stream-good",
))));
client.set_custom("bad", bad.clone()).await;
client.set_custom("good", good.clone()).await;
let router = Router::new(client)
.with_cooldown(Duration::from_secs(60))
.route("grp", ["bad/m", "good/m"]);
let mut s1 = router.stream("grp", "hi").await.unwrap();
let c1 = s1.next().await.unwrap().unwrap();
assert_eq!(c1.delta, "stream-good");
assert_eq!(bad.calls(), 1);
assert_eq!(good.calls(), 1);
let mut s2 = router.stream("grp", "hi").await.unwrap();
let c2 = s2.next().await.unwrap().unwrap();
assert_eq!(c2.delta, "stream-good");
assert_eq!(bad.calls(), 1, "bad must be skipped while in cooldown");
assert_eq!(good.calls(), 2);
}
#[tokio::test]
async fn successful_deployment_clears_cooldown() {
let client = Arc::new(LmrsClient::new());
struct ClearVerifyProvider {
name: String,
attempt: Mutex<u32>,
}
#[async_trait]
impl crate::providers::Provider for ClearVerifyProvider {
async fn chat(&self, _req: &ChatRequest) -> Result<ChatResponse> {
let mut guard = self.attempt.lock().unwrap();
*guard += 1;
if *guard == 1 {
Err(LlmError::Api {
status: 500,
message: "first fail".into(),
})
} else {
Ok(ChatResponse {
content: self.name.clone(),
model: "m".into(),
..Default::default()
})
}
}
async fn stream(
&self,
_req: &ChatRequest,
) -> Result<BoxStream<'static, Result<StreamChunk>>> {
unimplemented!()
}
}
let primary = Arc::new(ClearVerifyProvider {
name: "primary-ok".into(),
attempt: Mutex::new(0),
});
let secondary = Arc::new(OkProvider::new("secondary"));
client.set_custom("p", primary).await;
client.set_custom("s", secondary).await;
let router = Router::new(client)
.with_cooldown(Duration::from_millis(30))
.route("grp", ["p/m", "s/m"]);
let r1 = router.chat("grp", "hi").await.unwrap();
assert_eq!(r1.content, "secondary");
tokio::time::sleep(Duration::from_millis(50)).await;
let r2 = router.chat("grp", "hi").await.unwrap();
assert_eq!(r2.content, "primary-ok");
let r3 = router.chat("grp", "hi").await.unwrap();
assert_eq!(r3.content, "primary-ok");
}
}