use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use llm_trait::{
CallMode, Capabilities, ChatRequest, ChatResponse, ChatStream, HttpClient, LlmError,
LlmProvider, ProviderInfo, RawAdapter, RawRequest, ReqwestHttpClient,
};
use crate::model_registry::ModelProfile;
#[derive(Debug, Clone)]
pub struct ProviderConfig {
pub connect_timeout: Duration,
pub request_timeout: Duration,
pub max_retries: u32,
pub retry_delay: Duration,
pub client: Option<reqwest::Client>,
}
impl Default for ProviderConfig {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(15),
request_timeout: Duration::from_secs(120),
max_retries: 3,
retry_delay: Duration::from_secs(1),
client: None,
}
}
}
pub struct GenericProvider {
adapter: Box<dyn RawAdapter>,
client: Arc<dyn HttpClient>,
config: ProviderConfig,
}
impl GenericProvider {
pub fn new(adapter: Box<dyn RawAdapter>) -> Self {
Self::with_config(adapter, ProviderConfig::default())
}
pub fn with_config(adapter: Box<dyn RawAdapter>, config: ProviderConfig) -> Self {
let reqwest_client = config.client.clone().unwrap_or_else(|| {
reqwest::Client::builder()
.connect_timeout(config.connect_timeout)
.read_timeout(config.request_timeout)
.build()
.expect("Failed to build HTTP client")
});
Self {
adapter,
client: Arc::new(ReqwestHttpClient::new(reqwest_client)),
config,
}
}
pub fn with_http_client(
adapter: Box<dyn RawAdapter>,
client: Arc<dyn HttpClient>,
config: ProviderConfig,
) -> Self {
Self {
adapter,
client,
config,
}
}
pub fn adapter(&self) -> &dyn RawAdapter {
self.adapter.as_ref()
}
async fn execute_once(&self, request: RawRequest) -> Result<ChatResponse, LlmError> {
let response = self.send_request(&request).await?;
let body = response.text().await;
self.adapter.parse_response(body.as_bytes())
}
async fn execute_stream(&self, request: RawRequest) -> Result<ChatStream, LlmError> {
let mut last_err = None;
for attempt in 0..=self.config.max_retries {
if attempt > 0 {
let delay = self.calculate_backoff(attempt);
tokio::time::sleep(delay).await;
}
match self.client.send(&request).await {
Ok(response) => {
if response.is_success() {
return self
.adapter
.parse_sse_stream(self.client.as_ref(), request, response)
.await;
}
let status = response.status();
let body = response.text().await;
let is_retryable = status == 429 || status >= 500;
if !is_retryable || attempt == self.config.max_retries {
tracing::error!(
status = status,
url = %request.url,
error_body = %body,
"Stream HTTP error with full request context"
);
return Err(LlmError::api(status, body));
}
tracing::warn!(attempt, status, "Stream request failed, retrying");
last_err = Some(LlmError::api(status, body));
}
Err(e) => {
if attempt == self.config.max_retries {
return Err(e);
}
tracing::warn!(attempt, error = %e, "Stream request failed, retrying");
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| LlmError::llm("Stream request failed after retries")))
}
async fn send_request(
&self,
request: &RawRequest,
) -> Result<llm_trait::HttpResponse, LlmError> {
let mut last_err = None;
for attempt in 0..=self.config.max_retries {
if attempt > 0 {
let delay = self.calculate_backoff(attempt);
tokio::time::sleep(delay).await;
}
match self.client.send(request).await {
Ok(response) => {
if response.is_success() {
return Ok(response);
}
let status = response.status();
let body = response.text().await;
let is_retryable = status == 429 || status >= 500;
if !is_retryable || attempt == self.config.max_retries {
tracing::error!(
status = status,
url = %request.url,
error_body = %body,
request_body = %serde_json::to_string(&request.body).unwrap_or_default(),
"HTTP error with full request context"
);
return Err(LlmError::api(status, body));
}
tracing::warn!(attempt, status, "Request failed, retrying");
last_err = Some(LlmError::api(status, body));
}
Err(e) => {
if attempt == self.config.max_retries {
return Err(e);
}
tracing::warn!(attempt, error = %e, "Request failed, retrying");
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| LlmError::llm("Request failed after retries")))
}
fn calculate_backoff(&self, attempt: u32) -> Duration {
let base = self.config.retry_delay.as_millis() as u64;
let exponential = base * 2u64.pow(attempt.saturating_sub(1));
let jitter = rand::random::<u64>() % 100;
Duration::from_millis((exponential + jitter).min(30_000))
}
}
#[async_trait]
impl LlmProvider for GenericProvider {
async fn stream(&self, request: ChatRequest) -> Result<ChatStream, LlmError> {
let modes = self.adapter.supported_modes();
if !modes.contains(&CallMode::Stream) {
return Err(LlmError::llm("Streaming not supported by this adapter"));
}
let raw_request = self.adapter.build_request(&request, CallMode::Stream)?;
self.execute_stream(raw_request).await
}
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, LlmError> {
let modes = self.adapter.supported_modes();
if modes.contains(&CallMode::Once) {
let raw_request = self.adapter.build_request(&request, CallMode::Once)?;
return self.execute_once(raw_request).await;
}
let stream = self.stream(request).await?;
stream.collect_response().await
}
fn capabilities(&self) -> Capabilities {
self.adapter.capabilities()
}
fn info(&self) -> ProviderInfo {
self.adapter.info()
}
}
pub struct ProfiledProvider {
inner: GenericProvider,
profile: ModelProfile,
}
impl ProfiledProvider {
pub fn new(inner: GenericProvider, profile: ModelProfile) -> Self {
Self { inner, profile }
}
}
#[async_trait]
impl LlmProvider for ProfiledProvider {
async fn stream(&self, request: ChatRequest) -> Result<ChatStream, LlmError> {
self.inner.stream(request).await
}
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, LlmError> {
self.inner.chat(request).await
}
fn capabilities(&self) -> Capabilities {
self.profile.capabilities.clone()
}
fn info(&self) -> ProviderInfo {
ProviderInfo {
name: self.profile.provider_name.to_string(),
model: self.inner.adapter().info().model.clone(),
version: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use llm_trait::{ChatMessage, FinishReason, HttpMethod, StreamChunk, UsageInfo};
use std::sync::Arc;
struct MockHttpClient {
responses: std::sync::Mutex<Vec<Result<llm_trait::HttpResponse, LlmError>>>,
calls: std::sync::atomic::AtomicUsize,
}
impl MockHttpClient {
fn new(responses: Vec<llm_trait::HttpResponse>) -> Self {
Self {
responses: std::sync::Mutex::new(responses.into_iter().map(Ok).collect()),
calls: std::sync::atomic::AtomicUsize::new(0),
}
}
fn calls(&self) -> usize {
self.calls.load(std::sync::atomic::Ordering::SeqCst)
}
}
#[async_trait]
impl HttpClient for MockHttpClient {
async fn send(&self, _request: &RawRequest) -> Result<llm_trait::HttpResponse, LlmError> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let mut queue = self.responses.lock().unwrap();
if queue.is_empty() {
return Err(LlmError::llm("MockHttpClient: no responses left"));
}
queue.remove(0)
}
}
struct MockAdapter;
#[async_trait]
impl RawAdapter for MockAdapter {
fn build_request(
&self,
_request: &ChatRequest,
mode: CallMode,
) -> Result<RawRequest, LlmError> {
Ok(RawRequest {
url: "https://api.example.com/v1/messages".to_string(),
method: HttpMethod::Post,
headers: Default::default(),
body: serde_json::json!({"model": "test"}),
stream: mode == CallMode::Stream,
})
}
async fn execute_stream(
&self,
_client: &dyn HttpClient,
_request: RawRequest,
) -> Result<ChatStream, LlmError> {
let chunks = vec![
Ok(StreamChunk::Text("hello".into())),
Ok(StreamChunk::Stop {
finish_reason: Some("stop".into()),
}),
];
Ok(ChatStream::new(Box::pin(futures_util::stream::iter(
chunks,
))))
}
async fn parse_sse_stream(
&self,
_client: &dyn HttpClient,
_request: RawRequest,
_response: llm_trait::HttpResponse,
) -> Result<ChatStream, LlmError> {
let chunks = vec![
Ok(StreamChunk::Text("hello".into())),
Ok(StreamChunk::Stop {
finish_reason: Some("stop".into()),
}),
];
Ok(ChatStream::new(Box::pin(futures_util::stream::iter(
chunks,
))))
}
fn parse_response(&self, _body: &[u8]) -> Result<ChatResponse, LlmError> {
Ok(ChatResponse {
content: "mock response".to_string(),
reasoning_content: None,
thinking_signature: None,
tool_calls: vec![],
usage: UsageInfo::default(),
finish_reason: FinishReason::Stop,
raw: None,
})
}
fn capabilities(&self) -> Capabilities {
Capabilities {
supports_streaming: true,
supports_tools: true,
..Default::default()
}
}
fn info(&self) -> ProviderInfo {
ProviderInfo {
name: "mock".to_string(),
model: "mock-model".to_string(),
version: None,
}
}
fn supported_modes(&self) -> &[CallMode] {
&[CallMode::Stream, CallMode::Once]
}
}
#[test]
fn generic_provider_info() {
let provider = GenericProvider::new(Box::new(MockAdapter));
let info = provider.info();
assert_eq!(info.name, "mock");
assert_eq!(info.model, "mock-model");
}
#[test]
fn generic_provider_capabilities() {
let provider = GenericProvider::new(Box::new(MockAdapter));
let caps = provider.capabilities();
assert!(caps.supports_streaming);
assert!(caps.supports_tools);
}
#[tokio::test]
async fn chat_uses_injected_http_client() {
let client = Arc::new(MockHttpClient::new(vec![
llm_trait::HttpResponse::from_text(200, "{}".to_string()),
]));
let provider = GenericProvider::with_http_client(
Box::new(MockAdapter),
client.clone(),
ProviderConfig::default(),
);
let response = provider
.chat(ChatRequest::new(vec![ChatMessage::user("hi")]))
.await
.unwrap();
assert_eq!(response.content, "mock response");
assert_eq!(client.calls(), 1);
}
#[tokio::test]
async fn retries_5xx_then_succeeds() {
let client = Arc::new(MockHttpClient::new(vec![
llm_trait::HttpResponse::from_text(503, "overloaded".to_string()),
llm_trait::HttpResponse::from_text(200, "{}".to_string()),
]));
let config = ProviderConfig {
retry_delay: Duration::ZERO,
max_retries: 2,
..Default::default()
};
let provider =
GenericProvider::with_http_client(Box::new(MockAdapter), client.clone(), config);
let response = provider
.chat(ChatRequest::new(vec![ChatMessage::user("hi")]))
.await
.unwrap();
assert_eq!(response.content, "mock response");
assert_eq!(client.calls(), 2, "503 should be retried once");
}
#[tokio::test]
async fn does_not_retry_4xx() {
let client = Arc::new(MockHttpClient::new(vec![
llm_trait::HttpResponse::from_text(401, "bad key".to_string()),
]));
let config = ProviderConfig {
retry_delay: Duration::ZERO,
max_retries: 3,
..Default::default()
};
let provider =
GenericProvider::with_http_client(Box::new(MockAdapter), client.clone(), config);
let err = provider
.chat(ChatRequest::new(vec![ChatMessage::user("hi")]))
.await
.unwrap_err();
assert_eq!(err.status(), Some(401));
assert_eq!(client.calls(), 1, "401 must not be retried");
}
#[test]
fn profiled_provider_info() {
let profile = ModelProfile {
protocol: llm_trait::Protocol::OpenAi,
provider_name: "deepseek",
capabilities: Capabilities::default(),
reasoning_mode: llm_trait::ReasoningMode::Effort,
supported_extra_params: &[],
};
let provider = ProfiledProvider::new(GenericProvider::new(Box::new(MockAdapter)), profile);
let info = provider.info();
assert_eq!(info.name, "deepseek");
assert_eq!(info.model, "mock-model");
}
#[test]
fn provider_config_default() {
let config = ProviderConfig::default();
assert_eq!(config.connect_timeout, Duration::from_secs(15));
assert_eq!(config.request_timeout, Duration::from_secs(120));
assert_eq!(config.max_retries, 3);
}
#[test]
fn profiled_provider_capabilities() {
let caps = Capabilities {
supports_streaming: true,
supports_tools: false,
supports_vision: true,
..Default::default()
};
let profile = ModelProfile {
protocol: llm_trait::Protocol::OpenAi,
provider_name: "test",
capabilities: caps.clone(),
reasoning_mode: llm_trait::ReasoningMode::Effort,
supported_extra_params: &[],
};
let provider = ProfiledProvider::new(GenericProvider::new(Box::new(MockAdapter)), profile);
let got = provider.capabilities();
assert!(got.supports_streaming);
assert!(!got.supports_tools);
assert!(got.supports_vision);
}
#[test]
fn calculate_backoff_respects_max() {
let provider = GenericProvider::new(Box::new(MockAdapter));
let delay = provider.calculate_backoff(20);
assert!(delay <= Duration::from_millis(30_100)); }
#[test]
fn calculate_backoff_increases_with_attempt() {
let provider = GenericProvider::new(Box::new(MockAdapter));
let mut delays: Vec<u64> = (1..=5)
.map(|a| provider.calculate_backoff(a).as_millis() as u64)
.collect();
delays.sort();
let d1 = provider.calculate_backoff(1).as_millis() as u64;
let d5 = provider.calculate_backoff(5).as_millis() as u64;
assert!(d5 > d1, "d5={} should be > d1={}", d5, d1);
}
#[tokio::test]
async fn profiled_provider_delegates_stream() {
let profile = ModelProfile {
protocol: llm_trait::Protocol::OpenAi,
provider_name: "test",
capabilities: Capabilities::default(),
reasoning_mode: llm_trait::ReasoningMode::Effort,
supported_extra_params: &[],
};
let provider = ProfiledProvider::new(GenericProvider::new(Box::new(MockAdapter)), profile);
let req = ChatRequest::new(vec![ChatMessage::user("hi")]);
let result = provider.stream(req).await;
assert!(result.is_ok() || result.is_err());
}
}