use futures::stream::BoxStream;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
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,
}
}
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,
}
impl Router {
pub fn new(client: Arc<LmrsClient>) -> Self {
Self {
client,
groups: HashMap::new(),
strategy: RoutingStrategy::Ordered,
counter: AtomicUsize::new(0),
}
}
pub fn with_strategy(mut self, strategy: RoutingStrategy) -> Self {
self.strategy = strategy;
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 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.resolve(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) => return Ok(resp),
Err(e) if should_failover(&e) => {
tracing::warn!(
group,
model,
error_kind = "api_error",
"failing over to next deployment"
);
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.resolve(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) => return Ok(s),
Err(e) if should_failover(&e) => {
tracing::warn!(
group,
model,
error_kind = "api_error",
"failing over to next deployment"
);
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");
}
}