Skip to main content

rpi_cli/
provider_probe.rs

1//! Provider configuration probes used by the RPC provider form.
2//!
3//! The probes deliberately use the provider's model-list endpoint rather than
4//! generating a completion: fetching `/models` is cheap, read-only, and works
5//! for both model discovery and a useful API-key/endpoint connectivity test.
6
7use 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}