1use reqwest::header::{HeaderName, HeaderValue, AUTHORIZATION};
8use reqwest::{StatusCode, Url};
9use serde::Serialize;
10use serde_json::Value;
11use std::time::{Duration, Instant};
12
13const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
14const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
15
16#[derive(Debug, Clone, Serialize)]
17#[serde(rename_all = "camelCase")]
18pub struct ProviderModel {
19 pub id: String,
20 pub owned_by: Option<String>,
21 pub context_window: Option<u64>,
22 pub max_tokens: Option<u64>,
23 pub supports_images: Option<bool>,
24}
25
26#[derive(Debug, Clone, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct ProviderProbeResult {
29 pub ok: bool,
30 pub status: u16,
31 pub latency_ms: u64,
32 pub model_count: Option<usize>,
33}
34
35pub async fn get_models(
36 api: &str,
37 base_url: &str,
38 api_key: &str,
39) -> Result<Vec<ProviderModel>, String> {
40 let candidates = model_url_candidates(base_url)?;
41 let client = reqwest::Client::builder()
42 .timeout(REQUEST_TIMEOUT)
43 .build()
44 .map_err(|error| format!("failed to create HTTP client: {error}"))?;
45 let headers = request_headers(api, api_key)?;
46 let mut last_status = None;
47
48 for endpoint in candidates {
49 let response = client
50 .get(&endpoint)
51 .headers(headers.clone())
52 .send()
53 .await
54 .map_err(|error| request_error(&error))?;
55 let status = response.status();
56 last_status = Some(status);
57 if status == StatusCode::NOT_FOUND || status == StatusCode::METHOD_NOT_ALLOWED {
58 continue;
59 }
60 if !status.is_success() {
61 return Err(format!("provider returned HTTP {}", status.as_u16()));
62 }
63 let body = bounded_body(response).await?;
64 return parse_models(&body);
65 }
66
67 Err(format!(
68 "model endpoint not found (last status: {})",
69 last_status
70 .map(|status| status.as_u16().to_string())
71 .unwrap_or_else(|| "none".to_string())
72 ))
73}
74
75pub async fn test_connection(
76 api: &str,
77 base_url: &str,
78 api_key: &str,
79) -> Result<ProviderProbeResult, String> {
80 let candidates = model_url_candidates(base_url)?;
81 let client = reqwest::Client::builder()
82 .timeout(REQUEST_TIMEOUT)
83 .build()
84 .map_err(|error| format!("failed to create HTTP client: {error}"))?;
85 let headers = request_headers(api, api_key)?;
86 let mut last_status = None;
87 for endpoint in candidates {
88 let started = Instant::now();
89 let response = client
90 .get(&endpoint)
91 .headers(headers.clone())
92 .send()
93 .await
94 .map_err(|error| request_error(&error))?;
95 let status = response.status();
96 let latency_ms = started.elapsed().as_millis() as u64;
97 last_status = Some(status);
98 if status == StatusCode::NOT_FOUND || status == StatusCode::METHOD_NOT_ALLOWED {
99 continue;
100 }
101 if !status.is_success() {
102 return Err(format!("provider returned HTTP {}", status.as_u16()));
103 }
104 let model_count = bounded_body(response)
105 .await
106 .ok()
107 .and_then(|body| parse_models(&body).ok())
108 .map(|models| models.len());
109 return Ok(ProviderProbeResult {
110 ok: true,
111 status: status.as_u16(),
112 latency_ms,
113 model_count,
114 });
115 }
116 Err(format!(
117 "model endpoint not found (last status: {})",
118 last_status
119 .map(|status| status.as_u16().to_string())
120 .unwrap_or_else(|| "none".to_string())
121 ))
122}
123
124fn request_headers(api: &str, api_key: &str) -> Result<reqwest::header::HeaderMap, String> {
125 let api = api.trim();
126 let api_key = api_key.trim();
127 if !matches!(
128 api,
129 "anthropic-messages" | "openai-completions" | "openai-responses"
130 ) {
131 return Err("unsupported provider protocol".into());
132 }
133 if api_key.is_empty() {
134 return Err("API key is required".into());
135 }
136 let mut headers = reqwest::header::HeaderMap::new();
137 let (name, value) = if api == "anthropic-messages" {
138 (
139 HeaderName::from_static("x-api-key"),
140 HeaderValue::from_str(api_key).map_err(|_| "invalid API key header".to_string())?,
141 )
142 } else {
143 (
144 AUTHORIZATION,
145 HeaderValue::from_str(&format!("Bearer {api_key}"))
146 .map_err(|_| "invalid API key header".to_string())?,
147 )
148 };
149 headers.insert(name, value);
150 if api == "anthropic-messages" {
151 headers.insert(
152 HeaderName::from_static("anthropic-version"),
153 HeaderValue::from_static("2023-06-01"),
154 );
155 }
156 Ok(headers)
157}
158
159fn model_url_candidates(base_url: &str) -> Result<Vec<String>, String> {
160 let trimmed = base_url.trim().trim_end_matches('/');
161 let parsed = Url::parse(trimmed).map_err(|_| "base URL must be a valid URL".to_string())?;
162 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
163 return Err("base URL must use http:// or https://".into());
164 }
165 if trimmed.is_empty() {
166 return Err("base URL is empty".into());
167 }
168 let mut candidates = vec![format!("{trimmed}/models")];
169 if !trimmed.ends_with("/v1") && !trimmed.ends_with("/v2") && !trimmed.ends_with("/v3") {
170 candidates.push(format!("{trimmed}/v1/models"));
171 }
172 Ok(candidates)
173}
174
175async fn bounded_body(response: reqwest::Response) -> Result<String, String> {
176 if response
177 .content_length()
178 .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64)
179 {
180 return Err("model response is too large".into());
181 }
182 let bytes = response
183 .bytes()
184 .await
185 .map_err(|error| format!("failed to read model response: {error}"))?;
186 if bytes.len() > MAX_RESPONSE_BYTES {
187 return Err("model response is too large".into());
188 }
189 String::from_utf8(bytes.to_vec()).map_err(|_| "model response is not valid UTF-8".into())
190}
191
192fn parse_models(body: &str) -> Result<Vec<ProviderModel>, String> {
193 let value: Value =
194 serde_json::from_str(body).map_err(|_| "failed to parse model response".to_string())?;
195 let entries = value
196 .get("data")
197 .and_then(Value::as_array)
198 .or_else(|| value.get("models").and_then(Value::as_array))
199 .ok_or_else(|| "model response has no data or models array".to_string())?;
200 let mut models = entries
201 .iter()
202 .filter_map(|entry| {
203 let id = entry
204 .get("id")
205 .and_then(Value::as_str)
206 .or_else(|| entry.get("slug").and_then(Value::as_str))
207 .or_else(|| entry.get("name").and_then(Value::as_str))?
208 .trim();
209 if id.is_empty() {
210 return None;
211 }
212 Some(ProviderModel {
213 id: id.strip_prefix("models/").unwrap_or(id).to_string(),
214 owned_by: entry
215 .get("owned_by")
216 .and_then(Value::as_str)
217 .map(str::to_string),
218 context_window: model_context_window(entry),
219 max_tokens: model_max_tokens(entry),
220 supports_images: model_supports_images(entry),
221 })
222 })
223 .collect::<Vec<_>>();
224 models.sort_by(|a, b| a.id.cmp(&b.id));
225 models.dedup_by(|a, b| a.id == b.id);
226 Ok(models)
227}
228
229fn model_context_window(entry: &Value) -> Option<u64> {
230 [
231 entry.get("contextWindow"),
232 entry.get("context_window"),
233 entry.get("context_length"),
234 entry.get("max_context_size"),
235 entry.get("limit").and_then(|limit| limit.get("context")),
236 ]
237 .into_iter()
238 .flatten()
239 .find_map(|value| {
240 value
241 .as_u64()
242 .or_else(|| value.as_str().and_then(|text| text.trim().parse().ok()))
243 .filter(|value| *value > 0)
244 })
245}
246
247fn model_max_tokens(entry: &Value) -> Option<u64> {
248 [
249 entry.get("maxTokens"),
250 entry.get("max_tokens"),
251 entry.get("max_output_tokens"),
252 entry.get("output"),
253 entry.get("limit").and_then(|limit| limit.get("output")),
254 ]
255 .into_iter()
256 .flatten()
257 .find_map(|value| {
258 value
259 .as_u64()
260 .or_else(|| value.as_str().and_then(|text| text.trim().parse().ok()))
261 .filter(|value| *value > 0)
262 })
263}
264
265fn model_supports_images(entry: &Value) -> Option<bool> {
266 for key in ["supportsImages", "supports_images", "vision"] {
267 if let Some(value) = entry.get(key).and_then(Value::as_bool) {
268 return Some(value);
269 }
270 }
271 for key in ["input", "modalities", "input_modalities"] {
272 if let Some(values) = entry.get(key).and_then(Value::as_array) {
273 return Some(values.iter().any(|value| {
274 value
275 .as_str()
276 .is_some_and(|text| matches!(text, "image" | "images" | "vision"))
277 }));
278 }
279 }
280 entry
281 .get("capabilities")
282 .and_then(|value| value.get("image").or_else(|| value.get("vision")))
283 .and_then(Value::as_bool)
284}
285
286fn request_error(error: &reqwest::Error) -> String {
287 if error.is_timeout() {
288 "provider request timed out".into()
289 } else if error.is_connect() {
290 "could not connect to provider".into()
291 } else {
292 "provider request failed".into()
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn model_candidates_use_versioned_base_without_duplicate_v1() {
302 assert_eq!(
303 model_url_candidates("https://example.test/v1").unwrap(),
304 vec!["https://example.test/v1/models"]
305 );
306 assert_eq!(
307 model_url_candidates("https://example.test").unwrap(),
308 vec![
309 "https://example.test/models",
310 "https://example.test/v1/models"
311 ]
312 );
313 }
314
315 #[test]
316 fn parse_openai_and_anthropic_model_shapes() {
317 let models = parse_models(
318 r#"{"data":[{"id":"b"},{"id":"a","owned_by":"x","contextWindow":128000,"max_tokens":4096,"input":["text","image"]}]}"#,
319 )
320 .unwrap();
321 assert_eq!(models[0].id, "a");
322 assert_eq!(models[0].context_window, Some(128000));
323 assert_eq!(models[0].max_tokens, Some(4096));
324 assert_eq!(models[0].supports_images, Some(true));
325 assert_eq!(models[1].id, "b");
326 let models =
327 parse_models(r#"{"models":[{"slug":"models/gemini","limit":{"context":"1000"}}]}"#)
328 .unwrap();
329 assert_eq!(models[0].id, "gemini");
330 assert_eq!(models[0].context_window, Some(1000));
331 }
332
333 #[test]
334 fn protocols_use_the_expected_auth_headers() {
335 let anthropic = request_headers("anthropic-messages", "secret").unwrap();
336 assert_eq!(anthropic["x-api-key"], "secret");
337 assert_eq!(anthropic["anthropic-version"], "2023-06-01");
338 let openai = request_headers("openai-responses", "secret").unwrap();
339 assert_eq!(openai[AUTHORIZATION], "Bearer secret");
340 }
341
342 #[test]
343 fn rejects_non_http_urls_and_invalid_responses() {
344 assert!(model_url_candidates("file:///tmp/models").is_err());
345 assert!(parse_models("{}").is_err());
346 }
347}