mod budget;
mod rate;
pub use budget::{BudgetExceeded, BudgetKind, RouterBudget};
pub use rate::{ModelRateLimit, RateLimitReason};
use crate::cost::ModelPrice;
use crate::language_models::{
BaseChatModel, BaseLanguageModel, LLMResult, StreamChunk, TokenUsage,
};
use crate::model_registry::ModelRegistry;
use crate::runnables::Runnable;
use crate::RunnableConfig;
use async_trait::async_trait;
use futures_util::{Stream, StreamExt};
use lc_schema::Message;
use std::fmt::{self, Display, Formatter};
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
#[cfg(test)]
mod tests;
#[derive(Debug)]
#[non_exhaustive]
pub enum RouterError {
Empty,
AllFailed {
tried: usize,
last: Box<dyn std::error::Error + Send + Sync>,
},
Model {
model: String,
source: Box<dyn std::error::Error + Send + Sync>,
},
RateLimited {
model: String,
reason: RateLimitReason,
},
BudgetExceeded(
BudgetExceeded,
),
}
impl Display for RouterError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
RouterError::Empty => write!(f, "no models configured in router"),
RouterError::AllFailed { tried, last } => {
write!(f, "all {} models failed; last error: {}", tried, last)
}
RouterError::Model { model, source } => {
write!(f, "model '{}' error: {}", model, source)
}
RouterError::RateLimited { model, reason } => {
write!(f, "model '{}' rate limited: {}", model, reason)
}
RouterError::BudgetExceeded(exceeded) => {
write!(f, "router budget skip — {exceeded}")
}
}
}
}
impl std::error::Error for RouterError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
RouterError::AllFailed { last, .. } => Some(last.as_ref()),
RouterError::Model { source, .. } => Some(source.as_ref()),
RouterError::BudgetExceeded(exceeded) => Some(exceeded),
RouterError::Empty | RouterError::RateLimited { .. } => None,
}
}
}
pub enum RoutingStrategy {
Fallback,
RoundRobin,
LeastLatency,
LatencyWeighted(f64),
LowestCost,
InputDirected(Arc<dyn Fn(&str) -> usize + Send + Sync>),
}
pub struct RouterLLM {
name: String,
slots: Vec<ModelSlot>,
strategy: RoutingStrategy,
counter: AtomicUsize,
rng: AtomicU64,
registry: Option<Arc<ModelRegistry>>,
budget: Option<Arc<RouterBudget>>,
}
impl RouterLLM {
pub fn new(strategy: RoutingStrategy) -> Self {
Self {
name: "router".to_string(),
slots: Vec::new(),
strategy,
counter: AtomicUsize::new(0),
rng: AtomicU64::new(0x9E37_79B9_7F4A_7C15),
registry: None,
budget: None,
}
}
pub fn with_budget(mut self, budget: Arc<RouterBudget>) -> Self {
self.budget = Some(budget);
self
}
pub fn with_registry(mut self, registry: Arc<ModelRegistry>) -> Self {
self.registry = Some(registry);
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
pub fn with_model<M>(mut self, model: M) -> Self
where
M: BaseChatModel + 'static,
M::Error: std::error::Error + Send + Sync + 'static,
{
self.slots
.push(ModelSlot::new(Box::new(ModelAdapter(model)), None));
self
}
pub fn with_cost<M>(mut self, model: M, cost: f64) -> Self
where
M: BaseChatModel + 'static,
M::Error: std::error::Error + Send + Sync + 'static,
{
self.slots
.push(ModelSlot::new(Box::new(ModelAdapter(model)), Some(cost)));
self
}
pub fn with_model_as<M, K>(mut self, model: M, registry_key: K) -> Self
where
M: BaseChatModel + 'static,
M::Error: std::error::Error + Send + Sync + 'static,
K: Into<String>,
{
self.slots
.push(ModelSlot::new(Box::new(ModelAdapter(model)), None).with_key(registry_key));
self
}
pub fn with_priced_model<M>(mut self, model: M, price: ModelPrice) -> Self
where
M: BaseChatModel + 'static,
M::Error: std::error::Error + Send + Sync + 'static,
{
self.slots
.push(ModelSlot::new(Box::new(ModelAdapter(model)), None).with_price(Some(price)));
self
}
pub fn with_model_rate_limited<M>(mut self, model: M, limit: ModelRateLimit) -> Self
where
M: BaseChatModel + 'static,
M::Error: std::error::Error + Send + Sync + 'static,
{
self.slots.push(
ModelSlot::new(Box::new(ModelAdapter(model)), None)
.with_gate(rate::ModelGate::new(&limit)),
);
self
}
pub fn with_last_price(mut self, price: ModelPrice) -> Self {
match self.slots.last_mut() {
Some(slot) => slot.price = Some(price),
None => log::warn!("with_last_price called before any model was registered"),
}
self
}
pub fn with_last_rate_limit(mut self, limit: ModelRateLimit) -> Self {
match self.slots.last_mut() {
Some(slot) => slot.gate = Some(rate::ModelGate::new(&limit)),
None => log::warn!("with_last_rate_limit called before any model was registered"),
}
self
}
pub fn with_fallbacks<M>(primary: M, fallbacks: Vec<M>) -> Self
where
M: BaseChatModel + 'static,
M::Error: std::error::Error + Send + Sync + 'static,
{
let mut router = RouterLLM::new(RoutingStrategy::Fallback);
router = router.with_model(primary);
for fb in fallbacks {
router = router.with_model(fb);
}
router
}
pub fn len(&self) -> usize {
self.slots.len()
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
fn candidate_order(&self, input: &str) -> Vec<usize> {
let n = self.slots.len();
match &self.strategy {
RoutingStrategy::Fallback => (0..n).collect(),
RoutingStrategy::RoundRobin => {
if n == 0 {
(0..n).collect()
} else {
let start = self.counter.fetch_add(1, Ordering::SeqCst) % n;
(0..n).map(|i| (start + i) % n).collect()
}
}
RoutingStrategy::LeastLatency => {
let mut idx: Vec<usize> = (0..n).collect();
idx.sort_by(|a, b| {
let la = {
let v = self.slots[*a].latency();
if v == 0.0 {
f64::MAX
} else {
v
}
};
let lb = {
let v = self.slots[*b].latency();
if v == 0.0 {
f64::MAX
} else {
v
}
};
la.partial_cmp(&lb).unwrap_or(std::cmp::Ordering::Equal)
});
idx
}
RoutingStrategy::LatencyWeighted(beta) => {
let beta = if beta.is_finite() && *beta >= 0.0 {
*beta
} else {
1.0
};
let observed: Vec<f64> = self.slots.iter().map(|s| s.latency()).collect();
let best = observed
.iter()
.copied()
.filter(|l| *l > 0.0)
.fold(f64::MAX, f64::min);
let assumed = if best == f64::MAX { 1.0 } else { best };
let weights: Vec<f64> = observed
.iter()
.map(|l| {
let latency = if *l == 0.0 { assumed } else { *l };
(1.0 / latency).powf(beta)
})
.collect();
let total: f64 = weights.iter().sum();
let target = if total > 0.0 {
self.next_pseudo() * total
} else {
0.0
};
let mut primary = n.saturating_sub(1);
let mut cumulative = 0.0;
for (i, weight) in weights.iter().enumerate() {
cumulative += *weight;
if target < cumulative {
primary = i;
break;
}
}
let mut rest: Vec<usize> = (0..n).filter(|&i| i != primary).collect();
rest.sort_by(|&a, &b| {
weights[b]
.partial_cmp(&weights[a])
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.cmp(&b))
});
let mut order = Vec::with_capacity(n);
order.push(primary);
order.extend(rest);
order
}
RoutingStrategy::LowestCost => {
let mut idx: Vec<usize> = (0..n).collect();
idx.sort_by(|a, b| {
let ca = self.effective_cost(&self.slots[*a]);
let cb = self.effective_cost(&self.slots[*b]);
ca.partial_cmp(&cb).unwrap_or(std::cmp::Ordering::Equal)
});
idx
}
RoutingStrategy::InputDirected(f) => {
let primary = f(input);
if primary < n {
let mut order = vec![primary];
order.extend((0..n).filter(|&i| i != primary));
order
} else {
(0..n).collect()
}
}
}
}
fn first_text(messages: &[Message]) -> &str {
messages.first().map(|m| m.content.as_str()).unwrap_or("")
}
fn effective_cost(&self, slot: &ModelSlot) -> f64 {
if let Some(cost) = slot.cost {
return cost;
}
if let Some(price) = self.slot_price(slot) {
return price.blended_per_1k();
}
f64::MAX
}
fn slot_price(&self, slot: &ModelSlot) -> Option<ModelPrice> {
if slot.price.is_some() {
return slot.price;
}
if let (Some(registry), Some(key)) = (&self.registry, &slot.registry_key) {
if let Some(info) = registry.get_by_key(key) {
return Some(info.price);
}
}
None
}
fn estimate_prompt_tokens(&self, messages: &[Message]) -> usize {
let joined = messages
.iter()
.map(|m| m.content.as_str())
.collect::<Vec<_>>()
.join("\n");
self.get_num_tokens(&joined)
}
fn precheck_budget(
&self,
slot: &ModelSlot,
estimated_tokens: usize,
) -> Result<(), BudgetExceeded> {
let Some(budget) = &self.budget else {
return Ok(());
};
let projected = self
.slot_price(slot)
.map(|price| price.cost_of(estimated_tokens, 0))
.unwrap_or(0.0);
budget.precheck(projected, estimated_tokens as u64)
}
fn record_usage(&self, slot: &ModelSlot, usage: &TokenUsage) {
if let Some(budget) = &self.budget {
let cost = self
.slot_price(slot)
.map(|price| price.cost_of(usage.prompt_tokens, usage.completion_tokens))
.unwrap_or(0.0);
budget.record(cost, usage.total_tokens as u64);
}
}
fn next_pseudo(&self) -> f64 {
let mut z = self
.rng
.fetch_add(1, Ordering::SeqCst)
.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
(z >> 11) as f64 / (1u64 << 53) as f64
}
async fn chat_routed(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<LLMResult, RouterError> {
if self.slots.is_empty() {
return Err(RouterError::Empty);
}
let order = self.candidate_order(Self::first_text(&messages));
let estimated_tokens = self.estimate_prompt_tokens(&messages);
let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
for &idx in &order {
let slot = &self.slots[idx];
if let Err(exceeded) = self.precheck_budget(slot, estimated_tokens) {
last_err = Some(Box::new(RouterError::BudgetExceeded(exceeded)));
continue;
}
let permit = match &slot.gate {
Some(gate) => match gate.acquire(slot.model.name()).await {
Ok(permit) => Some(permit),
Err(e) => {
last_err = Some(Box::new(e));
continue;
}
},
None => None,
};
let start = Instant::now();
let res = slot.model.chat(messages.clone(), config.clone()).await;
let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
slot.update_latency(elapsed_ms);
match res {
Ok(result) => {
if let Some(usage) = result.token_usage.as_ref() {
self.record_usage(slot, usage);
}
drop(permit);
return Ok(result);
}
Err(e) => last_err = Some(Box::new(e)),
}
}
Err(RouterError::AllFailed {
tried: order.len(),
last: last_err.unwrap_or_else(|| {
Box::new(std::io::Error::other(
"candidate order produced no attempts",
))
}),
})
}
async fn stream_chat_routed(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, RouterError>> + Send>>, RouterError>
{
if self.slots.is_empty() {
return Err(RouterError::Empty);
}
let order = self.candidate_order(Self::first_text(&messages));
let estimated_tokens = self.estimate_prompt_tokens(&messages);
let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
for &idx in &order {
let slot = &self.slots[idx];
if let Err(exceeded) = self.precheck_budget(slot, estimated_tokens) {
last_err = Some(Box::new(RouterError::BudgetExceeded(exceeded)));
continue;
}
let permit = match &slot.gate {
Some(gate) => match gate.acquire(slot.model.name()).await {
Ok(permit) => Some(permit),
Err(e) => {
last_err = Some(Box::new(e));
continue;
}
},
None => None,
};
let start = Instant::now();
match slot
.model
.stream_chat(messages.clone(), config.clone())
.await
{
Ok(stream) => {
let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
slot.update_latency(elapsed_ms);
let budget = self.budget.clone();
let price = self.slot_price(slot);
let guarded = async_stream::stream! {
let _permit = permit;
let mut recorded = false;
let mut inner = stream;
while let Some(item) = inner.next().await {
if let Ok(chunk) = &item {
if !recorded {
if let Some(usage) = chunk.token_usage.as_ref() {
if let Some(budget) = &budget {
let cost = price
.map(|p| {
p.cost_of(
usage.prompt_tokens,
usage.completion_tokens,
)
})
.unwrap_or(0.0);
budget.record(cost, usage.total_tokens as u64);
}
recorded = true;
}
}
}
yield item;
}
};
return Ok(Box::pin(guarded));
}
Err(e) => {
let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
slot.update_latency(elapsed_ms);
last_err = Some(Box::new(e));
}
}
}
Err(RouterError::AllFailed {
tried: order.len(),
last: last_err.unwrap_or_else(|| {
Box::new(std::io::Error::other(
"candidate order produced no attempts",
))
}),
})
}
}
#[async_trait]
impl Runnable<Vec<Message>, LLMResult> for RouterLLM {
type Error = RouterError;
async fn invoke(
&self,
input: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
self.chat_routed(input, config).await
}
}
#[async_trait]
impl BaseLanguageModel<Vec<Message>, LLMResult> for RouterLLM {
fn model_name(&self) -> &str {
&self.name
}
fn get_num_tokens(&self, text: &str) -> usize {
crate::token_counter::count_tokens(text).unwrap_or_else(|e| {
log::warn!("token counting failed, falling back to byte-length estimate: {e}");
text.len()
})
}
fn with_temperature(self, _temp: f32) -> Self {
self
}
fn with_max_tokens(self, _max: usize) -> Self {
self
}
}
#[async_trait]
impl BaseChatModel for RouterLLM {
async fn chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
self.chat_routed(messages, config).await
}
async fn stream_chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
{
self.stream_chat_routed(messages, config).await
}
}
#[async_trait]
trait RoutedModel: Send + Sync {
fn name(&self) -> &str;
async fn chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<LLMResult, RouterError>;
async fn stream_chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, RouterError>> + Send>>, RouterError>;
}
struct ModelAdapter<M: BaseChatModel>(M);
#[async_trait]
impl<M: BaseChatModel> RoutedModel for ModelAdapter<M>
where
M::Error: std::error::Error + Send + Sync + 'static,
{
fn name(&self) -> &str {
self.0.model_name()
}
async fn chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<LLMResult, RouterError> {
let name = self.0.model_name().to_string();
self.0
.chat(messages, config)
.await
.map_err(|e| RouterError::Model {
model: name,
source: Box::new(e),
})
}
async fn stream_chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, RouterError>> + Send>>, RouterError>
{
let name = self.0.model_name().to_string();
let inner = self
.0
.stream_chat(messages, config)
.await
.map_err(|e| RouterError::Model {
model: name.clone(),
source: Box::new(e),
})?;
let mapped = inner.map(move |item| {
item.map_err(|e| RouterError::Model {
model: name.clone(),
source: Box::new(e),
})
});
Ok(Box::pin(mapped))
}
}
struct ModelSlot {
model: Box<dyn RoutedModel>,
cost: Option<f64>,
registry_key: Option<String>,
price: Option<ModelPrice>,
gate: Option<Arc<rate::ModelGate>>,
latency_ms: Mutex<f64>,
}
impl ModelSlot {
fn new(model: Box<dyn RoutedModel>, cost: Option<f64>) -> Self {
Self {
model,
cost,
registry_key: None,
price: None,
gate: None,
latency_ms: Mutex::new(0.0),
}
}
fn with_key<K: Into<String>>(mut self, key: K) -> Self {
self.registry_key = Some(key.into());
self
}
fn with_price(mut self, price: Option<ModelPrice>) -> Self {
self.price = price;
self
}
fn with_gate(mut self, gate: Arc<rate::ModelGate>) -> Self {
self.gate = Some(gate);
self
}
fn latency(&self) -> f64 {
*self.latency_ms.lock().unwrap_or_else(|e| e.into_inner())
}
fn update_latency(&self, ms: f64) {
let mut cur = self.latency_ms.lock().unwrap_or_else(|e| e.into_inner());
if *cur == 0.0 {
*cur = ms;
} else {
*cur = *cur * 0.7 + ms * 0.3;
}
}
}