use crate::breaker::{Breaker, Health};
use crate::budget::{Budget, Purse, Spending, Unpriced};
use crate::chat::request::ChatRequest;
use crate::chat::response::ChatResponse;
use crate::chat::stream::EventStream;
use crate::cost::pricing::{Micros, PriceBook, Rate};
use crate::cost::usage::Usage;
use crate::error::{Error, Result};
use crate::model::{ModelCapabilities, ModelId};
use crate::observe;
use crate::provider::{Access, Provider};
use crate::retry::Retry;
use std::sync::Arc;
use std::time::{Duration, Instant};
pub struct Route {
provider: Arc<dyn Provider>,
model: ModelId,
prices: Option<Arc<PriceBook>>,
}
impl Route {
pub fn new(provider: Arc<dyn Provider>, model: impl Into<ModelId>) -> Self {
Self {
provider: provider.clone(),
model: model.into(),
prices: None,
}
}
#[must_use]
pub fn priced_by(mut self, prices: Arc<PriceBook>) -> Self {
self.prices = Some(prices);
self
}
pub fn rate(&self) -> Option<Rate> {
self.prices.as_ref()?.rate(&self.model).copied()
}
pub fn capabilities(&self) -> Option<ModelCapabilities> {
self.provider.capabilities(&self.model)
}
pub fn name(&self) -> String {
format!("{}/{}", self.provider.id(), self.model)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct Requirements {
pub tools: bool,
pub structured_output: bool,
pub prompt_caching: bool,
pub thinking: bool,
pub images: bool,
pub streaming: bool,
pub must_stay_on_device: bool,
}
impl Requirements {
pub fn of(request: &ChatRequest) -> Requirements {
let needs = request.needs();
Requirements {
tools: needs.tools,
structured_output: needs.structured_output,
prompt_caching: needs.prompt_caching,
thinking: needs.thinking,
images: needs.images,
streaming: false,
must_stay_on_device: false,
}
}
#[must_use]
pub fn on_device(mut self) -> Self {
self.must_stay_on_device = true;
self
}
#[must_use]
pub fn streaming(mut self) -> Self {
self.streaming = true;
self
}
pub fn met_by(self, have: &ModelCapabilities) -> bool {
if self.must_stay_on_device && !have.reach.is_on_device() {
return false;
}
(!self.tools || have.tools)
&& (!self.structured_output || have.structured_output)
&& (!self.prompt_caching || have.prompt_caching)
&& (!self.thinking || have.thinking)
&& (!self.streaming || have.streaming)
&& (!self.images || have.images)
}
pub fn unmet_by(self, have: &ModelCapabilities) -> Vec<&'static str> {
let mut missing = Vec::new();
if self.must_stay_on_device && !have.reach.is_on_device() {
missing.push("on-device");
}
if self.tools && !have.tools {
missing.push("tools");
}
if self.structured_output && !have.structured_output {
missing.push("structured_output");
}
if self.prompt_caching && !have.prompt_caching {
missing.push("prompt_caching");
}
if self.streaming && !have.streaming {
missing.push("streaming");
}
if self.thinking && !have.thinking {
missing.push("thinking");
}
if self.images && !have.images {
missing.push("images");
}
missing
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Attempted {
pub route: String,
pub why: String,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Routed<T = ChatResponse> {
pub response: T,
pub route: String,
pub fell_through: Vec<Attempted>,
pub attempts: u32,
}
impl Routed<()> {
fn carrying<T>(self, response: T) -> Routed<T> {
Routed {
response,
route: self.route,
fell_through: self.fell_through,
attempts: self.attempts,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Order {
#[default]
AsListed,
Cheapest,
Healthiest,
}
pub struct Router {
routes: Vec<Route>,
retry: Option<Retry>,
breaker: Option<Breaker>,
budget: Option<Budget>,
purse: Purse,
deadline: Option<Duration>,
order: Order,
health: Vec<Health>,
since: Instant,
}
impl Router {
pub fn new(routes: Vec<Route>) -> Self {
let health = routes.iter().map(|_| Health::default()).collect();
Self {
routes,
retry: None,
breaker: None,
budget: None,
purse: Purse::default(),
deadline: None,
order: Order::AsListed,
health,
since: Instant::now(),
}
}
#[must_use]
pub fn breaking(mut self, policy: Breaker) -> Self {
self.breaker = Some(policy);
self
}
#[must_use]
pub fn within(mut self, budget: Budget) -> Self {
self.budget = Some(budget);
self
}
pub fn spending(&self) -> Option<Spending> {
self.budget
.as_ref()
.map(|budget| self.purse.spending(budget))
}
pub fn charge(&self, route: &str, model: &ModelId, usage: &Usage) -> bool {
let Some(amount) = self.price_against(route, model, usage) else {
return false;
};
self.purse.spend(amount);
self.purse.measured_after_all();
true
}
fn price_against(&self, route: &str, model: &ModelId, usage: &Usage) -> Option<Micros> {
let budget = self.budget.as_ref()?;
let found = self.routes.iter().find(|r| r.name() == route)?;
let book = found.prices.as_ref()?;
if book.currency != budget.currency() {
return None;
}
Some(book.price(model, usage)?.amount)
}
fn settle(&self, route: &str, model: &ModelId, usage: &Usage) {
if self.budget.is_none() {
return;
}
match self.price_against(route, model, usage) {
Some(amount) => self.purse.spend(amount),
None => self.purse.unmeasured(),
}
}
fn unaffordable(&self, route: &Route, request: &ChatRequest) -> Option<String> {
let budget = self.budget.as_ref()?;
let left = self.purse.remaining(budget.cap());
if left == Micros(0) {
return Some(format!(
"the {} budget of {} is spent",
budget.currency(),
budget.cap()
));
}
let Some(book) = &route.prices else {
return self.without_a_price(route, "carries no price book");
};
if book.currency != budget.currency() {
return Some(format!(
"priced in {} and the budget is in {}. There is no exchange rate in this crate: a rate has a date and a source exactly like a price does",
book.currency,
budget.currency()
));
}
let Some(rate) = book.rate(&route.model) else {
return self.without_a_price(route, "is not in this route's price book");
};
if let Some(max_tokens) = request.generation.max_tokens {
let most = Budget::most_a_reply_can_cost(max_tokens, rate);
if most > left {
return Some(format!(
"the reply alone could cost {most} and only {left} is left",
));
}
}
None
}
fn without_a_price(&self, route: &Route, why: &str) -> Option<String> {
match self.budget.as_ref().map(Budget::unpriced) {
Some(Unpriced::Refused) => Some(format!(
"{} {why}, so it cannot be measured against a budget",
route.model
)),
_ => None,
}
}
#[must_use]
pub fn within_deadline(mut self, deadline: Duration) -> Self {
self.deadline = Some(deadline);
self
}
#[must_use]
pub fn ordering(mut self, order: Order) -> Self {
self.order = order;
self
}
fn reaching_order(&self) -> Vec<usize> {
let mut order: Vec<usize> = (0..self.routes.len()).collect();
match self.order {
Order::AsListed => {}
Order::Cheapest => order.sort_by_key(|&i| {
let rate = self.routes[i].rate();
(
rate.is_none(),
rate.map_or(Micros(0), |rate| rate.input + rate.output),
)
}),
Order::Healthiest => order.sort_by_key(|&i| self.health[i].failures()),
}
order
}
pub fn resting(&self) -> Vec<(String, Duration)> {
self.routes
.iter()
.zip(&self.health)
.filter_map(|(route, health)| {
health
.closed_for(self.since)
.map(|left| (route.name(), left))
})
.collect()
}
#[must_use]
pub fn retrying(mut self, policy: Retry) -> Self {
self.retry = Some(policy);
self
}
pub fn unusable(&self) -> Vec<String> {
self.routes
.iter()
.filter(|route| route.capabilities().is_none())
.map(Route::name)
.collect()
}
pub fn routes(&self) -> impl Iterator<Item = (String, Option<ModelCapabilities>)> + '_ {
self.routes
.iter()
.map(|route| (route.name(), route.capabilities()))
}
pub async fn preflight(&self) -> Vec<(String, Access)> {
let mut reached = Vec::with_capacity(self.routes.len());
for (route, health) in self.routes.iter().zip(&self.health) {
let access = route.provider.validate(&route.model).await;
if let (Access::Denied { .. }, Some(breaker)) = (&access, &self.breaker) {
health.denied_by(breaker.settled, self.since);
}
reached.push((route.name(), access));
}
reached
}
pub async fn chat(&self, request: ChatRequest, needs: Requirements) -> Result<Routed> {
let span = observe::routing(&request.model);
let journey = self.attempt(request, needs, |provider, request| provider.chat(request));
let (response, routed) = observe::inside(span.clone(), journey).await?;
self.settle(&routed.route, &response.model, &response.usage);
observe::routed(
&span,
&routed.route,
response.usage.coverage(),
routed.attempts,
routed.fell_through.len(),
);
Ok(routed.carrying(response))
}
pub async fn stream(
&self,
request: ChatRequest,
needs: Requirements,
) -> Result<(EventStream<'_>, Routed<()>)> {
let span = observe::routing(&request.model);
let journey = self.attempt(request, needs, |provider, request| provider.stream(request));
let (events, routed) = observe::inside(span.clone(), journey).await?;
if self.budget.is_some() {
self.purse.unmeasured();
}
observe::routed_stream(
&span,
&routed.route,
routed.attempts,
routed.fell_through.len(),
);
Ok((events, routed))
}
async fn attempt<'a, T, Fut>(
&'a self,
request: ChatRequest,
needs: Requirements,
call: impl Fn(&'a dyn Provider, ChatRequest) -> Fut,
) -> Result<(T, Routed<()>)>
where
Fut: std::future::Future<Output = Result<T>> + 'a,
{
let started = Instant::now();
let mut fell_through = Vec::new();
let mut last: Option<Error> = None;
let mut attempts = 0;
let mut resting = 0;
let mut over_budget = 0;
for index in self.reaching_order() {
let (route, health) = (&self.routes[index], &self.health[index]);
let Some(capabilities) = route.capabilities() else {
fell_through.push(Attempted {
route: route.name(),
why: "the provider does not know this model".into(),
});
continue;
};
let missing = needs.unmet_by(&capabilities);
if !missing.is_empty() {
fell_through.push(Attempted {
route: route.name(),
why: format!("cannot do {}", missing.join(", ")),
});
continue;
}
if let Some(left) = health.closed_for(self.since) {
resting += 1;
fell_through.push(Attempted {
route: route.name(),
why: health.why(left),
});
continue;
}
if let Some(why) = self.unaffordable(route, &request) {
over_budget += 1;
fell_through.push(Attempted {
route: route.name(),
why,
});
continue;
}
if self.out_of_time(started) {
return Err(self.gave_up(started, attempts, &fell_through));
}
let mut sending = request.clone();
sending.model = route.model.clone();
let mut failed: Option<Error> = None;
let allowed = self.retry.as_ref().map_or(1, Retry::attempts);
for attempt in 1..=allowed {
attempts += 1;
match call(route.provider.as_ref(), sending.clone()).await {
Ok(response) => {
health.served();
return Ok((
response,
Routed {
response: (),
route: route.name(),
fell_through,
attempts,
},
));
}
Err(error) => {
if matches!(error, Error::Refused { .. }) {
self.record(health, &error);
return Err(error);
}
let waiting = self
.retry
.as_ref()
.and_then(|policy| policy.wait_before(attempt + 1, &error));
fell_through.push(Attempted {
route: route.name(),
why: match waiting {
Some(wait) => format!(
"{error} (attempt {attempt} of {allowed}, waiting {}ms)",
wait.as_millis()
),
None => error.to_string(),
},
});
match waiting {
Some(wait) if self.no_room_for(started, wait) => {
self.record(health, &error);
return Err(self.gave_up(started, attempts, &fell_through));
}
Some(wait) => {
last = Some(error);
if let Some(policy) = &self.retry {
policy.sleep(wait).await;
}
}
None => {
failed = Some(error);
break;
}
}
}
}
}
if let Some(error) = failed {
self.record(health, &error);
last = Some(error);
}
}
match last {
Some(error) => Err(error),
None if over_budget > 0 => Err(Error::OverBudget(Self::listing(&fell_through))),
None if resting > 0 => Err(Error::Transient(format!(
"every route that could serve this is circuit open. Tried: {}",
Self::listing(&fell_through)
))),
None => Err(Error::Unsupported(format!(
"no route can serve this request. Tried: {}",
Self::listing(&fell_through)
))),
}
}
fn out_of_time(&self, started: Instant) -> bool {
self.deadline
.is_some_and(|deadline| started.elapsed() >= deadline)
}
fn no_room_for(&self, started: Instant, wait: Duration) -> bool {
self.deadline
.is_some_and(|deadline| started.elapsed().saturating_add(wait) >= deadline)
}
fn gave_up(&self, started: Instant, attempts: u32, fell_through: &[Attempted]) -> Error {
let elapsed = started.elapsed();
observe::gave_up(elapsed, attempts, &Self::listing(fell_through));
Error::Timeout { elapsed }
}
fn record(&self, health: &Health, error: &Error) {
let failures = health.failed();
if let Some(breaker) = &self.breaker {
health.rest(breaker, failures, error, self.since);
}
}
fn listing(fell_through: &[Attempted]) -> String {
fell_through
.iter()
.map(|a| format!("{} ({})", a.route, a.why))
.collect::<Vec<_>>()
.join("; ")
}
}