use std::sync::LazyLock;
use std::time::Duration;
use color_eyre::{eyre::bail, Result};
use reqwest::{Client, RequestBuilder, StatusCode};
use tokio::sync::mpsc;
use crate::agent::{ContentPart, Message, StreamOutcome, ToolDefinition};
use crate::anthropic::ClaudeClient;
use crate::config::AppConfig;
use crate::openai::OpenAiClient;
static HTTP: LazyLock<Client> = LazyLock::new(Client::new);
pub fn http_client() -> Client {
HTTP.clone()
}
const MAX_ATTEMPTS: u32 = 3;
const MAX_RETRY_AFTER: Duration = Duration::from_secs(20);
fn is_transient(status: StatusCode) -> bool {
status == StatusCode::TOO_MANY_REQUESTS
|| status == StatusCode::REQUEST_TIMEOUT
|| status.is_server_error()
}
fn is_transient_transport(error: &reqwest::Error) -> bool {
error.is_timeout() || error.is_connect() || error.is_request()
}
fn backoff(attempt: u32) -> Duration {
Duration::from_millis(500 * 2u64.pow(attempt.saturating_sub(1)))
}
pub async fn send_with_retry<F>(build: F) -> Result<reqwest::Response>
where
F: Fn() -> RequestBuilder,
{
let mut attempt = 0;
loop {
attempt += 1;
let last = attempt >= MAX_ATTEMPTS;
match build().send().await {
Ok(response) if response.status().is_success() => return Ok(response),
Ok(response) => {
let status = response.status();
if !is_transient(status) || last {
let body = response.text().await.unwrap_or_default();
bail!("API error {}: {}", status, body);
}
let wait = retry_after(&response).unwrap_or_else(|| backoff(attempt));
crate::diag::warn(format!(
"provider returned {}, retrying in {:?} (attempt {} of {})",
status, wait, attempt, MAX_ATTEMPTS
));
tokio::time::sleep(wait).await;
}
Err(e) => {
if !is_transient_transport(&e) || last {
return Err(e.into());
}
let wait = backoff(attempt);
crate::diag::warn(format!(
"request failed ({}), retrying in {:?} (attempt {} of {})",
e, wait, attempt, MAX_ATTEMPTS
));
tokio::time::sleep(wait).await;
}
}
}
}
fn retry_after(response: &reqwest::Response) -> Option<Duration> {
let seconds: u64 = response
.headers()
.get(reqwest::header::RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse()
.ok()?;
Some(Duration::from_secs(seconds).min(MAX_RETRY_AFTER))
}
pub enum LlmClient {
Anthropic(ClaudeClient),
OpenAiCompatible(OpenAiClient),
}
impl LlmClient {
pub fn from_config(config: &AppConfig) -> Result<Self> {
let api_key = config.get_api_key()?;
Ok(if config.provider.is_anthropic() {
Self::Anthropic(
ClaudeClient::new(api_key)
.with_model(config.default_model.clone())
.with_max_tokens(config.max_tokens),
)
} else {
Self::OpenAiCompatible(OpenAiClient::new(
api_key,
config.resolve_base_url()?,
config.default_model.clone(),
config.max_tokens,
))
})
}
pub async fn send_message(
&self,
history: &[Message],
tools: Option<&[ToolDefinition]>,
system: Option<&str>,
) -> Result<Vec<ContentPart>> {
match self {
Self::Anthropic(client) => client.send_message(history, tools, system).await,
Self::OpenAiCompatible(client) => client.send_message(history, tools, system).await,
}
}
pub async fn send_message_streaming(
&self,
history: &[Message],
tools: Option<&[ToolDefinition]>,
system: Option<&str>,
update_tx: &mpsc::UnboundedSender<String>,
) -> Result<StreamOutcome> {
match self {
Self::Anthropic(client) => {
client
.send_message_streaming(history, tools, system, update_tx)
.await
}
Self::OpenAiCompatible(client) => {
client
.send_message_streaming(history, tools, system, update_tx)
.await
}
}
}
}