mod stream;
mod wire;
use color_eyre::Result;
use reqwest::Client;
use tokio::sync::mpsc;
use self::stream::StreamState;
use self::wire::{MessagesRequest, MessagesResponse};
use crate::agent::{ContentPart, Message, StreamOutcome, ToolDefinition};
use crate::sse::EventReader;
pub struct ClaudeClient {
client: Client,
api_key: String,
base_url: String,
api_version: String,
model: String,
max_tokens: u32,
}
impl ClaudeClient {
pub fn new(api_key: String) -> Self {
Self {
client: crate::llm::http_client(),
api_key,
base_url: "https://api.anthropic.com".to_string(),
api_version: "2023-06-01".to_string(),
model: "claude-sonnet-5".to_string(),
max_tokens: 4096,
}
}
pub fn with_model(mut self, model: String) -> Self {
self.model = model;
self
}
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = max_tokens;
self
}
#[allow(dead_code)]
pub fn with_base_url(mut self, base_url: String) -> Self {
self.base_url = base_url.trim_end_matches('/').to_string();
self
}
#[allow(dead_code)]
pub fn with_api_version(mut self, api_version: String) -> Self {
self.api_version = api_version;
self
}
fn endpoint(&self) -> String {
format!("{}/v1/messages", self.base_url)
}
async fn dispatch(&self, request: &MessagesRequest<'_>) -> Result<reqwest::Response> {
crate::llm::send_with_retry(|| {
self.client
.post(self.endpoint())
.header("x-api-key", &self.api_key)
.header("anthropic-version", &self.api_version)
.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 = MessagesRequest::build(
self.model.clone(),
self.max_tokens,
history,
tools,
system,
false,
);
let response = self.dispatch(&request).await?;
let body: MessagesResponse = response.json().await?;
Ok(body.content)
}
pub async fn send_message_streaming(
&self,
history: &[Message],
tools: Option<&[ToolDefinition]>,
system: Option<&str>,
update_tx: &mpsc::UnboundedSender<String>,
) -> Result<StreamOutcome> {
let request = MessagesRequest::build(
self.model.clone(),
self.max_tokens,
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 = ClaudeClient::new("k".to_string())
.with_base_url("https://gateway.internal/".to_string());
assert_eq!(client.endpoint(), "https://gateway.internal/v1/messages");
}
}