pub mod backoff;
pub mod mock;
pub mod ollama;
#[cfg(feature = "openai")]
pub mod openai;
pub mod recording;
#[cfg(feature = "openai")]
pub mod sse;
pub use backoff::BackoffConfig;
pub use mock::MockBackend;
pub use ollama::OllamaBackend;
#[cfg(feature = "openai")]
pub use openai::OpenAiBackend;
pub use recording::RecordingBackend;
use crate::client::LlmConfig;
use crate::error::Result;
use crate::PipelineError;
use async_trait::async_trait;
use reqwest::Client;
use std::sync::Arc;
use std::time::Duration;
pub type RetryCallback<'a> = Option<&'a mut (dyn FnMut(u32, std::time::Duration, &str) + Send)>;
#[derive(Debug, Clone)]
pub struct LlmRequest {
pub model: String,
pub system_prompt: Option<String>,
pub prompt: String,
pub messages: Vec<ChatMessage>,
pub config: LlmConfig,
pub stream: bool,
pub request_timeout: Option<Duration>,
}
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub role: Role,
pub content: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
System,
User,
Assistant,
}
#[derive(Debug)]
pub struct LlmResponse {
pub text: String,
pub status: u16,
pub metadata: Option<serde_json::Value>,
}
#[async_trait]
pub trait Backend: Send + Sync {
async fn complete(
&self,
client: &Client,
base_url: &str,
request: &LlmRequest,
) -> Result<LlmResponse>;
async fn complete_streaming(
&self,
client: &Client,
base_url: &str,
request: &LlmRequest,
on_token: &mut (dyn FnMut(String) + Send),
) -> Result<LlmResponse>;
fn name(&self) -> &'static str;
}
pub fn is_retryable(error: &PipelineError, config: &BackoffConfig) -> bool {
match error {
PipelineError::HttpError { status, .. } => config.retryable_statuses.contains(status),
PipelineError::Request(_) => true,
_ => false,
}
}
pub async fn with_backoff(
backend: &Arc<dyn Backend>,
client: &Client,
base_url: &str,
request: &LlmRequest,
config: &BackoffConfig,
cancel: Option<&std::sync::atomic::AtomicBool>,
mut on_retry: RetryCallback<'_>,
) -> Result<LlmResponse> {
let mut last_error: Option<PipelineError> = None;
for attempt in 0..=config.max_retries {
if let Some(flag) = cancel {
if flag.load(std::sync::atomic::Ordering::Relaxed) {
return Err(PipelineError::Cancelled);
}
}
if attempt > 0 {
let delay = if let Some(PipelineError::HttpError {
retry_after: Some(ra),
..
}) = &last_error
{
if config.respect_retry_after {
*ra
} else {
config.delay_for_attempt(attempt - 1)
}
} else {
config.delay_for_attempt(attempt - 1)
};
let reason = last_error
.as_ref()
.map(|e| e.to_string())
.unwrap_or_default();
if let Some(ref mut cb) = on_retry {
cb(attempt, delay, &reason);
}
tokio::time::sleep(delay).await;
if let Some(flag) = cancel {
if flag.load(std::sync::atomic::Ordering::Relaxed) {
return Err(PipelineError::Cancelled);
}
}
}
match backend.complete(client, base_url, request).await {
Ok(response) => return Ok(response),
Err(e) => {
if attempt < config.max_retries && is_retryable(&e, config) {
last_error = Some(e);
continue;
}
return Err(e);
}
}
}
Err(last_error.unwrap_or(PipelineError::Other(
"backoff loop exited unexpectedly".into(),
)))
}
pub struct BackoffStreamOpts<'a> {
pub cancel: Option<&'a std::sync::atomic::AtomicBool>,
pub on_retry: RetryCallback<'a>,
pub on_token: &'a mut (dyn FnMut(String) + Send),
}
pub async fn with_backoff_streaming(
backend: &Arc<dyn Backend>,
client: &Client,
base_url: &str,
request: &LlmRequest,
config: &BackoffConfig,
opts: BackoffStreamOpts<'_>,
) -> Result<LlmResponse> {
let BackoffStreamOpts {
cancel,
mut on_retry,
on_token,
} = opts;
let mut last_error: Option<PipelineError> = None;
for attempt in 0..=config.max_retries {
if let Some(flag) = cancel {
if flag.load(std::sync::atomic::Ordering::Relaxed) {
return Err(PipelineError::Cancelled);
}
}
if attempt > 0 {
let delay = if let Some(PipelineError::HttpError {
retry_after: Some(ra),
..
}) = &last_error
{
if config.respect_retry_after {
*ra
} else {
config.delay_for_attempt(attempt - 1)
}
} else {
config.delay_for_attempt(attempt - 1)
};
let reason = last_error
.as_ref()
.map(|e| e.to_string())
.unwrap_or_default();
if let Some(ref mut cb) = on_retry {
cb(attempt, delay, &reason);
}
tokio::time::sleep(delay).await;
if let Some(flag) = cancel {
if flag.load(std::sync::atomic::Ordering::Relaxed) {
return Err(PipelineError::Cancelled);
}
}
}
match backend
.complete_streaming(client, base_url, request, on_token)
.await
{
Ok(response) => return Ok(response),
Err(e) => {
if attempt < config.max_retries && is_retryable(&e, config) {
last_error = Some(e);
continue;
}
return Err(e);
}
}
}
Err(last_error.unwrap_or(PipelineError::Other(
"backoff loop exited unexpectedly".into(),
)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_is_retryable_429() {
let config = BackoffConfig::standard();
let err = PipelineError::HttpError {
status: 429,
body: "rate limited".into(),
retry_after: None,
};
assert!(is_retryable(&err, &config));
}
#[test]
fn test_is_retryable_503() {
let config = BackoffConfig::standard();
let err = PipelineError::HttpError {
status: 503,
body: "service unavailable".into(),
retry_after: None,
};
assert!(is_retryable(&err, &config));
}
#[test]
fn test_is_retryable_400_not_retried() {
let config = BackoffConfig::standard();
let err = PipelineError::HttpError {
status: 400,
body: "bad request".into(),
retry_after: None,
};
assert!(!is_retryable(&err, &config));
}
#[test]
fn test_is_retryable_other_error_not_retried() {
let config = BackoffConfig::standard();
let err = PipelineError::Other("some error".into());
assert!(!is_retryable(&err, &config));
}
#[test]
fn test_is_retryable_cancelled_not_retried() {
let config = BackoffConfig::standard();
let err = PipelineError::Cancelled;
assert!(!is_retryable(&err, &config));
}
#[test]
fn test_backoff_none_no_retry() {
let config = BackoffConfig::none();
assert_eq!(config.max_retries, 0);
}
#[tokio::test]
async fn test_backoff_respects_cancellation() {
use std::sync::atomic::AtomicBool;
let cancel = AtomicBool::new(true);
let backend: Arc<dyn Backend> = Arc::new(OllamaBackend);
let client = Client::new();
let request = LlmRequest {
model: "test".into(),
system_prompt: None,
prompt: "test".into(),
messages: Vec::new(),
config: LlmConfig::default(),
stream: false,
request_timeout: None,
};
let result = with_backoff(
&backend,
&client,
"http://localhost:99999",
&request,
&BackoffConfig::standard(),
Some(&cancel),
None,
)
.await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), PipelineError::Cancelled));
}
#[test]
fn test_backoff_respects_retry_after_parsing() {
let err = PipelineError::HttpError {
status: 429,
body: "rate limited".into(),
retry_after: Some(Duration::from_secs(30)),
};
if let PipelineError::HttpError { retry_after, .. } = err {
assert_eq!(retry_after, Some(Duration::from_secs(30)));
}
}
}