use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use super::{ChatModel, ChatRequest, ChatResponse, Usage};
use crate::context::{CamelContext, WeakContext};
use crate::error::{CamelError, Result};
#[derive(Clone, Default)]
pub struct Reliability {
pub max_retries: u32,
pub retry_delay: Duration,
pub fallbacks: Vec<String>,
pub cache_ttl: Option<Duration>,
pub cache_size: usize,
pub coalesce: bool,
ctx: Option<WeakContext>,
state: Arc<State>,
}
impl fmt::Debug for Reliability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Reliability")
.field("max_retries", &self.max_retries)
.field("retry_delay", &self.retry_delay)
.field("fallbacks", &self.fallbacks)
.field("cache_ttl", &self.cache_ttl)
.field("coalesce", &self.coalesce)
.finish()
}
}
type Flight = Arc<(Mutex<Option<Result<ChatResponse>>>, Condvar)>;
#[derive(Default)]
struct State {
cache: Mutex<HashMap<[u8; 32], (Instant, ChatResponse)>>,
inflight: Mutex<HashMap<[u8; 32], Flight>>,
}
impl Reliability {
pub fn take(params: &mut crate::util::Parameters) -> Result<Reliability> {
let max_retries = params.take_usize("maxRetries", 2)? as u32;
let retry_delay = params.take_duration("retryDelay", Duration::from_millis(500))?;
let fallbacks = params.take("fallbackModels").map(|f| f.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()).unwrap_or_default();
let cache = match params.take_string("responseCache", "off").to_ascii_lowercase().as_str() {
"off" | "none" | "false" => false,
"exact" | "true" | "on" => true,
other => return Err(CamelError::illegal_argument(format!("responseCache must be off or exact, got {}", other))),
};
let ttl = params.take_duration("responseCacheTtl", Duration::from_secs(300))?;
let cache_size = params.take_usize("responseCacheSize", 1000)?.max(1);
let coalesce = params.take_bool("coalesce", false)?;
Ok(Reliability { max_retries, retry_delay, fallbacks, cache_ttl: if cache && !ttl.is_zero() { Some(ttl) } else { None }, cache_size, coalesce, ctx: None, state: Arc::default() })
}
pub fn with_context(mut self, ctx: &CamelContext) -> Reliability {
self.ctx = Some(ctx.downgrade());
self
}
pub fn without_retries(&self) -> Reliability {
Reliability { max_retries: 0, ..self.clone() }
}
pub fn needs_wrapper(&self) -> bool {
!self.fallbacks.is_empty() || self.cache_ttl.is_some() || self.coalesce
}
pub fn wrap(&self, model: Arc<dyn ChatModel>, id: &str) -> Arc<dyn ChatModel> {
if self.max_retries == 0 && !self.needs_wrapper() {
return model;
}
Arc::new(ReliableModel { inner: model, opts: self.clone(), id: id.to_string() })
}
pub fn retry<T>(&self, mut f: impl FnMut() -> Result<T>) -> Result<T> {
let mut delay = self.retry_delay;
let mut attempt = 0;
loop {
match f() {
Err(e) if attempt < self.max_retries && is_transient(&e) => {
attempt += 1;
crate::camel_debug!("ai", "model call failed ({}), retry {} of {} in {:?}", e.message, attempt, self.max_retries, delay);
std::thread::sleep(delay);
delay = (delay * 2).min(Duration::from_secs(10));
}
r => return r,
}
}
}
fn key(&self, id: &str, req: &ChatRequest) -> [u8; 32] {
crate::language::support::sha256(format!("{}\u{0}{:?}", id, req).as_bytes())
}
fn cached(&self, key: &[u8; 32]) -> Option<ChatResponse> {
let ttl = self.cache_ttl?;
let c = self.state.cache.lock().unwrap_or_else(|p| p.into_inner());
c.get(key).filter(|(at, _)| at.elapsed() < ttl).map(|(_, r)| ChatResponse { usage: Usage::default(), ..r.clone() })
}
fn store(&self, key: [u8; 32], resp: &ChatResponse) {
let Some(ttl) = self.cache_ttl else { return };
let mut c = self.state.cache.lock().unwrap_or_else(|p| p.into_inner());
if c.len() >= self.cache_size {
c.retain(|_, (at, _)| at.elapsed() < ttl);
if c.len() >= self.cache_size {
if let Some(oldest) = c.iter().min_by_key(|(_, (at, _))| *at).map(|(k, _)| *k) {
c.remove(&oldest);
}
}
}
c.insert(key, (Instant::now(), resp.clone()));
}
fn call(&self, inner: &dyn ChatModel, req: &ChatRequest) -> Result<ChatResponse> {
let first = self.retry(|| inner.chat(req));
let Err(mut err) = first else { return first };
for uri in &self.fallbacks {
let model = match self.fallback(uri) {
Ok(m) => m,
Err(e) => {
crate::camel_warn!("ai", "fallback model {} is not usable: {}", uri, e.message);
continue;
}
};
crate::camel_info!("ai", "model call failed ({}), falling back to {}", err.message, uri);
match self.retry(|| model.chat(req)) {
Ok(r) => return Ok(r),
Err(e) => err = e,
}
}
Err(err)
}
fn fallback(&self, uri: &str) -> Result<Arc<dyn ChatModel>> {
let ctx = self.ctx.as_ref().and_then(|c| c.upgrade()).ok_or_else(|| CamelError::illegal_state("fallbackModels need a context"))?;
ctx.get_endpoint(uri)?.chat_model().ok_or_else(|| CamelError::illegal_argument(format!("{} is not a chat model", uri)))
}
}
pub fn is_transient(e: &CamelError) -> bool {
let m = e.message.to_ascii_lowercase();
for marker in ["statuscode: ", "status code ", "status: ", "http "] {
if let Some(i) = m.find(marker) {
let code: String = m[i + marker.len()..].chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(c) = code.parse::<u16>() {
return c == 408 || c == 409 || c == 425 || c == 429 || (500..600).contains(&c);
}
}
}
["timed out", "timeout", "connection reset", "broken pipe", "unexpected eof", "throttl", "overloaded", "rate limit", "too many requests"].iter().any(|k| m.contains(k))
}
struct ReliableModel {
inner: Arc<dyn ChatModel>,
opts: Reliability,
id: String,
}
impl ChatModel for ReliableModel {
fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
let o = &self.opts;
if o.cache_ttl.is_none() && !o.coalesce {
return o.call(self.inner.as_ref(), req);
}
let key = o.key(&self.id, req);
if let Some(r) = o.cached(&key) {
return Ok(r);
}
if !o.coalesce {
let r = o.call(self.inner.as_ref(), req)?;
o.store(key, &r);
return Ok(r);
}
let (flight, leader) = {
let mut m = o.state.inflight.lock().unwrap_or_else(|p| p.into_inner());
match m.get(&key) {
Some(f) => (f.clone(), false),
None => {
let f: Flight = Arc::default();
m.insert(key, f.clone());
(f, true)
}
}
};
if leader {
let r = o.call(self.inner.as_ref(), req);
if let Ok(resp) = &r {
o.store(key, resp);
}
o.state.inflight.lock().unwrap_or_else(|p| p.into_inner()).remove(&key);
let (lock, cv) = &*flight;
*lock.lock().unwrap_or_else(|p| p.into_inner()) = Some(r.clone());
cv.notify_all();
return r;
}
let (lock, cv) = &*flight;
let mut g = lock.lock().unwrap_or_else(|p| p.into_inner());
while g.is_none() {
g = cv.wait(g).unwrap_or_else(|p| p.into_inner());
}
g.clone().expect("answer").map(|r| ChatResponse { usage: Usage::default(), ..r })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai::ChatMessage;
use std::sync::atomic::{AtomicUsize, Ordering};
struct Flaky {
calls: AtomicUsize,
fail_first: usize,
error: &'static str,
delay: Duration,
}
impl ChatModel for Flaky {
fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(self.delay);
if n < self.fail_first {
return Err(CamelError::illegal_state(self.error));
}
Ok(ChatResponse { message: ChatMessage::assistant(format!("a:{}", req.messages[0].text())), stop_reason: "stop".into(), usage: Usage::new(5, 1) })
}
}
fn flaky(fail_first: usize, error: &'static str) -> Arc<Flaky> {
Arc::new(Flaky { calls: AtomicUsize::new(0), fail_first, error, delay: Duration::ZERO })
}
fn req(t: &str) -> ChatRequest {
ChatRequest { messages: vec![ChatMessage::user(t)], ..ChatRequest::default() }
}
fn opts(q: &str) -> Reliability {
let mut p = crate::util::Parameters::new(crate::util::parse_query(q));
Reliability::take(&mut p).unwrap()
}
#[test]
fn retries_transient_failures_only() {
let m = flaky(2, "HTTP operation failed invoking X with statusCode: 429: slow down");
let r = opts("retryDelay=1ms").wrap(m.clone(), "x");
assert_eq!(r.chat(&req("q")).unwrap().message.text(), "a:q");
assert_eq!(m.calls.load(Ordering::SeqCst), 3);
let m = flaky(1, "HTTP operation failed invoking X with statusCode: 400: bad");
assert!(opts("retryDelay=1ms").wrap(m.clone(), "x").chat(&req("q")).is_err());
assert_eq!(m.calls.load(Ordering::SeqCst), 1);
let m = flaky(1, "statusCode: 503");
assert!(opts("maxRetries=0").wrap(m.clone(), "x").chat(&req("q")).is_err());
assert!(is_transient(&CamelError::illegal_state("Read timed out")));
assert!(!is_transient(&CamelError::illegal_state("statusCode: 401")));
}
#[test]
fn caches_exact_requests_without_charging_tokens() {
let m = flaky(0, "");
let r = opts("responseCache=exact").wrap(m.clone(), "x");
assert_eq!(r.chat(&req("q")).unwrap().usage, Usage::new(5, 1));
let again = r.chat(&req("q")).unwrap();
assert_eq!((again.message.text().as_str(), again.usage), ("a:q", Usage::default()));
r.chat(&req("other")).unwrap();
assert_eq!(m.calls.load(Ordering::SeqCst), 2);
opts("responseCache=exact").wrap(m.clone(), "y").chat(&req("q")).unwrap();
assert_eq!(m.calls.load(Ordering::SeqCst), 3);
}
#[test]
fn coalesces_identical_concurrent_requests() {
let m = Arc::new(Flaky { calls: AtomicUsize::new(0), fail_first: 0, error: "", delay: Duration::from_millis(150) });
let r = opts("coalesce=true").wrap(m.clone(), "x");
let threads: Vec<_> = (0..8)
.map(|_| {
let r = r.clone();
std::thread::spawn(move || r.chat(&req("same")).unwrap())
})
.collect();
let answers: Vec<ChatResponse> = threads.into_iter().map(|t| t.join().unwrap()).collect();
assert!(answers.iter().all(|a| a.message.text() == "a:same"));
assert_eq!(m.calls.load(Ordering::SeqCst), 1, "one call for eight callers");
assert_eq!(answers.iter().filter(|a| a.usage.input > 0).count(), 1, "tokens are reported once");
}
}