pub mod anthropic;
pub mod compat;
pub mod deepseek;
pub mod google;
pub(crate) mod http;
pub mod moonshot;
pub mod ollama;
pub mod openai;
pub mod openrouter;
pub mod retry;
pub mod stream_util;
use async_trait::async_trait;
use futures::stream::BoxStream;
use crate::types::{ChatRequest, ChatResponse, StreamChunk};
#[derive(Debug, thiserror::Error)]
pub enum LlmError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("API error ({status}): {message}")]
Api { status: u16, message: String },
#[error("Stream error: {0}")]
Stream(String),
#[error("Invalid response: {0}")]
Parse(String),
#[error("Unknown provider: {0}")]
UnknownProvider(String),
}
pub type Result<T> = std::result::Result<T, LlmError>;
#[async_trait]
pub trait Provider: Send + Sync {
async fn chat(&self, req: &ChatRequest) -> Result<ChatResponse>;
async fn stream(&self, req: &ChatRequest) -> Result<BoxStream<'static, Result<StreamChunk>>>;
}
#[derive(Clone)]
pub struct ProviderConfig {
pub api_key: String,
pub base_url: Option<String>,
pub timeout_secs: Option<u64>,
pub custom_headers: Option<std::collections::HashMap<String, String>>,
}
impl std::fmt::Debug for ProviderConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderConfig")
.field("api_key", &"***")
.field("base_url", &self.base_url.as_ref().map(|_| "***"))
.field("timeout_secs", &self.timeout_secs)
.field(
"custom_headers",
&self.custom_headers.as_ref().map(|_| "***"),
)
.finish()
}
}
pub(crate) fn make_tool_call_id(index: usize) -> String {
format!("call_{index}")
}
pub(crate) fn n_is_unsupported(n: Option<u32>) -> bool {
matches!(n, Some(k) if k > 1)
}
pub(crate) fn warn_if_unsupported_n(provider: &str, n: Option<u32>) {
if n_is_unsupported(n) {
tracing::warn!(
provider,
n = n.unwrap(),
"n > 1 requested but llmrust returns only the first completion; \
the remaining choices are discarded and upstream providers may still bill for all N"
);
}
}
impl ProviderConfig {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
base_url: None,
timeout_secs: None,
custom_headers: None,
}
}
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn with_timeout_secs(mut self, secs: u64) -> Self {
self.timeout_secs = Some(secs);
self
}
pub fn with_header(mut self, key: impl Into<String>, val: impl Into<String>) -> Self {
self.custom_headers
.get_or_insert_with(std::collections::HashMap::new)
.insert(key.into(), val.into());
self
}
pub fn with_headers(mut self, headers: impl IntoIterator<Item = (String, String)>) -> Self {
self.custom_headers = Some(headers.into_iter().collect());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_config_debug_masks_sensitive_fields() {
let mut config =
ProviderConfig::new("sk-secret-12345").with_base_url("https://gateway.example/v1");
config.custom_headers = Some(
[
("Authorization".into(), "Bearer hidden-token".into()),
("X-Api-Key".into(), "secret-key".into()),
]
.into_iter()
.collect(),
);
let debug = format!("{:?}", config);
assert!(
!debug.contains("sk-secret-12345"),
"Debug output should not contain the API key, got: {debug}"
);
assert!(
!debug.contains("gateway.example"),
"Debug output should not contain the base URL, got: {debug}"
);
assert!(
!debug.contains("Bearer hidden-token"),
"Debug output should not contain custom header values, got: {debug}"
);
assert!(
!debug.contains("secret-key"),
"Debug output should not contain custom header values, got: {debug}"
);
assert!(
debug.contains("***"),
"Debug output should mask fields, got: {debug}"
);
}
#[test]
fn n_one_or_none_is_supported() {
assert!(!n_is_unsupported(None));
assert!(!n_is_unsupported(Some(1)));
}
#[test]
fn n_greater_than_one_is_unsupported() {
assert!(n_is_unsupported(Some(2)));
assert!(n_is_unsupported(Some(5)));
assert!(n_is_unsupported(Some(128)));
}
}