use std::collections::BTreeMap;
use std::sync::Arc;
use async_trait::async_trait;
use futures::stream::{self, BoxStream};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::message::{Content, Message};
use crate::pricing::Pricing;
use crate::tool::ToolSpec;
#[derive(Debug, Clone, Default)]
pub struct ModelRequest {
pub system: Option<String>,
pub messages: Vec<Message>,
pub tools: Vec<ToolSpec>,
pub max_tokens: u32,
pub temperature: Option<f32>,
pub provider_options: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
#[default]
EndTurn,
ToolUse,
MaxTokens,
Other(String),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
#[serde(default)]
pub cache_read_tokens: u64,
#[serde(default)]
pub cache_write_tokens: u64,
}
impl Usage {
pub fn total(&self) -> u64 {
self.input_tokens + self.output_tokens + self.cache_read_tokens + self.cache_write_tokens
}
pub fn add(&mut self, other: &Usage) {
self.input_tokens += other.input_tokens;
self.output_tokens += other.output_tokens;
self.cache_read_tokens += other.cache_read_tokens;
self.cache_write_tokens += other.cache_write_tokens;
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ModelResponse {
pub model: String,
pub content: Vec<Content>,
pub stop_reason: StopReason,
pub usage: Usage,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub served_by: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rejected: Vec<RejectedAttempt>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RejectedAttempt {
pub model: String,
pub reason: String,
pub usage: Usage,
pub cost_usd: Option<f64>,
}
impl ModelResponse {
pub fn total_usage(&self) -> Usage {
let mut u = self.usage;
for r in &self.rejected {
u.add(&r.usage);
}
u
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum ModelError {
#[error("transport error: {0}")]
Transport(String),
#[error("provider returned {status}: {body}")]
Api { status: u16, body: String },
#[error("could not decode provider response: {0}")]
Decode(String),
#[error("no usable response: {reason}")]
Unusable { reason: String, attempts: Vec<RejectedAttempt> },
#[error("{0}")]
Other(String),
}
impl ModelError {
pub fn retryable(&self) -> bool {
match self {
ModelError::Transport(_) => true,
ModelError::Api { status, .. } => *status == 408 || *status == 429 || *status >= 500,
_ => false,
}
}
pub fn spent(&self) -> (Usage, Option<f64>) {
match self {
ModelError::Unusable { attempts, .. } => {
let mut usage = Usage::default();
let mut cost = Some(0.0);
for a in attempts {
usage.add(&a.usage);
cost = cost.zip(a.cost_usd).map(|(x, y)| x + y);
}
(usage, cost)
}
_ => (Usage::default(), Some(0.0)),
}
}
}
#[async_trait]
pub trait Model: Send + Sync {
fn id(&self) -> String;
async fn complete(&self, request: &ModelRequest) -> Result<ModelResponse, ModelError>;
async fn stream(&self, request: &ModelRequest) -> Result<ModelStream, ModelError> {
let response = self.complete(request).await?;
Ok(Box::pin(stream::iter(synthesize_stream(response).into_iter().map(Ok))))
}
fn pricing(&self) -> Option<Pricing> {
None
}
fn cost_usd(&self, response: &ModelResponse) -> Option<f64> {
self.pricing().map(|p| p.cost(&response.usage))
}
fn max_call_cost(&self, input_tokens: u64, output_tokens: u64) -> Option<f64> {
self.pricing()
.map(|p| (input_tokens as f64 * p.input.max(p.cache_write) + output_tokens as f64 * p.output) / 1_000_000.0)
}
fn unpriced_models(&self) -> Vec<String> {
if self.pricing().is_none() { vec![self.id()] } else { Vec::new() }
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum StreamEvent {
TextDelta(String),
ThinkingDelta(String),
ThinkingSignature(String),
ToolCallStart {
index: usize,
id: String,
name: String,
},
ToolInputDelta {
index: usize,
partial_json: String,
},
Completed(ModelResponse),
}
pub type ModelStream = BoxStream<'static, Result<StreamEvent, ModelError>>;
pub fn synthesize_stream(response: ModelResponse) -> Vec<StreamEvent> {
let mut events = Vec::new();
let mut tool_index = 0;
for c in &response.content {
match c {
Content::Text { text } => events.push(StreamEvent::TextDelta(text.clone())),
Content::Thinking { text, provider_data } => {
events.push(StreamEvent::ThinkingDelta(text.clone()));
if let Some(sig) = provider_data.as_ref().and_then(|d| d["signature"].as_str()) {
events.push(StreamEvent::ThinkingSignature(sig.to_owned()));
}
}
Content::ToolUse { id, name, input } => {
events.push(StreamEvent::ToolCallStart { index: tool_index, id: id.clone(), name: name.clone() });
events.push(StreamEvent::ToolInputDelta { index: tool_index, partial_json: input.to_string() });
tool_index += 1;
}
Content::ToolResult { .. } => {}
}
}
events.push(StreamEvent::Completed(response));
events
}
type UsableFn = Arc<dyn Fn(&ModelResponse) -> Result<(), String> + Send + Sync>;
pub struct FallbackModel {
models: Vec<Arc<dyn Model>>,
allow_truncated: bool,
allow_empty: bool,
json_fields: Option<Vec<String>>,
custom: Option<UsableFn>,
keep_last: bool,
}
impl FallbackModel {
pub fn new(models: Vec<Arc<dyn Model>>) -> Self {
assert!(!models.is_empty(), "FallbackModel needs at least one model");
Self { models, allow_truncated: false, allow_empty: false, json_fields: None, custom: None, keep_last: false }
}
pub fn keep_last_answer(mut self) -> Self {
self.keep_last = true;
self
}
pub fn accept_truncated(mut self) -> Self {
self.allow_truncated = true;
self
}
pub fn accept_empty(mut self) -> Self {
self.allow_empty = true;
self
}
pub fn require_json_fields(mut self, fields: &[&str]) -> Self {
self.json_fields = Some(fields.iter().map(|f| (*f).to_owned()).collect());
self
}
pub fn require(mut self, check: impl Fn(&ModelResponse) -> Result<(), String> + Send + Sync + 'static) -> Self {
self.custom = Some(Arc::new(check));
self
}
fn has_checks(&self) -> bool {
!self.allow_truncated || !self.allow_empty || self.json_fields.is_some() || self.custom.is_some()
}
pub fn check(&self, r: &ModelResponse) -> Result<(), String> {
if !self.allow_truncated && r.stop_reason == StopReason::MaxTokens {
return Err("reply was cut off at the token limit".into());
}
let has_tools = r.content.iter().any(|c| matches!(c, Content::ToolUse { .. }));
let text = crate::message::content_text(&r.content);
if !self.allow_empty && !has_tools && text.trim().is_empty() {
return Err("reply is empty".into());
}
if let (Some(fields), false) = (&self.json_fields, has_tools) {
let Some(serde_json::Value::Object(obj)) = crate::output::extract_json(&text) else {
return Err("reply is not a JSON object".into());
};
let missing: Vec<&str> = fields.iter().filter(|f| !obj.contains_key(*f)).map(String::as_str).collect();
if !missing.is_empty() {
return Err(format!("JSON is missing required field(s): {}", missing.join(", ")));
}
}
if let Some(check) = &self.custom {
check(r)?;
}
Ok(())
}
}
#[async_trait]
impl Model for FallbackModel {
fn id(&self) -> String {
let ids: Vec<_> = self.models.iter().map(|m| m.id()).collect();
format!("fallback({})", ids.join(","))
}
async fn complete(&self, request: &ModelRequest) -> Result<ModelResponse, ModelError> {
let mut last_err = None;
let mut rejected: Vec<RejectedAttempt> = Vec::new();
let mut last_unusable: Option<ModelResponse> = None;
for model in &self.models {
match model.complete(request).await {
Ok(mut resp) => {
resp.served_by.get_or_insert_with(|| model.id());
match self.check(&resp) {
Ok(()) => {
rejected.append(&mut resp.rejected);
resp.rejected = rejected;
return Ok(resp);
}
Err(reason) => {
tracing::warn!(model = %model.id(), %reason, "unusable answer, falling back");
rejected.append(&mut resp.rejected);
if self.keep_last {
last_unusable = Some(resp.clone());
last_err = None;
}
rejected.push(RejectedAttempt {
model: model.id(),
reason,
usage: resp.usage,
cost_usd: model.cost_usd(&resp),
});
}
}
}
Err(e) if e.retryable() => {
tracing::warn!(model = %model.id(), error = %e, "model failed, falling back");
last_err = Some(e);
}
Err(ModelError::Unusable { reason, attempts }) => {
rejected.extend(attempts);
last_err = Some(ModelError::Other(reason));
}
Err(e) if rejected.is_empty() => return Err(e),
Err(e) => {
last_err = Some(e);
break;
}
}
}
if rejected.is_empty() {
return Err(last_err.expect("at least one model"));
}
if let (Some(mut resp), None) = (last_unusable, &last_err) {
rejected.pop();
resp.rejected = rejected;
tracing::warn!(model = resp.served_by.as_deref().unwrap_or(""), "no usable answer; keeping the last one");
return Ok(resp);
}
let mut reason = rejected.iter().map(|r| format!("{}: {}", r.model, r.reason)).collect::<Vec<_>>().join("; ");
if let Some(e) = last_err {
reason = format!("{reason}; last error: {e}");
}
Err(ModelError::Unusable { reason, attempts: rejected })
}
async fn stream(&self, request: &ModelRequest) -> Result<ModelStream, ModelError> {
if self.has_checks() {
let response = self.complete(request).await?;
return Ok(Box::pin(stream::iter(synthesize_stream(response).into_iter().map(Ok))));
}
let mut last_err = None;
for model in &self.models {
match model.stream(request).await {
Ok(s) => {
let id = model.id();
return Ok(Box::pin(futures::StreamExt::map(s, move |ev| match ev {
Ok(StreamEvent::Completed(mut resp)) => {
resp.served_by.get_or_insert_with(|| id.clone());
Ok(StreamEvent::Completed(resp))
}
other => other,
})));
}
Err(e) if e.retryable() => {
tracing::warn!(model = %model.id(), error = %e, "model stream failed, falling back");
last_err = Some(e);
}
Err(e) => return Err(e),
}
}
Err(last_err.expect("at least one model"))
}
fn pricing(&self) -> Option<Pricing> {
self.models.iter().map(|m| m.pricing()).try_fold(Pricing::default(), |acc, p| p.map(|p| acc.max(p)))
}
fn cost_usd(&self, response: &ModelResponse) -> Option<f64> {
let served = response.served_by.as_deref();
let answer = match self.models.iter().find(|m| Some(m.id().as_str()) == served) {
Some(m) => m.cost_usd(&ModelResponse { rejected: Vec::new(), ..response.clone() }),
None => self.pricing().map(|p| p.cost(&response.usage)),
}?;
let conservative = self.pricing();
response
.rejected
.iter()
.try_fold(answer, |acc, r| r.cost_usd.or_else(|| conservative.map(|p| p.cost(&r.usage))).map(|c| acc + c))
}
fn unpriced_models(&self) -> Vec<String> {
self.models.iter().flat_map(|m| m.unpriced_models()).collect()
}
fn max_call_cost(&self, input_tokens: u64, output_tokens: u64) -> Option<f64> {
self.models.iter().map(|m| m.max_call_cost(input_tokens, output_tokens)).sum()
}
}
#[async_trait]
impl<M: Model + ?Sized> Model for Arc<M> {
fn id(&self) -> String {
(**self).id()
}
async fn complete(&self, request: &ModelRequest) -> Result<ModelResponse, ModelError> {
(**self).complete(request).await
}
async fn stream(&self, request: &ModelRequest) -> Result<ModelStream, ModelError> {
(**self).stream(request).await
}
fn pricing(&self) -> Option<Pricing> {
(**self).pricing()
}
fn cost_usd(&self, response: &ModelResponse) -> Option<f64> {
(**self).cost_usd(response)
}
fn unpriced_models(&self) -> Vec<String> {
(**self).unpriced_models()
}
fn max_call_cost(&self, input_tokens: u64, output_tokens: u64) -> Option<f64> {
(**self).max_call_cost(input_tokens, output_tokens)
}
}