1#![forbid(unsafe_code)]
24#![warn(missing_debug_implementations)]
25
26mod anthropic;
27mod cohere;
28mod error;
29mod gemini;
30mod openai;
31mod types;
32
33pub use anthropic::AnthropicProvider;
34pub use cohere::CohereProvider;
35pub use error::{ApiError, ApiErrorKind, LlmError};
36pub use gemini::GeminiProvider;
37pub use openai::OpenAiProvider;
38pub use types::*;
39
40use async_trait::async_trait;
41use serde_json::Value;
42
43pub type Result<T> = std::result::Result<T, LlmError>;
44
45#[async_trait]
47pub trait LlmProvider: Send + Sync {
48 async fn chat(&self, request: &ChatRequest) -> Result<ChatResponse>;
49}
50
51fn invalid(message: String) -> LlmError {
52 LlmError::InvalidArgument(message)
53}
54
55const MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
57const MAX_ERROR_BYTES: usize = 64 * 1024;
59
60fn checked_host<'a>(provider: &str, base_url: &'a str) -> Result<&'a str> {
62 let rest = base_url
63 .strip_prefix("https://")
64 .or_else(|| base_url.strip_prefix("http://"))
65 .ok_or_else(|| {
66 invalid(format!(
67 "{provider}: base_url must start with http:// or https://"
68 ))
69 })?;
70 let authority = rest.split('/').next().unwrap_or_default();
71 if authority.contains('@') || base_url.contains('?') || base_url.contains('#') {
72 return Err(invalid(format!(
74 "{provider}: base_url must not contain credentials, a query string or a fragment"
75 )));
76 }
77 if authority.is_empty() {
78 return Err(invalid(format!("{provider}: base_url has no host")));
79 }
80 Ok(authority)
81}
82
83fn is_loopback(authority: &str) -> bool {
84 let host = if let Some(rest) = authority.strip_prefix('[') {
85 rest.split(']').next().unwrap_or_default()
86 } else {
87 authority.split(':').next().unwrap_or_default()
88 };
89 host == "localhost" || host.ends_with(".localhost") || host == "::1" || host.starts_with("127.")
90}
91
92pub(crate) fn validate(
94 provider: &'static str,
95 api_key: &str,
96 require_key: bool,
97 base_url: &str,
98 max_tool_name: usize,
99 req: &ChatRequest,
100) -> Result<()> {
101 let authority = checked_host(provider, base_url)?;
102 if base_url.starts_with("http://") && !api_key.is_empty() && !is_loopback(authority) {
103 return Err(invalid(format!(
104 "{provider}: refusing to send an API key over plain http:// to a non-local host; use https://"
105 )));
106 }
107 if require_key && api_key.trim().is_empty() {
108 return Err(invalid(format!("{provider}: api_key must not be empty")));
109 }
110 if req.model.trim().is_empty() {
111 return Err(invalid("model must not be empty".to_string()));
112 }
113 if req.model.chars().any(char::is_control) {
114 return Err(invalid(
115 "model must not contain control characters".to_string(),
116 ));
117 }
118 if req.messages.is_empty() {
119 return Err(invalid("messages must not be empty".to_string()));
120 }
121 for (i, message) in req.messages.iter().enumerate() {
122 let has_content = message.parts.iter().any(|p| match p {
123 Part::Text { text } => !text.is_empty(),
124 _ => true,
125 });
126 if !has_content {
127 return Err(invalid(format!("messages[{i}] has no content")));
128 }
129 }
130 if let Some(t) = req.temperature {
131 if !(0.0..=2.0).contains(&t) {
132 return Err(invalid(format!(
133 "temperature must be between 0.0 and 2.0, got {t}"
134 )));
135 }
136 }
137 let mut names = std::collections::HashSet::new();
138 for tool in &req.tools {
139 let valid_name = !tool.name.is_empty()
140 && tool.name.len() <= max_tool_name
141 && tool
142 .name
143 .chars()
144 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
145 if !valid_name {
146 return Err(invalid(format!(
147 "tool name `{}` must match ^[a-zA-Z0-9_-]{{1,{max_tool_name}}}$",
148 tool.name
149 )));
150 }
151 if !names.insert(tool.name.as_str()) {
152 return Err(invalid(format!("duplicate tool name `{}`", tool.name)));
153 }
154 if tool.parameters["type"] != "object" {
155 return Err(invalid(format!(
156 "tool `{}`: parameters must be a JSON Schema with \"type\": \"object\"",
157 tool.name
158 )));
159 }
160 }
161 Ok(())
162}
163
164pub(crate) fn secret_header(provider: &str, value: &str) -> Result<reqwest::header::HeaderValue> {
166 let mut header = reqwest::header::HeaderValue::from_str(value).map_err(|_| {
167 invalid(format!(
168 "{provider}: api_key contains characters not allowed in an HTTP header"
169 ))
170 })?;
171 header.set_sensitive(true);
172 Ok(header)
173}
174
175pub(crate) fn http_client() -> reqwest::Client {
178 reqwest::Client::builder()
179 .connect_timeout(std::time::Duration::from_secs(10))
180 .timeout(std::time::Duration::from_secs(300))
181 .redirect(reqwest::redirect::Policy::none())
182 .pool_max_idle_per_host(8)
183 .build()
184 .unwrap_or_else(|_| reqwest::Client::new())
185}
186
187async fn read_body(mut response: reqwest::Response, cap: usize, truncate: bool) -> Result<String> {
190 let too_large = || LlmError::InvalidResponse(format!("response body exceeds {cap} bytes"));
191 if !truncate && response.content_length().is_some_and(|n| n > cap as u64) {
192 return Err(too_large());
193 }
194 let mut buf = Vec::new();
195 while let Some(chunk) = response.chunk().await.map_err(LlmError::from_reqwest)? {
196 if buf.len() + chunk.len() > cap {
197 if !truncate {
198 return Err(too_large());
199 }
200 buf.extend_from_slice(&chunk[..cap - buf.len()]);
201 break;
202 }
203 buf.extend_from_slice(&chunk);
204 }
205 Ok(String::from_utf8_lossy(&buf).into_owned())
206}
207
208pub(crate) async fn send_json(request: reqwest::RequestBuilder) -> Result<Value> {
210 let response = request.send().await.map_err(LlmError::from_reqwest)?;
211 let status = response.status().as_u16();
212 let headers = response.headers();
213 let request_id = headers
214 .get("request-id")
215 .or_else(|| headers.get("x-request-id"))
216 .and_then(|v| v.to_str().ok())
217 .map(str::to_string);
218
219 if !(200..300).contains(&status) {
220 let text = read_body(response, MAX_ERROR_BYTES, true).await?;
221 return Err(LlmError::Api(Box::new(ApiError::from_response(
222 status, request_id, &text,
223 ))));
224 }
225 let text = read_body(response, MAX_RESPONSE_BYTES, false).await?;
226 serde_json::from_str(&text).map_err(|source| LlmError::Deserialize {
227 source,
228 content: error::truncate(&text),
229 })
230}
231
232#[cfg(doctest)]
234#[doc = include_str!("../README.md")]
235pub struct ReadmeDoctests;