use crate::auth::AuthConfig;
use crate::environment::LettaEnvironment;
use crate::error::{LettaError, LettaResult};
use crate::retry::{retry_with_config, RetryConfig};
use reqwest::header::HeaderMap;
use std::time::Duration;
use url::Url;
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub base_url: Url,
pub auth: AuthConfig,
pub timeout: Duration,
pub headers: HeaderMap,
}
impl ClientConfig {
pub fn new(base_url: impl AsRef<str>) -> LettaResult<Self> {
let base_url = Url::parse(base_url.as_ref())?;
Ok(Self {
base_url,
auth: AuthConfig::default(),
timeout: Duration::from_secs(30),
headers: HeaderMap::new(),
})
}
pub fn auth(mut self, auth: AuthConfig) -> Self {
self.auth = auth;
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn headers(mut self, headers: HeaderMap) -> Self {
self.headers = headers;
self
}
pub fn header(mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> LettaResult<Self> {
let key = key.as_ref();
let value = value.as_ref();
let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())
.map_err(|_| LettaError::validation(format!("Invalid header name: {}", key)))?;
let header_value = reqwest::header::HeaderValue::from_str(value).map_err(|_| {
LettaError::validation(format!("Invalid header value for {}: {}", key, value))
})?;
self.headers.insert(header_name, header_value);
Ok(self)
}
pub fn project(self, project_id: impl AsRef<str>) -> LettaResult<Self> {
self.header("X-Project", project_id)
}
pub fn user_id(self, user_id: impl AsRef<str>) -> LettaResult<Self> {
self.header("user-id", user_id)
}
}
#[derive(Debug, Clone)]
pub struct LettaClient {
http: reqwest::Client,
config: ClientConfig,
retry_config: RetryConfig,
}
impl LettaClient {
pub fn new(config: ClientConfig) -> LettaResult<Self> {
let http = reqwest::Client::builder()
.timeout(config.timeout)
.default_headers(config.headers.clone())
.build()?;
Ok(Self {
http,
config,
retry_config: RetryConfig::default(),
})
}
pub fn cloud(token: impl Into<String>) -> LettaResult<Self> {
ClientBuilder::new()
.environment(LettaEnvironment::Cloud)
.auth(AuthConfig::bearer(token))
.build()
}
pub fn local() -> LettaResult<Self> {
ClientBuilder::new()
.environment(LettaEnvironment::SelfHosted)
.build()
}
pub fn cloud_with_project(
token: impl Into<String>,
project_id: impl AsRef<str>,
) -> LettaResult<Self> {
ClientBuilder::new()
.environment(LettaEnvironment::Cloud)
.auth(AuthConfig::bearer(token))
.project(project_id)?
.build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
pub fn base_url(&self) -> &Url {
&self.config.base_url
}
pub fn http(&self) -> &reqwest::Client {
&self.http
}
pub fn auth(&self) -> &AuthConfig {
&self.config.auth
}
pub fn agents(&self) -> crate::api::AgentApi<'_> {
crate::api::AgentApi::new(self)
}
pub fn messages(&self) -> crate::api::MessageApi<'_> {
crate::api::MessageApi::new(self)
}
pub fn memory(&self) -> crate::api::MemoryApi<'_> {
crate::api::MemoryApi::new(self)
}
pub fn sources(&self) -> crate::api::SourceApi<'_> {
crate::api::SourceApi::new(self)
}
pub fn tools(&self) -> crate::api::ToolApi<'_> {
crate::api::ToolApi::new(self)
}
pub fn health(&self) -> crate::api::HealthApi<'_> {
crate::api::HealthApi::new(self)
}
pub fn blocks(&self) -> crate::api::BlocksApi<'_> {
crate::api::BlocksApi::new(self)
}
pub fn retry_config(&self) -> &RetryConfig {
&self.retry_config
}
pub fn set_retry_config(&mut self, config: RetryConfig) {
self.retry_config = config;
}
pub async fn get<T>(&self, path: &str) -> LettaResult<T>
where
T: serde::de::DeserializeOwned,
{
self.get_internal(path).await
}
#[tracing::instrument(skip(self), fields(path = %path))]
async fn get_internal<T>(&self, path: &str) -> LettaResult<T>
where
T: serde::de::DeserializeOwned,
{
let url = self.base_url().join(path.trim_start_matches('/'))?;
retry_with_config(&self.retry_config, || async {
let mut headers = HeaderMap::new();
self.auth().apply_to_headers(&mut headers)?;
tracing::debug!("Sending GET request to {}", url);
let response = self.http().get(url.clone()).headers(headers).send().await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response.text().await?;
return Err(LettaError::from_response_with_context(
status,
body,
Some(&headers),
Some(url.clone()),
Some("GET".to_string()),
));
}
Ok(response.json().await?)
})
.await
}
#[tracing::instrument(skip(self, body), fields(path = %path))]
pub async fn post<T, B>(&self, path: &str, body: &B) -> LettaResult<T>
where
T: serde::de::DeserializeOwned,
B: serde::Serialize + ?Sized,
{
let url = self.base_url().join(path.trim_start_matches('/'))?;
let body_json = serde_json::to_value(body)?;
retry_with_config(&self.retry_config, || async {
let mut headers = HeaderMap::new();
self.auth().apply_to_headers(&mut headers)?;
headers.insert(
"Content-Type",
"application/json"
.parse()
.map_err(|_| LettaError::config("Failed to parse Content-Type header"))?,
);
let response = self
.http()
.post(url.clone())
.headers(headers)
.json(&body_json)
.send()
.await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response.text().await?;
return Err(LettaError::from_response_with_context(
status,
body,
Some(&headers),
Some(url.clone()),
Some("POST".to_string()),
));
}
Ok(response.json().await?)
})
.await
}
#[tracing::instrument(skip(self, body), fields(path = %path))]
pub async fn patch<T, B>(&self, path: &str, body: &B) -> LettaResult<T>
where
T: serde::de::DeserializeOwned,
B: serde::Serialize + ?Sized,
{
let url = self.base_url().join(path.trim_start_matches('/'))?;
let body_json = serde_json::to_value(body)?;
retry_with_config(&self.retry_config, || async {
let mut headers = HeaderMap::new();
self.auth().apply_to_headers(&mut headers)?;
headers.insert(
"Content-Type",
"application/json"
.parse()
.map_err(|_| LettaError::config("Failed to parse Content-Type header"))?,
);
let response = self
.http()
.patch(url.clone())
.headers(headers)
.json(&body_json)
.send()
.await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response.text().await?;
return Err(LettaError::from_response_with_context(
status,
body,
Some(&headers),
Some(url.clone()),
Some("PATCH".to_string()),
));
}
Ok(response.json().await?)
})
.await
}
#[tracing::instrument(skip(self), fields(path = %path))]
pub async fn patch_no_body<T>(&self, path: &str) -> LettaResult<T>
where
T: serde::de::DeserializeOwned,
{
let url = self.base_url().join(path.trim_start_matches('/'))?;
retry_with_config(&self.retry_config, || async {
let mut headers = HeaderMap::new();
self.auth().apply_to_headers(&mut headers)?;
let response = self
.http()
.patch(url.clone())
.headers(headers)
.send()
.await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response.text().await?;
return Err(LettaError::from_response_with_context(
status,
body,
Some(&headers),
Some(url.clone()),
Some("PATCH".to_string()),
));
}
Ok(response.json().await?)
})
.await
}
#[tracing::instrument(skip(self, body), fields(path = %path))]
pub async fn put<T, B>(&self, path: &str, body: &B) -> LettaResult<T>
where
T: serde::de::DeserializeOwned,
B: serde::Serialize + ?Sized,
{
let url = self.base_url().join(path.trim_start_matches('/'))?;
let body_json = serde_json::to_value(body)?;
retry_with_config(&self.retry_config, || async {
let mut headers = HeaderMap::new();
self.auth().apply_to_headers(&mut headers)?;
headers.insert(
"Content-Type",
"application/json"
.parse()
.map_err(|_| LettaError::config("Failed to parse Content-Type header"))?,
);
let response = self
.http()
.put(url.clone())
.headers(headers)
.json(&body_json)
.send()
.await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response.text().await?;
return Err(LettaError::from_response_with_context(
status,
body,
Some(&headers),
Some(url.clone()),
Some("PUT".to_string()),
));
}
Ok(response.json().await?)
})
.await
}
#[tracing::instrument(skip(self, body, extra_headers), fields(path = %path))]
pub async fn put_with_headers<T, B>(
&self,
path: &str,
body: &B,
extra_headers: HeaderMap,
) -> LettaResult<T>
where
T: serde::de::DeserializeOwned,
B: serde::Serialize + ?Sized,
{
let url = self.base_url().join(path.trim_start_matches('/'))?;
let body_json = serde_json::to_value(body)?;
retry_with_config(&self.retry_config, || async {
let mut headers = HeaderMap::new();
self.auth().apply_to_headers(&mut headers)?;
headers.insert(
"Content-Type",
"application/json"
.parse()
.map_err(|_| LettaError::config("Failed to parse Content-Type header"))?,
);
for (key, value) in extra_headers.iter() {
headers.insert(key.clone(), value.clone());
}
let response = self
.http()
.put(url.clone())
.headers(headers)
.json(&body_json)
.send()
.await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response.text().await?;
return Err(LettaError::from_response_with_context(
status,
body,
Some(&headers),
Some(url.clone()),
Some("PUT".to_string()),
));
}
Ok(response.json().await?)
})
.await
}
#[tracing::instrument(skip(self), fields(path = %path))]
pub async fn delete<T>(&self, path: &str) -> LettaResult<T>
where
T: serde::de::DeserializeOwned,
{
let url = self.base_url().join(path.trim_start_matches('/'))?;
retry_with_config(&self.retry_config, || async {
let mut headers = HeaderMap::new();
self.auth().apply_to_headers(&mut headers)?;
let response = self
.http()
.delete(url.clone())
.headers(headers)
.send()
.await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response.text().await?;
return Err(LettaError::from_response_with_context(
status,
body,
Some(&headers),
Some(url.clone()),
Some("DELETE".to_string()),
));
}
Ok(response.json().await?)
})
.await
}
#[tracing::instrument(skip(self), fields(path = %path))]
pub async fn delete_no_response(&self, path: &str) -> LettaResult<()> {
let url = self.base_url().join(path.trim_start_matches('/'))?;
retry_with_config(&self.retry_config, || async {
let mut headers = HeaderMap::new();
self.auth().apply_to_headers(&mut headers)?;
let response = self
.http()
.delete(url.clone())
.headers(headers)
.send()
.await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response.text().await?;
return Err(LettaError::from_response_with_context(
status,
body,
Some(&headers),
Some(url.clone()),
Some("DELETE".to_string()),
));
}
Ok(())
})
.await
}
#[tracing::instrument(skip(self, query), fields(path = %path))]
pub async fn get_with_query<T, Q>(&self, path: &str, query: &Q) -> LettaResult<T>
where
T: serde::de::DeserializeOwned,
Q: serde::Serialize + ?Sized,
{
let url = self.base_url().join(path.trim_start_matches('/'))?;
retry_with_config(&self.retry_config, || async {
let mut headers = HeaderMap::new();
self.auth().apply_to_headers(&mut headers)?;
let response = self
.http()
.get(url.clone())
.headers(headers)
.query(query)
.send()
.await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response.text().await?;
return Err(LettaError::from_response_with_context(
status,
body,
Some(&headers),
Some(url.clone()),
Some("GET".to_string()),
));
}
Ok(response.json().await?)
})
.await
}
#[tracing::instrument(skip(self, body, extra_headers), fields(path = %path))]
pub async fn post_with_headers<T, B>(
&self,
path: &str,
body: &B,
extra_headers: HeaderMap,
) -> LettaResult<T>
where
T: serde::de::DeserializeOwned,
B: serde::Serialize + ?Sized,
{
let url = self.base_url().join(path.trim_start_matches('/'))?;
let body_json = serde_json::to_value(body)?;
retry_with_config(&self.retry_config, || async {
let mut headers = HeaderMap::new();
self.auth().apply_to_headers(&mut headers)?;
headers.insert(
"Content-Type",
"application/json"
.parse()
.map_err(|_| LettaError::config("Failed to parse Content-Type header"))?,
);
for (key, value) in extra_headers.iter() {
headers.insert(key.clone(), value.clone());
}
let response = self
.http()
.post(url.clone())
.headers(headers)
.json(&body_json)
.send()
.await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response.text().await?;
return Err(LettaError::from_response_with_context(
status,
body,
Some(&headers),
Some(url.clone()),
Some("POST".to_string()),
));
}
Ok(response.json().await?)
})
.await
}
#[tracing::instrument(skip(self, form), fields(path = %path))]
pub async fn post_multipart<T>(
&self,
path: &str,
form: reqwest::multipart::Form,
) -> LettaResult<T>
where
T: serde::de::DeserializeOwned,
{
let url = self.base_url().join(path.trim_start_matches('/'))?;
let mut headers = HeaderMap::new();
self.auth().apply_to_headers(&mut headers)?;
let response = self
.http()
.post(url.clone())
.headers(headers)
.multipart(form)
.send()
.await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response.text().await?;
return Err(LettaError::from_response_with_context(
status,
body,
Some(&headers),
Some(url.clone()),
Some("POST".to_string()),
));
}
Ok(response.json().await?)
}
}
#[derive(Debug, Default)]
pub struct ClientBuilder {
environment: Option<LettaEnvironment>,
base_url: Option<String>,
auth: Option<AuthConfig>,
timeout: Option<Duration>,
headers: Option<HeaderMap>,
}
impl ClientBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn environment(mut self, env: LettaEnvironment) -> Self {
self.environment = Some(env);
self
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn auth(mut self, auth: AuthConfig) -> Self {
self.auth = Some(auth);
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn headers(mut self, headers: HeaderMap) -> Self {
self.headers = Some(headers);
self
}
pub fn header(mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> LettaResult<Self> {
let key = key.as_ref();
let value = value.as_ref();
let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())
.map_err(|_| LettaError::validation(format!("Invalid header name: {}", key)))?;
let header_value = reqwest::header::HeaderValue::from_str(value).map_err(|_| {
LettaError::validation(format!("Invalid header value for {}: {}", key, value))
})?;
let headers = self.headers.get_or_insert_with(HeaderMap::new);
headers.insert(header_name, header_value);
Ok(self)
}
pub fn project(self, project_id: impl AsRef<str>) -> LettaResult<Self> {
self.header("X-Project", project_id)
}
pub fn user_id(self, user_id: impl AsRef<str>) -> LettaResult<Self> {
self.header("user-id", user_id)
}
pub fn build(self) -> LettaResult<LettaClient> {
let has_explicit_url = self.base_url.is_some();
let base_url = if let Some(url) = self.base_url {
url
} else {
let env = self.environment.unwrap_or_default();
env.base_url().to_string()
};
let mut config = ClientConfig::new(base_url)?;
if let Some(auth) = self.auth {
config = config.auth(auth);
} else if self.environment.unwrap_or_default().requires_auth() && !has_explicit_url {
eprintln!("Warning: Cloud environment typically requires authentication");
}
if let Some(timeout) = self.timeout {
config = config.timeout(timeout);
}
if let Some(headers) = self.headers {
config = config.headers(headers);
}
LettaClient::new(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_config() {
let config = ClientConfig::new("http://localhost:8283").unwrap();
assert_eq!(config.base_url.as_str(), "http://localhost:8283/");
assert_eq!(config.timeout, Duration::from_secs(30));
}
#[test]
fn test_client_builder() {
let client = ClientBuilder::new()
.base_url("http://localhost:8283")
.timeout(Duration::from_secs(60))
.build()
.unwrap();
assert_eq!(client.base_url().as_str(), "http://localhost:8283/");
}
#[test]
fn test_environment_based_client() {
let client = ClientBuilder::new()
.environment(LettaEnvironment::Cloud)
.auth(AuthConfig::bearer("test-token"))
.build()
.unwrap();
assert_eq!(client.base_url().as_str(), "https://api.letta.com/");
let client = ClientBuilder::new()
.environment(LettaEnvironment::SelfHosted)
.build()
.unwrap();
assert_eq!(client.base_url().as_str(), "http://localhost:8283/");
}
#[test]
fn test_convenience_constructors() {
let client = LettaClient::cloud("test-token").unwrap();
assert_eq!(client.base_url().as_str(), "https://api.letta.com/");
let client = LettaClient::local().unwrap();
assert_eq!(client.base_url().as_str(), "http://localhost:8283/");
}
#[test]
fn test_base_url_overrides_environment() {
let client = ClientBuilder::new()
.environment(LettaEnvironment::Cloud)
.base_url("http://custom.example.com")
.build()
.unwrap();
assert_eq!(client.base_url().as_str(), "http://custom.example.com/");
}
#[test]
fn test_header_configuration() -> LettaResult<()> {
let _client = ClientBuilder::new()
.base_url("http://localhost:8283")
.header("user-id", "test-user-123")?
.header("x-custom-header", "custom-value")?
.build()?;
let mut config = ClientConfig::new("http://localhost:8283")?;
config = config.header("user-id", "test-user-456")?;
let _client = LettaClient::new(config)?;
Ok(())
}
#[test]
fn test_header_helpers() -> LettaResult<()> {
let _client = ClientBuilder::new()
.base_url("http://localhost:8283")
.project("my-project-123")?
.build()?;
let _client = ClientBuilder::new()
.base_url("http://localhost:8283")
.user_id("user-456")?
.build()?;
let _client = ClientBuilder::new()
.base_url("http://localhost:8283")
.project("project-789")?
.user_id("user-789")?
.build()?;
let config = ClientConfig::new("http://localhost:8283")?
.project("config-project")?
.user_id("config-user")?;
let _client = LettaClient::new(config)?;
Ok(())
}
#[test]
fn test_cloud_with_project() -> LettaResult<()> {
let client = LettaClient::cloud_with_project("test-token", "project-123")?;
assert_eq!(client.base_url().as_str(), "https://api.letta.com/");
Ok(())
}
}