mod anthropic;
mod attachments;
mod error;
mod openai;
mod responses;
mod think_parser;
use anyhow::{Context, Result};
use reqwest::Client;
use tokio::sync::mpsc::UnboundedSender;
use uuid::Uuid;
use crate::{
config::{ActiveModel, ApiType},
session::{BackendEvent, Message},
tooling::ToolDefinition,
};
use error::{MAX_RETRIES, backoff_delay, backoff_sleep, classify_anyhow_error};
#[derive(Clone, Debug)]
pub struct LlmClient {
http: Client,
}
impl LlmClient {
pub fn new() -> Result<Self> {
let http = Client::builder()
.user_agent("tidev/0.1")
.build()
.context("failed to construct HTTP client")?;
Ok(Self { http })
}
pub fn http(&self) -> &Client {
&self.http
}
pub async fn stream_chat(
&self,
session_id: Uuid,
request_id: u64,
model: ActiveModel,
messages: Vec<Message>,
tools: Vec<ToolDefinition>,
tx: UnboundedSender<BackendEvent>,
thinking_level: crate::config::reasoning::ThinkingLevelType,
) {
let result = self
.stream_chat_with_retry(
session_id,
request_id,
model,
messages,
tools,
tx.clone(),
thinking_level,
)
.await;
if let Err(error) = result {
let _ = tx.send(BackendEvent::Failed {
session_id,
request_id,
error: error.to_string(),
});
}
}
pub async fn complete_with_messages(
&self,
model: ActiveModel,
messages: Vec<Message>,
) -> Result<String> {
let result = self.complete_with_retry(model, messages).await;
result.context("LLM completion failed after retries")
}
async fn stream_chat_with_retry(
&self,
session_id: Uuid,
request_id: u64,
model: ActiveModel,
messages: Vec<Message>,
tools: Vec<ToolDefinition>,
tx: UnboundedSender<BackendEvent>,
thinking_level: crate::config::reasoning::ThinkingLevelType,
) -> Result<()> {
for attempt in 1..=MAX_RETRIES {
let result = self
.stream_chat_inner(
session_id,
request_id,
model.clone(),
messages.clone(),
tools.clone(),
tx.clone(),
thinking_level.clone(),
)
.await;
match result {
Ok(()) => return Ok(()),
Err(e) => {
let network_error = classify_anyhow_error(e);
let is_last_attempt = attempt == MAX_RETRIES;
if !network_error.is_retryable() || is_last_attempt {
return Err(anyhow::anyhow!("{}", network_error.message()));
}
let delay_secs = backoff_delay(attempt).as_secs() as u32;
let _ = tx.send(BackendEvent::Retrying {
session_id,
request_id,
attempt,
max_attempts: MAX_RETRIES,
reason: network_error.message().to_string(),
retry_after_secs: Some(delay_secs),
});
backoff_sleep(attempt).await;
}
}
}
unreachable!("loop should return before this point")
}
async fn complete_with_retry(
&self,
model: ActiveModel,
messages: Vec<Message>,
) -> Result<String> {
for attempt in 1..=MAX_RETRIES {
let result = match model.api_type {
ApiType::Anthropic => {
anthropic::complete_anthropic(&self.http, model.clone(), messages.clone()).await
}
ApiType::OpenAiChatCompletions => {
openai::complete_openai(&self.http, model.clone(), messages.clone()).await
}
ApiType::OpenAiResponses => {
responses::complete_responses(&self.http, model.clone(), messages.clone()).await
}
};
match result {
Ok(response) => return Ok(response),
Err(e) => {
let network_error = classify_anyhow_error(e);
let is_last_attempt = attempt == MAX_RETRIES;
if !network_error.is_retryable() || is_last_attempt {
return Err(anyhow::anyhow!("{}", network_error.message()));
}
let delay_secs = backoff_delay(attempt).as_secs() as u32;
eprintln!(
"Completion failed (attempt {}/{}): {}, retrying in {}s...",
attempt,
MAX_RETRIES,
network_error.message(),
delay_secs
);
backoff_sleep(attempt).await;
}
}
}
unreachable!("loop should return before this point")
}
async fn stream_chat_inner(
&self,
session_id: Uuid,
request_id: u64,
model: ActiveModel,
messages: Vec<Message>,
tools: Vec<ToolDefinition>,
tx: UnboundedSender<BackendEvent>,
thinking_level: crate::config::reasoning::ThinkingLevelType,
) -> Result<()> {
match model.api_type {
ApiType::Anthropic => {
anthropic::stream_anthropic(
&self.http, session_id, request_id, model, messages, tools, tx,
)
.await
}
ApiType::OpenAiChatCompletions => {
openai::stream_openai(
&self.http,
session_id,
request_id,
model,
messages,
tools,
tx,
thinking_level,
)
.await
}
ApiType::OpenAiResponses => {
responses::stream_responses(
&self.http, session_id, request_id, model, messages, tools, tx,
)
.await
}
}
}
}