Skip to main content

harn_vm/llm/
healthcheck.rs

1use std::collections::BTreeMap;
2use std::time::Duration;
3
4use reqwest::Method;
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value as JsonValue};
7
8use crate::llm_config::{self, AuthEnv, HealthcheckDef, ProviderDef};
9
10use super::api::apply_auth_headers;
11
12const DEFAULT_HEALTHCHECK_TIMEOUT_SECS: u64 = 5;
13const BODY_SNIPPET_LIMIT: usize = 1000;
14
15#[derive(Debug, Clone, Default)]
16pub struct ProviderHealthcheckOptions {
17    /// Candidate API key to validate. When unset, Harn resolves credentials
18    /// from the provider's configured environment variables.
19    pub api_key: Option<String>,
20    /// Optional client override for hosts that need custom transport policy.
21    pub client: Option<reqwest::Client>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ProviderHealthcheckResult {
26    pub provider: String,
27    pub valid: bool,
28    pub message: String,
29    pub metadata: BTreeMap<String, JsonValue>,
30}
31
32impl ProviderHealthcheckResult {
33    fn new(
34        provider: impl Into<String>,
35        valid: bool,
36        message: impl Into<String>,
37        metadata: BTreeMap<String, JsonValue>,
38    ) -> Self {
39        Self {
40            provider: provider.into(),
41            valid,
42            message: message.into(),
43            metadata,
44        }
45    }
46}
47
48pub async fn run_provider_healthcheck(provider: &str) -> ProviderHealthcheckResult {
49    run_provider_healthcheck_with_options(provider, ProviderHealthcheckOptions::default()).await
50}
51
52pub async fn run_provider_healthcheck_with_options(
53    provider: &str,
54    options: ProviderHealthcheckOptions,
55) -> ProviderHealthcheckResult {
56    let provider = if provider.trim().is_empty() {
57        "anthropic"
58    } else {
59        provider.trim()
60    };
61
62    let Some(def) = llm_config::provider_config(provider) else {
63        let mut metadata = base_metadata("unknown_provider");
64        metadata.insert("provider".to_string(), json!(provider));
65        return ProviderHealthcheckResult::new(
66            provider,
67            false,
68            format!("Unknown provider: {provider}"),
69            metadata,
70        );
71    };
72
73    let Some(healthcheck) = def.healthcheck.as_ref() else {
74        let mut metadata = base_metadata("no_healthcheck");
75        metadata.insert("provider".to_string(), json!(provider));
76        return ProviderHealthcheckResult::new(
77            provider,
78            false,
79            format!("No healthcheck configured for {provider}"),
80            metadata,
81        );
82    };
83
84    let auth = resolve_healthcheck_auth(&def, options.api_key);
85    if auth.requires_auth && auth.api_key.is_none() {
86        let mut metadata = base_metadata("missing_credentials");
87        metadata.insert("provider".to_string(), json!(provider));
88        metadata.insert("auth_env".to_string(), json!(auth.candidates));
89        return ProviderHealthcheckResult::new(
90            provider,
91            false,
92            format!(
93                "Missing credentials for {provider}: set {} or pass an api_key",
94                auth.candidates.join(", ")
95            ),
96            metadata,
97        );
98    }
99
100    let url = build_healthcheck_url(&def, healthcheck);
101    let method = Method::from_bytes(healthcheck.method.as_bytes()).unwrap_or(Method::GET);
102    let client = match options.client {
103        Some(client) => client,
104        None => match crate::egress::install_ssrf_guard(
105            reqwest::Client::builder()
106                .timeout(Duration::from_secs(DEFAULT_HEALTHCHECK_TIMEOUT_SECS))
107                .redirect(crate::egress::redirect_policy(
108                    "llm_healthcheck_redirect",
109                    5,
110                )),
111        )
112        .build()
113        {
114            Ok(client) => client,
115            Err(error) => {
116                let mut metadata = base_metadata("client_build_failed");
117                metadata.insert("provider".to_string(), json!(provider));
118                return ProviderHealthcheckResult::new(
119                    provider,
120                    false,
121                    format!("{provider} healthcheck failed: {error}"),
122                    metadata,
123                );
124            }
125        },
126    };
127
128    let mut request = client.request(method.clone(), &url);
129    if let Some(api_key) = auth.api_key.as_deref() {
130        request = apply_auth_headers(request, api_key, Some(&def));
131    }
132    for (name, value) in &def.extra_headers {
133        request = request.header(name, value);
134    }
135    if let Some(body) = &healthcheck.body {
136        request = request
137            .header(reqwest::header::CONTENT_TYPE, "application/json")
138            .body(body.clone());
139    }
140
141    match request.send().await {
142        Ok(response) => {
143            let status = response.status();
144            let status_code = status.as_u16();
145            let valid = status.is_success();
146            let body_text = response.text().await.unwrap_or_default();
147            let mut metadata = base_metadata(if valid { "ok" } else { "http_status" });
148            metadata.insert("provider".to_string(), json!(provider));
149            metadata.insert("status".to_string(), json!(status_code));
150            metadata.insert("url".to_string(), json!(url));
151            metadata.insert("method".to_string(), json!(method.as_str()));
152            if !valid && !body_text.is_empty() {
153                metadata.insert("body".to_string(), json!(body_snippet(&body_text)));
154            }
155
156            let message = if valid {
157                format!("{provider} is reachable (HTTP {status_code})")
158            } else {
159                let suffix = body_snippet(&body_text);
160                if suffix.is_empty() {
161                    format!("{provider} returned HTTP {status_code}")
162                } else {
163                    format!("{provider} returned HTTP {status_code}: {suffix}")
164                }
165            };
166
167            ProviderHealthcheckResult::new(provider, valid, message, metadata)
168        }
169        Err(error) => {
170            let mut metadata = base_metadata("request_failed");
171            metadata.insert("provider".to_string(), json!(provider));
172            metadata.insert(
173                "url".to_string(),
174                json!(crate::egress::redact_diagnostic_text(&url)),
175            );
176            metadata.insert("method".to_string(), json!(method.as_str()));
177            let error = crate::egress::redact_reqwest_error(&error);
178            ProviderHealthcheckResult::new(
179                provider,
180                false,
181                format!("{provider} healthcheck failed: {error}"),
182                metadata,
183            )
184        }
185    }
186}
187
188pub fn build_healthcheck_url(def: &ProviderDef, healthcheck: &HealthcheckDef) -> String {
189    if let Some(url) = &healthcheck.url {
190        return url.clone();
191    }
192
193    let base = llm_config::resolve_base_url(def);
194    let path = healthcheck.path.as_deref().unwrap_or("");
195    if path.starts_with('/') {
196        format!("{}{}", base.trim_end_matches('/'), path)
197    } else if path.is_empty() {
198        base
199    } else {
200        format!("{}/{}", base.trim_end_matches('/'), path)
201    }
202}
203
204#[derive(Debug, Clone)]
205struct ResolvedHealthcheckAuth {
206    requires_auth: bool,
207    api_key: Option<String>,
208    candidates: Vec<String>,
209}
210
211fn resolve_healthcheck_auth(
212    def: &ProviderDef,
213    api_key_override: Option<String>,
214) -> ResolvedHealthcheckAuth {
215    let candidates = auth_env_candidates(&def.auth_env);
216    if def.auth_style == "none" || matches!(def.auth_env, AuthEnv::None) {
217        let api_key = api_key_override.and_then(non_empty);
218        return ResolvedHealthcheckAuth {
219            requires_auth: api_key.is_some(),
220            api_key,
221            candidates,
222        };
223    }
224
225    let api_key = api_key_override
226        .and_then(non_empty)
227        .or_else(|| resolve_api_key_from_env(&def.auth_env));
228    ResolvedHealthcheckAuth {
229        requires_auth: true,
230        api_key,
231        candidates,
232    }
233}
234
235fn auth_env_candidates(auth_env: &AuthEnv) -> Vec<String> {
236    match auth_env {
237        AuthEnv::None => Vec::new(),
238        AuthEnv::Single(env) => vec![env.clone()],
239        AuthEnv::Multiple(envs) => envs.clone(),
240    }
241}
242
243fn resolve_api_key_from_env(auth_env: &AuthEnv) -> Option<String> {
244    match auth_env {
245        AuthEnv::None => None,
246        AuthEnv::Single(env) => std::env::var(env).ok().and_then(non_empty),
247        AuthEnv::Multiple(envs) => envs
248            .iter()
249            .find_map(|env| std::env::var(env).ok().and_then(non_empty)),
250    }
251}
252
253fn non_empty(value: String) -> Option<String> {
254    let trimmed = value.trim();
255    if trimmed.is_empty() {
256        None
257    } else {
258        Some(trimmed.to_string())
259    }
260}
261
262fn base_metadata(reason: &str) -> BTreeMap<String, JsonValue> {
263    BTreeMap::from([("reason".to_string(), json!(reason))])
264}
265
266fn body_snippet(body: &str) -> String {
267    let mut snippet = String::new();
268    for ch in body.chars().take(BODY_SNIPPET_LIMIT) {
269        snippet.push(ch);
270    }
271    snippet
272}
273
274#[cfg(test)]
275mod tests {
276    use crate::http::framing::{http_content_length_from_header_lines, TEST_HTTP_MAX_BODY_BYTES};
277    use std::io::{Read, Write};
278    use std::net::TcpListener;
279    use std::sync::{Arc, Mutex};
280
281    use super::*;
282
283    fn provider_with_healthcheck(base_url: String, healthcheck: HealthcheckDef) -> ProviderDef {
284        ProviderDef {
285            base_url,
286            auth_style: "bearer".to_string(),
287            auth_env: AuthEnv::Single("HARN_TEST_PROVIDER_KEY".to_string()),
288            extra_headers: BTreeMap::from([("x-extra".to_string(), "extra-value".to_string())]),
289            chat_endpoint: "/chat/completions".to_string(),
290            healthcheck: Some(healthcheck),
291            ..Default::default()
292        }
293    }
294
295    fn install_provider(name: &str, provider: ProviderDef) {
296        let mut config = llm_config::ProvidersConfig::default();
297        config.providers.insert(name.to_string(), provider);
298        llm_config::set_user_overrides(Some(config));
299    }
300
301    fn spawn_healthcheck_stub(
302        status: u16,
303        body: &'static str,
304        captured: Arc<Mutex<Option<String>>>,
305    ) -> (String, std::thread::JoinHandle<()>) {
306        let listener = TcpListener::bind("127.0.0.1:0").expect("bind healthcheck stub");
307        let addr = listener.local_addr().expect("stub addr");
308
309        // Block on `accept()` directly. The earlier nonblocking poll +
310        // 30s wall-clock deadline introduced two failure modes under
311        // CI fan-out: (1) a 10ms tick stretched under load, delaying
312        // accept past the client's connect timeout; (2) the deadline
313        // wall-clock could elapse before the polling thread woke,
314        // panicking even though the client was already connected.
315        // Blocking accept is deterministic and cannot starve. The
316        // test always sends a request, so the accept always returns;
317        // if a test panics before sending, the leaked thread is
318        // captured by nextest's `leak-timeout = "fail"` rather than
319        // hidden behind a synthetic panic.
320        let handle = std::thread::spawn(move || {
321            let (mut stream, _) = listener
322                .accept()
323                .unwrap_or_else(|e| panic!("healthcheck stub accept failed: {e}"));
324            stream
325                .set_read_timeout(Some(std::time::Duration::from_secs(30)))
326                .ok();
327            stream
328                .set_write_timeout(Some(std::time::Duration::from_secs(30)))
329                .ok();
330
331            let mut bytes = Vec::new();
332            let mut buf = [0u8; 4096];
333            loop {
334                let n = stream.read(&mut buf).expect("read request");
335                if n == 0 {
336                    break;
337                }
338                bytes.extend_from_slice(&buf[..n]);
339                let request = String::from_utf8_lossy(&bytes);
340                if request_complete(&request) {
341                    break;
342                }
343            }
344            *captured.lock().expect("capture request") =
345                Some(String::from_utf8_lossy(&bytes).to_string());
346
347            let response = format!(
348                "HTTP/1.1 {status} OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
349                body.len()
350            );
351            stream
352                .write_all(response.as_bytes())
353                .expect("write response");
354        });
355
356        (format!("http://{addr}"), handle)
357    }
358
359    fn request_complete(request: &str) -> bool {
360        let Some((headers, body)) = request.split_once("\r\n\r\n") else {
361            return false;
362        };
363        let content_length = match http_content_length_from_header_lines(
364            headers.lines(),
365            TEST_HTTP_MAX_BODY_BYTES,
366        ) {
367            Ok(content_length) => content_length,
368            Err(_) => return true,
369        };
370        body.len() >= content_length
371    }
372
373    #[test]
374    fn request_complete_stops_on_oversized_content_length() {
375        let request = format!(
376            "POST /probe HTTP/1.1\r\nContent-Length: {}\r\n\r\n",
377            TEST_HTTP_MAX_BODY_BYTES + 1
378        );
379        assert!(request_complete(&request));
380    }
381
382    #[tokio::test(flavor = "current_thread")]
383    #[allow(clippy::await_holding_lock)]
384    async fn sends_configured_probe_request_with_candidate_key() {
385        let _guard = crate::llm::env_guard();
386        let captured = Arc::new(Mutex::new(None));
387        let (base_url, server) = spawn_healthcheck_stub(200, r#"{"ok":true}"#, captured.clone());
388        install_provider(
389            "acme",
390            provider_with_healthcheck(
391                base_url.clone(),
392                HealthcheckDef {
393                    method: "POST".to_string(),
394                    path: Some("probe".to_string()),
395                    url: None,
396                    body: Some(r#"{"ping":true}"#.to_string()),
397                },
398            ),
399        );
400
401        let result = run_provider_healthcheck_with_options(
402            "acme",
403            ProviderHealthcheckOptions {
404                api_key: Some("candidate-key".to_string()),
405                client: None,
406            },
407        )
408        .await;
409        server.join().expect("stub server");
410        llm_config::clear_user_overrides();
411
412        assert!(result.valid);
413        assert_eq!(result.provider, "acme");
414        assert_eq!(result.metadata["status"], json!(200));
415        assert_eq!(result.metadata["method"], json!("POST"));
416        assert_eq!(result.metadata["url"], json!(format!("{base_url}/probe")));
417
418        let request = captured
419            .lock()
420            .expect("captured request")
421            .clone()
422            .expect("request");
423        assert!(request.starts_with("POST /probe HTTP/1.1\r\n"));
424        assert!(request.contains("authorization: Bearer candidate-key\r\n"));
425        assert!(request.contains("x-extra: extra-value\r\n"));
426        assert!(request.ends_with(r#"{"ping":true}"#));
427    }
428
429    #[tokio::test(flavor = "current_thread")]
430    #[allow(clippy::await_holding_lock)]
431    async fn reports_missing_credentials_without_network() {
432        let _guard = crate::llm::env_guard();
433        unsafe {
434            std::env::remove_var("HARN_TEST_PROVIDER_KEY");
435        }
436        install_provider(
437            "acme-missing-key",
438            provider_with_healthcheck(
439                "http://127.0.0.1:9".to_string(),
440                HealthcheckDef {
441                    method: "GET".to_string(),
442                    path: Some("/models".to_string()),
443                    url: None,
444                    body: None,
445                },
446            ),
447        );
448
449        let result = run_provider_healthcheck("acme-missing-key").await;
450        llm_config::clear_user_overrides();
451
452        assert!(!result.valid);
453        assert_eq!(result.metadata["reason"], json!("missing_credentials"));
454        assert_eq!(
455            result.metadata["auth_env"],
456            json!(["HARN_TEST_PROVIDER_KEY"])
457        );
458        assert!(result.message.contains("Missing credentials"));
459    }
460
461    #[tokio::test(flavor = "current_thread")]
462    #[allow(clippy::await_holding_lock)]
463    async fn returns_stable_failure_shape_for_http_errors() {
464        let _guard = crate::llm::env_guard();
465        let captured = Arc::new(Mutex::new(None));
466        let (base_url, server) = spawn_healthcheck_stub(401, r#"{"error":"bad key"}"#, captured);
467        install_provider(
468            "acme-auth",
469            provider_with_healthcheck(
470                base_url,
471                HealthcheckDef {
472                    method: "GET".to_string(),
473                    path: Some("/models".to_string()),
474                    url: None,
475                    body: None,
476                },
477            ),
478        );
479
480        let result = run_provider_healthcheck_with_options(
481            "acme-auth",
482            ProviderHealthcheckOptions {
483                api_key: Some("bad-key".to_string()),
484                client: None,
485            },
486        )
487        .await;
488        server.join().expect("stub server");
489        llm_config::clear_user_overrides();
490
491        assert!(!result.valid);
492        assert_eq!(result.provider, "acme-auth");
493        assert_eq!(result.metadata["reason"], json!("http_status"));
494        assert_eq!(result.metadata["status"], json!(401));
495        assert_eq!(result.metadata["body"], json!(r#"{"error":"bad key"}"#));
496    }
497
498    #[test]
499    fn default_external_provider_catalog_has_healthchecks() {
500        for provider in [
501            "openrouter",
502            "anthropic",
503            "openai",
504            "huggingface",
505            "together",
506        ] {
507            let config = llm_config::provider_config(provider)
508                .unwrap_or_else(|| panic!("missing provider {provider}"));
509            let healthcheck = config
510                .healthcheck
511                .as_ref()
512                .unwrap_or_else(|| panic!("missing healthcheck for {provider}"));
513            assert!(!healthcheck.method.is_empty());
514            assert!(healthcheck.path.is_some() || healthcheck.url.is_some());
515        }
516    }
517}