#![forbid(unsafe_code)]
#![warn(missing_debug_implementations)]
mod anthropic;
mod error;
mod openai;
mod types;
pub use anthropic::AnthropicProvider;
pub use error::{ApiError, ApiErrorKind, LlmError};
pub use openai::OpenAiProvider;
pub use types::*;
use async_trait::async_trait;
use serde_json::Value;
pub type Result<T> = std::result::Result<T, LlmError>;
#[async_trait]
pub trait LlmProvider: Send + Sync {
async fn chat(&self, request: &ChatRequest) -> Result<ChatResponse>;
}
fn invalid(message: String) -> LlmError {
LlmError::InvalidArgument(message)
}
pub(crate) fn validate(
provider: &'static str,
api_key: &str,
base_url: &str,
req: &ChatRequest,
) -> Result<()> {
if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
return Err(invalid(format!(
"{provider}: base_url `{base_url}` must start with http:// or https://"
)));
}
if provider == "AnthropicProvider" && api_key.trim().is_empty() {
return Err(invalid(format!("{provider}: api_key must not be empty")));
}
if req.model.trim().is_empty() {
return Err(invalid("model must not be empty".to_string()));
}
if req.messages.is_empty() {
return Err(invalid("messages must not be empty".to_string()));
}
if let Some(t) = req.temperature {
if !(0.0..=2.0).contains(&t) {
return Err(invalid(format!(
"temperature must be between 0.0 and 2.0, got {t}"
)));
}
}
for tool in &req.tools {
let valid_name = !tool.name.is_empty()
&& tool.name.len() <= 64
&& tool
.name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if !valid_name {
return Err(invalid(format!(
"tool name `{}` must match ^[a-zA-Z0-9_-]{{1,64}}$",
tool.name
)));
}
if tool.parameters["type"] != "object" {
return Err(invalid(format!(
"tool `{}`: parameters must be a JSON Schema with \"type\": \"object\"",
tool.name
)));
}
}
Ok(())
}
pub(crate) fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(300))
.redirect(reqwest::redirect::Policy::none())
.pool_max_idle_per_host(8)
.build()
.expect("building an HTTP client from a fixed configuration should not fail")
}
pub(crate) async fn send_json(request: reqwest::RequestBuilder) -> Result<Value> {
let response = request.send().await.map_err(LlmError::from_reqwest)?;
let status = response.status().as_u16();
let headers = response.headers();
let request_id = headers
.get("request-id")
.or_else(|| headers.get("x-request-id"))
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let text = response.text().await.map_err(LlmError::from_reqwest)?;
if !(200..300).contains(&status) {
return Err(LlmError::Api(Box::new(ApiError::from_response(
status, request_id, &text,
))));
}
serde_json::from_str(&text).map_err(|source| LlmError::Deserialize {
source,
content: error::truncate(&text),
})
}
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
pub struct ReadmeDoctests;