use crate::config::ChippConfig;
use crate::error::ChippClientError;
use crate::stream::ChippStream;
use crate::types::{
ChatCompletionRequest, ChatCompletionResponse, ChatResponse, ChippMessage, ChippSession,
};
use backoff::backoff::Backoff;
use backoff::ExponentialBackoffBuilder;
use futures::StreamExt;
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
pub struct ChippClient {
http: reqwest::Client,
config: ChippConfig,
}
impl ChippClient {
pub fn new(config: ChippConfig) -> Result<Self, ChippClientError> {
let http = reqwest::Client::builder().timeout(config.timeout).build()?;
Ok(Self { http, config })
}
fn is_retryable_error(error: &ChippClientError) -> bool {
match error {
ChippClientError::HttpError(e) => e.is_timeout() || e.is_connect() || e.is_request(),
ChippClientError::ApiError { status, .. } => *status >= 500 || *status == 429,
_ => false,
}
}
fn create_backoff(&self) -> backoff::ExponentialBackoff {
ExponentialBackoffBuilder::new()
.with_initial_interval(self.config.initial_retry_delay)
.with_max_interval(self.config.max_retry_delay)
.with_max_elapsed_time(None)
.with_multiplier(2.0)
.with_randomization_factor(0.3)
.build()
}
#[tracing::instrument(skip(self, session, messages), fields(correlation_id))]
pub async fn chat(
&self,
session: &mut ChippSession,
messages: &[ChippMessage],
) -> Result<String, ChippClientError> {
let response = self.chat_detailed(session, messages).await?;
Ok(response.content().to_string())
}
#[tracing::instrument(skip(self, session, messages), fields(correlation_id))]
pub async fn chat_detailed(
&self,
session: &mut ChippSession,
messages: &[ChippMessage],
) -> Result<ChatResponse, ChippClientError> {
let correlation_id = Uuid::new_v4().to_string();
tracing::Span::current().record("correlation_id", &correlation_id);
let mut backoff = self.create_backoff();
let mut attempt = 0;
let max_attempts = self.config.max_retries + 1;
loop {
attempt += 1;
let result = self.chat_attempt(session, messages, &correlation_id).await;
match result {
Ok(response) => return Ok(response),
Err(e) if attempt >= max_attempts => {
tracing::warn!(attempt, error = %e, "Max retry attempts exceeded");
return Err(ChippClientError::MaxRetriesExceeded(
self.config.max_retries,
));
}
Err(e) if Self::is_retryable_error(&e) => {
if let Some(delay) = backoff.next_backoff() {
tracing::warn!(attempt, error = %e, delay_ms = delay.as_millis(), "Retrying");
tokio::time::sleep(delay).await;
} else {
return Err(e);
}
}
Err(e) => {
tracing::error!(error = %e, "Non-retryable error");
return Err(e);
}
}
}
}
async fn chat_attempt(
&self,
session: &mut ChippSession,
messages: &[ChippMessage],
correlation_id: &str,
) -> Result<ChatResponse, ChippClientError> {
let request_body = ChatCompletionRequest {
model: self.config.model.clone(),
messages: messages.to_vec(),
stream: false,
chat_session_id: session.chat_session_id.clone(),
};
let url = format!("{}/chat/completions", self.config.base_url);
let response = self
.http
.post(&url)
.header("Authorization", format!("Bearer {}", self.config.api_key))
.header("Content-Type", "application/json")
.header("X-Correlation-ID", correlation_id)
.json(&request_body)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(ChippClientError::ApiError {
status: status.as_u16(),
message: error_text,
});
}
let response_body: ChatCompletionResponse = response.json().await.map_err(|e| {
ChippClientError::InvalidResponse(format!("Failed to parse response: {}", e))
})?;
if response_body.choices.is_empty() {
return Err(ChippClientError::InvalidResponse(
"No choices in response".to_string(),
));
}
session.chat_session_id = Some(response_body.chat_session_id.clone());
Ok(response_body.into())
}
pub async fn chat_stream(
&self,
session: &mut ChippSession,
messages: &[ChippMessage],
) -> Result<ChippStream, ChippClientError> {
let correlation_id = Uuid::new_v4().to_string();
let request_body = ChatCompletionRequest {
model: self.config.model.clone(),
messages: messages.to_vec(),
stream: true,
chat_session_id: session.chat_session_id.clone(),
};
let url = format!("{}/chat/completions", self.config.base_url);
tracing::debug!("Sending Chipp API streaming request");
let response = self
.http
.post(&url)
.header("Authorization", format!("Bearer {}", self.config.api_key))
.header("Content-Type", "application/json")
.header("X-Correlation-ID", &correlation_id)
.header("Accept", "text/event-stream")
.json(&request_body)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(ChippClientError::ApiError {
status: status.as_u16(),
message: error_text,
});
}
let session_id = Arc::new(Mutex::new(None::<String>));
let byte_stream = response.bytes_stream();
let stream = ChippStream::new(Box::pin(byte_stream), session_id);
Ok(stream)
}
pub async fn chat_stream_collect(
&self,
session: &mut ChippSession,
messages: &[ChippMessage],
) -> Result<String, ChippClientError> {
let mut stream = self.chat_stream(session, messages).await?;
let mut full_response = String::new();
while let Some(chunk) = stream.next().await {
full_response.push_str(&chunk?);
}
if let Some(id) = stream.session_id().await {
session.chat_session_id = Some(id);
}
Ok(full_response)
}
pub async fn ping(&self) -> Result<std::time::Duration, ChippClientError> {
let url = format!("{}/chat/completions", self.config.base_url);
let start = std::time::Instant::now();
let _response = self.http.head(&url).send().await?;
let latency = start.elapsed();
Ok(latency)
}
}