mod stream;
mod wire;
use color_eyre::{eyre::bail, Result};
use reqwest::Client;
use tokio::sync::mpsc;
use self::stream::StreamState;
use self::wire::{blocks_from_message, ChatRequest, ChatResponse};
use crate::agent::{ContentPart, Message, StreamOutcome, ToolDefinition};
use crate::sse::EventReader;
pub struct OpenAiClient {
client: Client,
api_key: String,
base_url: String,
model: String,
max_tokens: u32,
}
impl OpenAiClient {
pub fn new(api_key: String, base_url: String, model: String, max_tokens: u32) -> Self {
Self {
client: crate::llm::http_client(),
api_key,
base_url: base_url.trim_end_matches('/').to_string(),
model,
max_tokens,
}
}
fn endpoint(&self) -> String {
format!("{}/chat/completions", self.base_url)
}
fn build_request(
&self,
history: &[Message],
tools: Option<&[ToolDefinition]>,
system: Option<&str>,
stream: bool,
) -> ChatRequest {
ChatRequest::build(
self.model.clone(),
self.max_tokens,
history,
tools,
system,
stream,
)
}
async fn dispatch(&self, request: &ChatRequest) -> Result<reqwest::Response> {
crate::llm::send_with_retry(|| {
self.client
.post(self.endpoint())
.bearer_auth(&self.api_key)
.header("content-type", "application/json")
.json(request)
})
.await
}
pub async fn send_message(
&self,
history: &[Message],
tools: Option<&[ToolDefinition]>,
system: Option<&str>,
) -> Result<Vec<ContentPart>> {
let request = self.build_request(history, tools, system, false);
let response = self.dispatch(&request).await?;
let body: ChatResponse = response.json().await?;
let choice = match body.choices.into_iter().next() {
Some(choice) => choice,
None => bail!("API returned no choices"),
};
if choice.message.content.is_none()
&& choice
.message
.tool_calls
.as_ref()
.is_none_or(|t| t.is_empty())
{
bail!("API returned a choice with no content and no tool calls");
}
Ok(blocks_from_message(choice.message))
}
pub async fn send_message_streaming(
&self,
history: &[Message],
tools: Option<&[ToolDefinition]>,
system: Option<&str>,
update_tx: &mpsc::UnboundedSender<String>,
) -> Result<StreamOutcome> {
let request = self.build_request(history, tools, system, true);
let response = self.dispatch(&request).await?;
let mut reader = EventReader::new(StreamState::new(update_tx));
reader.read(response).await?;
Ok(reader.into_sink().into_outcome())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trailing_slash_in_base_url_does_not_double() {
let client = OpenAiClient::new(
"k".to_string(),
"https://api.deepseek.com/v1/".to_string(),
"deepseek-chat".to_string(),
4096,
);
assert_eq!(
client.endpoint(),
"https://api.deepseek.com/v1/chat/completions"
);
}
}