1#![forbid(unsafe_code)]
35#![warn(missing_debug_implementations)]
36
37mod anthropic;
38mod cohere;
39mod error;
40mod gemini;
41mod http;
42mod openai;
43mod runner;
44mod stream;
45mod types;
46
47pub use anthropic::AnthropicProvider;
48pub use cohere::CohereProvider;
49pub use error::{ApiError, ApiErrorKind, LlmError};
50pub use futures_util::StreamExt;
51pub use gemini::GeminiProvider;
52pub use http::DEFAULT_MAX_RETRIES;
53pub use openai::OpenAiProvider;
54pub use runner::run_tools;
55pub use stream::{ChatStream, StreamEvent};
56pub use types::*;
57
58use async_trait::async_trait;
59
60pub type Result<T> = std::result::Result<T, LlmError>;
61
62#[async_trait]
64pub trait LlmProvider: Send + Sync {
65 async fn chat(&self, request: &ChatRequest) -> Result<ChatResponse>;
67
68 async fn chat_stream(&self, request: &ChatRequest) -> Result<ChatStream> {
74 let response = self.chat(request).await?;
75 let events = vec![
76 Ok(StreamEvent::TextDelta(response.text())),
77 Ok(StreamEvent::Done(response)),
78 ];
79 Ok(Box::pin(futures_util::stream::iter(events)))
80 }
81
82 async fn list_models(&self) -> Result<Vec<ModelInfo>> {
84 Err(LlmError::InvalidArgument(
85 "list_models is not supported by this provider".into(),
86 ))
87 }
88}
89
90#[async_trait]
92pub trait EmbeddingProvider: Send + Sync {
93 async fn embed(&self, model: &str, inputs: &[&str]) -> Result<Embeddings>;
94}
95
96fn invalid(message: String) -> LlmError {
97 LlmError::InvalidArgument(message)
98}
99
100fn checked_host<'a>(provider: &str, base_url: &'a str) -> Result<&'a str> {
102 let rest = base_url
103 .strip_prefix("https://")
104 .or_else(|| base_url.strip_prefix("http://"))
105 .ok_or_else(|| {
106 invalid(format!(
107 "{provider}: base_url must start with http:// or https://"
108 ))
109 })?;
110 let authority = rest.split('/').next().unwrap_or_default();
111 if authority.contains('@') || base_url.contains('?') || base_url.contains('#') {
112 return Err(invalid(format!(
114 "{provider}: base_url must not contain credentials, a query string or a fragment"
115 )));
116 }
117 if authority.is_empty() {
118 return Err(invalid(format!("{provider}: base_url has no host")));
119 }
120 Ok(authority)
121}
122
123fn is_loopback(authority: &str) -> bool {
124 let host = if let Some(rest) = authority.strip_prefix('[') {
125 rest.split(']').next().unwrap_or_default()
126 } else {
127 authority.split(':').next().unwrap_or_default()
128 };
129 host == "localhost" || host.ends_with(".localhost") || host == "::1" || host.starts_with("127.")
130}
131
132pub(crate) fn validate_endpoint(
134 provider: &'static str,
135 api_key: &str,
136 require_key: bool,
137 base_url: &str,
138) -> Result<()> {
139 let authority = checked_host(provider, base_url)?;
140 if base_url.starts_with("http://") && !api_key.is_empty() && !is_loopback(authority) {
141 return Err(invalid(format!(
142 "{provider}: refusing to send an API key over plain http:// to a non-local host; use https://"
143 )));
144 }
145 if require_key && api_key.trim().is_empty() {
146 return Err(invalid(format!("{provider}: api_key must not be empty")));
147 }
148 Ok(())
149}
150
151pub(crate) fn checked_path_segment<'a>(
153 provider: &str,
154 what: &str,
155 value: &'a str,
156 extra: &[char],
157) -> Result<&'a str> {
158 let ok = !value.is_empty()
160 && !value.chars().all(|c| c == '.')
161 && value.chars().all(|c| {
162 c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') || extra.contains(&c)
163 });
164 if ok {
165 Ok(value)
166 } else {
167 Err(invalid(format!(
168 "{provider}: {what} `{value}` may only contain a-z, A-Z, 0-9, '-', '_' and '.'"
169 )))
170 }
171}
172
173const IMAGE_TYPES: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
174
175pub(crate) fn validate(
177 provider: &'static str,
178 api_key: &str,
179 require_key: bool,
180 base_url: &str,
181 max_tool_name: usize,
182 req: &ChatRequest,
183) -> Result<()> {
184 validate_endpoint(provider, api_key, require_key, base_url)?;
185 if req.model.trim().is_empty() {
186 return Err(invalid("model must not be empty".to_string()));
187 }
188 if req.model.chars().any(char::is_control) {
189 return Err(invalid(
190 "model must not contain control characters".to_string(),
191 ));
192 }
193 if req.messages.is_empty() {
194 return Err(invalid("messages must not be empty".to_string()));
195 }
196 for (i, message) in req.messages.iter().enumerate() {
197 let has_content = message.parts.iter().any(|p| match p {
198 Part::Text { text } => !text.is_empty(),
199 Part::Reasoning { .. } => false,
200 _ => true,
201 });
202 if !has_content {
203 return Err(invalid(format!("messages[{i}] has no content")));
204 }
205 if message.has_images() && message.role != Role::User {
206 return Err(invalid(format!(
207 "messages[{i}]: images are only allowed in user messages"
208 )));
209 }
210 for part in &message.parts {
211 if let Part::Image { source } = part {
212 validate_image(i, source)?;
213 }
214 }
215 }
216 if let Some(t) = req.temperature {
217 if !(0.0..=2.0).contains(&t) {
218 return Err(invalid(format!(
219 "temperature must be between 0.0 and 2.0, got {t}"
220 )));
221 }
222 }
223 if let Some(format) = &req.response_format {
224 let valid_name = !format.name.is_empty()
225 && format.name.len() <= 64
226 && format
227 .name
228 .chars()
229 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
230 if !valid_name {
231 return Err(invalid(format!(
232 "response_format name `{}` must match ^[a-zA-Z0-9_-]{{1,64}}$",
233 format.name
234 )));
235 }
236 if !format.schema.is_object() {
237 return Err(invalid(
238 "response_format schema must be a JSON object".to_string(),
239 ));
240 }
241 }
242 let mut names = std::collections::HashSet::new();
243 for tool in &req.tools {
244 let valid_name = !tool.name.is_empty()
245 && tool.name.len() <= max_tool_name
246 && tool
247 .name
248 .chars()
249 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
250 if !valid_name {
251 return Err(invalid(format!(
252 "tool name `{}` must match ^[a-zA-Z0-9_-]{{1,{max_tool_name}}}$",
253 tool.name
254 )));
255 }
256 if !names.insert(tool.name.as_str()) {
257 return Err(invalid(format!("duplicate tool name `{}`", tool.name)));
258 }
259 if tool.parameters["type"] != "object" {
260 return Err(invalid(format!(
261 "tool `{}`: parameters must be a JSON Schema with \"type\": \"object\"",
262 tool.name
263 )));
264 }
265 }
266 Ok(())
267}
268
269fn validate_image(i: usize, source: &ImageSource) -> Result<()> {
270 match source {
271 ImageSource::Url { url } => {
272 if !url.starts_with("https://") && !url.starts_with("http://") {
273 return Err(invalid(format!(
274 "messages[{i}]: image url must start with http:// or https://"
275 )));
276 }
277 }
278 ImageSource::Base64 { media_type, data } => {
279 if !IMAGE_TYPES.contains(&media_type.as_str()) {
280 return Err(invalid(format!(
281 "messages[{i}]: image media_type `{media_type}` must be one of {}",
282 IMAGE_TYPES.join(", ")
283 )));
284 }
285 let base64 = |c: char| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=');
286 if data.is_empty() || !data.chars().all(base64) {
287 return Err(invalid(format!(
288 "messages[{i}]: image data must be non-empty base64 without a `data:` prefix"
289 )));
290 }
291 }
292 }
293 Ok(())
294}
295
296#[cfg(doctest)]
298#[doc = include_str!("../README.md")]
299pub struct ReadmeDoctests;