llm-universal 0.1.0

One async client for OpenAI-compatible Chat Completions and the Anthropic Messages API, with tool calling and SDK-style errors
Documentation
//! One client interface for OpenAI-compatible APIs and the Anthropic Messages API.
//!
//! The same code (`ChatRequest` -> `ChatResponse`) runs against any provider; switching
//! providers means switching the constructor:
//!
//! ```no_run
//! # use llm_universal::*;
//! # async fn demo() -> Result<()> {
//! let provider: Box<dyn LlmProvider> = Box::new(AnthropicProvider::new("sk-ant-..."));
//! // or: Box::new(OpenAiProvider::new("sk-...")),
//! // or: Box::new(OpenAiProvider::new("").with_base_url("http://localhost:11434/v1")),
//! let req = ChatRequest::new("claude-opus-5-5")
//!     .system("Be concise.")
//!     .max_tokens(512)
//!     .message(Message::user("Hello!"));
//! let resp = provider.chat(&req).await?;
//! println!("{}", resp.text());
//! # Ok(()) }
//! ```
//!
//! Errors follow the official SDKs: see [`LlmError`] and [`ApiError`].

#![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>;

/// Common contract implemented by every provider.
#[async_trait]
pub trait LlmProvider: Send + Sync {
    async fn chat(&self, request: &ChatRequest) -> Result<ChatResponse>;
}

fn invalid(message: String) -> LlmError {
    LlmError::InvalidArgument(message)
}

/// Validates the request and provider settings before any network call.
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(())
}

/// Default HTTP client: timeouts, no redirects (so the key is never sent to another host),
/// and a bounded idle pool.
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")
}

/// Sends the request, maps non-2xx responses to [`ApiError`] and parses the body as JSON.
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),
    })
}

/// Compiles the README examples as doctests.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
pub struct ReadmeDoctests;