Skip to main content

harn_vm/llm/
readiness.rs

1use serde::Serialize;
2
3use super::api::apply_auth_headers;
4use super::helpers::resolve_api_key;
5use crate::llm_config::{self, ProviderDef};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ReadinessStatus {
10    Ok,
11    UnknownProvider,
12    InvalidUrl,
13    Unreachable,
14    BadStatus,
15    BadResponse,
16    ModelMissing,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20pub struct ProviderReadiness {
21    pub provider: String,
22    pub ok: bool,
23    pub status: ReadinessStatus,
24    pub message: String,
25    pub base_url: Option<String>,
26    pub url: Option<String>,
27    pub model: Option<String>,
28    pub requested_model: Option<String>,
29    pub served_models: Vec<String>,
30    pub http_status: Option<u16>,
31}
32
33impl ProviderReadiness {
34    fn fail(
35        provider: &str,
36        status: ReadinessStatus,
37        message: String,
38        base_url: Option<String>,
39        url: Option<String>,
40        model: Option<String>,
41        requested_model: Option<String>,
42        http_status: Option<u16>,
43    ) -> Self {
44        Self {
45            provider: provider.to_string(),
46            ok: false,
47            status,
48            message,
49            base_url,
50            url,
51            model,
52            requested_model,
53            served_models: Vec::new(),
54            http_status,
55        }
56    }
57}
58
59#[derive(Debug, Clone, Copy, Default)]
60pub struct ProviderReadinessOptions<'a> {
61    pub requested_model: Option<&'a str>,
62    pub base_url_override: Option<&'a str>,
63    pub api_key_override: Option<&'a str>,
64}
65
66pub fn supports_model_readiness_probe(def: &ProviderDef) -> bool {
67    let healthcheck_uses_models = def.healthcheck.as_ref().is_some_and(|hc| {
68        hc.method.eq_ignore_ascii_case("GET") && {
69            hc.path
70                .as_deref()
71                .is_some_and(|path| path.contains("models"))
72                || hc.url.as_deref().is_some_and(|url| url.contains("models"))
73        }
74    });
75    healthcheck_uses_models || def.chat_endpoint.ends_with("/chat/completions")
76}
77
78pub fn selected_model_for_provider(provider: &str) -> Option<String> {
79    configured_model_for_provider(provider).map(|model| {
80        let (resolved, _) = llm_config::resolve_model(model.trim());
81        resolved
82    })
83}
84
85pub fn build_models_url(def: &ProviderDef) -> Result<String, String> {
86    let base_url = llm_config::resolve_base_url(def);
87    models_url(def, &base_url)
88}
89
90pub async fn probe_provider_readiness(
91    provider: &str,
92    requested_model: Option<&str>,
93    base_url_override: Option<&str>,
94) -> ProviderReadiness {
95    probe_provider_readiness_with_options(
96        provider,
97        ProviderReadinessOptions {
98            requested_model,
99            base_url_override,
100            api_key_override: None,
101        },
102    )
103    .await
104}
105
106pub async fn probe_provider_readiness_with_options(
107    provider: &str,
108    options: ProviderReadinessOptions<'_>,
109) -> ProviderReadiness {
110    let Some(def) = llm_config::provider_config(provider) else {
111        return ProviderReadiness::fail(
112            provider,
113            ReadinessStatus::UnknownProvider,
114            format!("Unknown provider: {provider}"),
115            None,
116            None,
117            options.requested_model.map(ToOwned::to_owned),
118            options.requested_model.map(ToOwned::to_owned),
119            None,
120        );
121    };
122
123    let base_url = options
124        .base_url_override
125        .filter(|value| !value.trim().is_empty())
126        .map(|value| value.trim().to_string())
127        .unwrap_or_else(|| llm_config::resolve_base_url(&def));
128    let diagnostic_base_url = crate::egress::redact_diagnostic_text(&base_url);
129    let url = match models_url(&def, &base_url) {
130        Ok(url) => url,
131        Err(message) => {
132            let message = crate::egress::redact_diagnostic_text(&message);
133            return ProviderReadiness::fail(
134                provider,
135                ReadinessStatus::InvalidUrl,
136                message,
137                Some(diagnostic_base_url),
138                None,
139                options.requested_model.map(ToOwned::to_owned),
140                options.requested_model.map(ToOwned::to_owned),
141                None,
142            );
143        }
144    };
145    let diagnostic_url = crate::egress::redact_diagnostic_text(&url);
146
147    let (raw_model, resolved_model) = options
148        .requested_model
149        .filter(|model| !model.trim().is_empty())
150        .map(|model| {
151            let trimmed = model.trim();
152            let (resolved, _) = llm_config::resolve_model(trimmed);
153            (Some(trimmed.to_string()), Some(resolved))
154        })
155        .unwrap_or_else(|| match configured_model_for_provider(provider) {
156            Some(model) => {
157                let (resolved, _) = llm_config::resolve_model(&model);
158                (Some(model), Some(resolved))
159            }
160            None => (None, None),
161        });
162
163    let client = super::shared_utility_client();
164    let api_key = options
165        .api_key_override
166        .filter(|value| !value.trim().is_empty())
167        .map(|value| value.trim().to_string())
168        .unwrap_or_else(|| resolve_api_key(provider).unwrap_or_default());
169    let request = client.get(&url).header("Content-Type", "application/json");
170    let request = apply_auth_headers(request, &api_key, Some(&def));
171    let request = def
172        .extra_headers
173        .iter()
174        .fold(request, |request, (name, value)| {
175            request.header(name.as_str(), value.as_str())
176        });
177
178    let response = match request.send().await {
179        Ok(response) => response,
180        Err(error) => {
181            let error = crate::egress::redact_reqwest_error(&error);
182            return ProviderReadiness::fail(
183                provider,
184                ReadinessStatus::Unreachable,
185                format!("{provider} server is not reachable at {diagnostic_base_url}: {error}"),
186                Some(diagnostic_base_url),
187                Some(diagnostic_url),
188                resolved_model,
189                raw_model,
190                None,
191            );
192        }
193    };
194
195    let http_status = response.status().as_u16();
196    if !response.status().is_success() {
197        return ProviderReadiness::fail(
198            provider,
199            ReadinessStatus::BadStatus,
200            format!("{provider} returned HTTP {http_status} at {diagnostic_url}"),
201            Some(diagnostic_base_url),
202            Some(diagnostic_url),
203            resolved_model,
204            raw_model,
205            Some(http_status),
206        );
207    }
208
209    let body = match response.text().await {
210        Ok(body) => body,
211        Err(error) => {
212            let error = crate::egress::redact_reqwest_error(&error);
213            return ProviderReadiness::fail(
214                provider,
215                ReadinessStatus::BadResponse,
216                format!("{provider} returned an unreadable /models response: {error}"),
217                Some(diagnostic_base_url),
218                Some(diagnostic_url),
219                resolved_model,
220                raw_model,
221                Some(http_status),
222            );
223        }
224    };
225    let served_models = match parse_model_ids(&body) {
226        Ok(models) if !models.is_empty() => models,
227        Ok(_) => {
228            return ProviderReadiness::fail(
229                provider,
230                ReadinessStatus::BadResponse,
231                format!("{provider} /models response did not include any model ids"),
232                Some(diagnostic_base_url),
233                Some(diagnostic_url),
234                resolved_model,
235                raw_model,
236                Some(http_status),
237            );
238        }
239        Err(error) => {
240            return ProviderReadiness::fail(
241                provider,
242                ReadinessStatus::BadResponse,
243                format!("{provider} returned an unparsable /models response: {error}"),
244                Some(diagnostic_base_url),
245                Some(diagnostic_url),
246                resolved_model,
247                raw_model,
248                Some(http_status),
249            );
250        }
251    };
252
253    let readiness_model = resolved_model.as_deref().map(llm_config::wire_model_id);
254
255    if let Some(model) = readiness_model.as_deref() {
256        if !model_is_served(model, &served_models) {
257            let display_model = resolved_model.as_deref().unwrap_or(model);
258            let model_label = if display_model == model {
259                format!("Model '{display_model}'")
260            } else {
261                format!("Model '{display_model}' (wire model '{model}')")
262            };
263            return ProviderReadiness {
264                provider: provider.to_string(),
265                ok: false,
266                status: ReadinessStatus::ModelMissing,
267                message: format!(
268                    "{model_label} is not served by {provider} at {diagnostic_base_url}. Currently served: {}",
269                    served_models.join(", ")
270                ),
271                base_url: Some(diagnostic_base_url),
272                url: Some(diagnostic_url),
273                model: resolved_model,
274                requested_model: raw_model,
275                served_models,
276                http_status: Some(http_status),
277            };
278        }
279    }
280
281    let message = match (resolved_model.as_deref(), readiness_model.as_deref()) {
282        (Some(model), Some(wire_model)) if model != wire_model => format!(
283            "{provider} is ready at {diagnostic_base_url}; model '{model}' is served as '{wire_model}'"
284        ),
285        (Some(model), _) => {
286            format!("{provider} is ready at {diagnostic_base_url}; model '{model}' is served")
287        }
288        (None, _) => format!(
289            "{provider} is reachable at {diagnostic_base_url}; served models: {}",
290            served_models.join(", ")
291        ),
292    };
293
294    ProviderReadiness {
295        provider: provider.to_string(),
296        ok: true,
297        status: ReadinessStatus::Ok,
298        message,
299        base_url: Some(diagnostic_base_url),
300        url: Some(diagnostic_url),
301        model: resolved_model,
302        requested_model: raw_model,
303        served_models,
304        http_status: Some(http_status),
305    }
306}
307
308pub fn parse_model_ids(body: &str) -> Result<Vec<String>, serde_json::Error> {
309    let payload: serde_json::Value = serde_json::from_str(body)?;
310    Ok(parse_model_ids_from_value(&payload))
311}
312
313pub fn parse_model_ids_from_value(payload: &serde_json::Value) -> Vec<String> {
314    let mut models = Vec::new();
315    if let Some(entries) = payload.as_array() {
316        collect_model_ids(entries, &mut models);
317    }
318    if let Some(entries) = payload.get("data").and_then(|value| value.as_array()) {
319        collect_model_ids(entries, &mut models);
320    }
321    if let Some(entries) = payload.get("models").and_then(|value| value.as_array()) {
322        collect_model_ids(entries, &mut models);
323    }
324    models.sort();
325    models.dedup();
326    models
327}
328
329fn collect_model_ids(entries: &[serde_json::Value], models: &mut Vec<String>) {
330    for entry in entries {
331        if let Some(id) = entry.as_str().or_else(|| {
332            entry
333                .get("id")
334                .or_else(|| entry.get("name"))
335                .and_then(|value| value.as_str())
336        }) {
337            models.push(id.to_string());
338        }
339    }
340}
341
342pub fn model_is_served(model: &str, served_models: &[String]) -> bool {
343    served_models
344        .iter()
345        .any(|served| served == model || served.starts_with(model))
346}
347
348pub fn configured_model_for_provider(provider: &str) -> Option<String> {
349    if provider == "mlx" {
350        if let Ok(model) = std::env::var("MLX_MODEL_ID") {
351            if !model.trim().is_empty() {
352                return Some(model);
353            }
354        }
355    }
356    if provider == "local" {
357        if let Ok(model) = std::env::var("LOCAL_LLM_MODEL") {
358            if !model.trim().is_empty() {
359                return Some(model);
360            }
361        }
362    }
363    let harn_provider = std::env::var("HARN_LLM_PROVIDER").ok();
364    let model = std::env::var("HARN_LLM_MODEL")
365        .ok()
366        .filter(|model| !model.trim().is_empty())?;
367    let (_, resolved_provider) = llm_config::resolve_model(&model);
368    if resolved_provider.as_deref() == Some(provider)
369        || (resolved_provider.is_none() && harn_provider.as_deref() == Some(provider))
370    {
371        return Some(model);
372    }
373    None
374}
375
376fn models_url(def: &ProviderDef, base_url: &str) -> Result<String, String> {
377    if let Some(url) = def.healthcheck.as_ref().and_then(|healthcheck| {
378        if healthcheck.method.eq_ignore_ascii_case("GET") {
379            healthcheck
380                .url
381                .as_deref()
382                .filter(|url| url.contains("models"))
383        } else {
384            None
385        }
386    }) {
387        return reqwest::Url::parse(url)
388            .map(|_| normalize_loopback(url))
389            .map_err(|error| format!("Invalid provider models URL '{url}': {error}"));
390    }
391
392    let path = def
393        .healthcheck
394        .as_ref()
395        .and_then(|healthcheck| {
396            if healthcheck.method.eq_ignore_ascii_case("GET") {
397                healthcheck
398                    .path
399                    .as_deref()
400                    .filter(|path| path.contains("models"))
401            } else {
402                None
403            }
404        })
405        .map(ToOwned::to_owned)
406        .unwrap_or_else(|| model_path_from_chat_endpoint(&def.chat_endpoint));
407    let url = join_base_and_path(base_url, &path);
408    reqwest::Url::parse(&url)
409        .map(|_| normalize_loopback(&url))
410        .map_err(|error| format!("Invalid provider models URL '{url}': {error}"))
411}
412
413fn model_path_from_chat_endpoint(chat_endpoint: &str) -> String {
414    if let Some(prefix) = chat_endpoint.strip_suffix("/chat/completions") {
415        if prefix.is_empty() {
416            "/models".to_string()
417        } else {
418            format!("{prefix}/models")
419        }
420    } else {
421        "/v1/models".to_string()
422    }
423}
424
425fn join_base_and_path(base: &str, path: &str) -> String {
426    let base = base.trim_end_matches('/');
427    if path.is_empty() {
428        base.to_string()
429    } else if path.starts_with('/') {
430        format!("{base}{path}")
431    } else {
432        format!("{base}/{path}")
433    }
434}
435
436fn normalize_loopback(url: &str) -> String {
437    url.replace("://localhost:", "://127.0.0.1:")
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use std::io::{Read, Write};
444    use std::net::TcpListener;
445
446    #[test]
447    fn parse_model_ids_reads_openai_compatible_data() {
448        let models =
449            parse_model_ids(r#"{"object":"list","data":[{"id":"qwen"},{"id":"mlx-model"}]}"#)
450                .expect("parse models");
451        assert_eq!(models, vec!["mlx-model".to_string(), "qwen".to_string()]);
452    }
453
454    #[test]
455    fn parse_model_ids_reads_together_top_level_array() {
456        let models = parse_model_ids(r#"[{"id":"deepseek-ai/DeepSeek-V4-Pro"},{"name":"qwen"}]"#)
457            .expect("parse models");
458        assert_eq!(
459            models,
460            vec![
461                "deepseek-ai/DeepSeek-V4-Pro".to_string(),
462                "qwen".to_string()
463            ]
464        );
465    }
466
467    #[test]
468    fn models_url_does_not_duplicate_version_prefix_in_base_url() {
469        let def = ProviderDef {
470            base_url: "https://openrouter.ai/api/v1".to_string(),
471            chat_endpoint: "/chat/completions".to_string(),
472            healthcheck: Some(crate::llm_config::HealthcheckDef {
473                method: "GET".to_string(),
474                path: Some("/auth/key".to_string()),
475                url: None,
476                body: None,
477            }),
478            ..Default::default()
479        };
480
481        assert_eq!(
482            models_url(&def, &def.base_url).expect("models url"),
483            "https://openrouter.ai/api/v1/models"
484        );
485    }
486
487    #[test]
488    fn models_url_keeps_openai_compatible_fallback_for_native_chat_endpoint() {
489        let def = ProviderDef {
490            base_url: "http://localhost:11434".to_string(),
491            chat_endpoint: "/api/chat".to_string(),
492            healthcheck: Some(crate::llm_config::HealthcheckDef {
493                method: "GET".to_string(),
494                path: Some("/api/tags".to_string()),
495                url: None,
496                body: None,
497            }),
498            ..Default::default()
499        };
500
501        assert_eq!(
502            models_url(&def, &def.base_url).expect("models url"),
503            "http://127.0.0.1:11434/v1/models"
504        );
505    }
506
507    #[test]
508    fn model_is_served_accepts_exact_or_prefix() {
509        let models = vec!["unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit".to_string()];
510        assert!(model_is_served(
511            "unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit",
512            &models
513        ));
514        assert!(model_is_served("unsloth/Qwen3.6", &models));
515        assert!(!model_is_served("Qwen/Qwen3.6-35B", &models));
516    }
517
518    #[tokio::test]
519    async fn probe_provider_readiness_verifies_served_model() {
520        let (base_url, handle) = spawn_models_stub(
521            200,
522            r#"{"data":[{"id":"unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit"}]}"#,
523        );
524        let result = probe_provider_readiness("mlx", Some("mlx-qwen36-27b"), Some(&base_url)).await;
525        handle.join().expect("stub joins");
526        assert!(result.ok);
527        assert_eq!(result.status, ReadinessStatus::Ok);
528        assert_eq!(
529            result.model.as_deref(),
530            Some("unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit")
531        );
532    }
533
534    #[tokio::test]
535    async fn probe_provider_readiness_verifies_wire_model_for_catalog_key() {
536        let (base_url, handle) = spawn_models_stub(200, r#"{"data":[{"id":"zai-org/GLM-5.2"}]}"#);
537        let result = probe_provider_readiness(
538            "deepinfra",
539            Some("deepinfra/zai-org/GLM-5.2"),
540            Some(&base_url),
541        )
542        .await;
543        handle.join().expect("stub joins");
544        assert!(result.ok, "{}", result.message);
545        assert_eq!(result.status, ReadinessStatus::Ok);
546        assert_eq!(result.model.as_deref(), Some("deepinfra/zai-org/GLM-5.2"));
547        assert!(result.message.contains("served as 'zai-org/GLM-5.2'"));
548    }
549
550    #[tokio::test]
551    async fn probe_provider_readiness_uses_explicit_api_key_override() {
552        let (base_url, handle) = spawn_models_stub_with_expected_header(
553            200,
554            r#"{"data":[{"id":"zai-org/GLM-5.2"}]}"#,
555            Some("authorization: Bearer test-key"),
556        );
557        let result = probe_provider_readiness_with_options(
558            "deepinfra",
559            ProviderReadinessOptions {
560                requested_model: Some("deepinfra/zai-org/GLM-5.2"),
561                base_url_override: Some(&base_url),
562                api_key_override: Some("test-key"),
563            },
564        )
565        .await;
566        handle.join().expect("stub joins");
567        assert!(result.ok, "{}", result.message);
568    }
569
570    #[tokio::test]
571    async fn probe_provider_readiness_reports_missing_model() {
572        let (base_url, handle) = spawn_models_stub(200, r#"{"data":[{"id":"other-model"}]}"#);
573        let result = probe_provider_readiness("mlx", Some("mlx-qwen36-27b"), Some(&base_url)).await;
574        handle.join().expect("stub joins");
575        assert!(!result.ok);
576        assert_eq!(result.status, ReadinessStatus::ModelMissing);
577        assert!(result.message.contains("Currently served: other-model"));
578    }
579
580    fn spawn_models_stub(status: u16, body: &'static str) -> (String, std::thread::JoinHandle<()>) {
581        spawn_models_stub_with_expected_header(status, body, None)
582    }
583
584    fn spawn_models_stub_with_expected_header(
585        status: u16,
586        body: &'static str,
587        expected_header: Option<&'static str>,
588    ) -> (String, std::thread::JoinHandle<()>) {
589        let listener = TcpListener::bind("127.0.0.1:0").expect("bind models stub");
590        let addr = listener.local_addr().expect("stub addr");
591        // Block on `accept()` directly rather than polling. The
592        // earlier 3s wall-clock deadline + 20ms polling sleep was
593        // brittle under nextest's flake-detection profile: another
594        // concurrent test could starve this thread of CPU long
595        // enough for the deadline to elapse before the kernel even
596        // delivered the SYN that the client had already sent.
597        // Blocking accept is deterministic; the test invariably
598        // sends a request, so it returns promptly.
599        let handle = std::thread::spawn(move || {
600            let (mut stream, _) = listener
601                .accept()
602                .unwrap_or_else(|e| panic!("models stub: accept failed: {e}"));
603            let mut buf = vec![0u8; 4096];
604            let n = stream.read(&mut buf).expect("read request");
605            let request = String::from_utf8_lossy(&buf[..n]);
606            assert!(
607                request.starts_with("GET /v1/models HTTP/1.1\r\n")
608                    || request.starts_with("GET /models HTTP/1.1\r\n")
609            );
610            if let Some(header) = expected_header {
611                assert!(
612                    request
613                        .lines()
614                        .any(|line| line.eq_ignore_ascii_case(header)),
615                    "expected request header {header:?}, got:\n{request}"
616                );
617            }
618            let response = format!(
619                "HTTP/1.1 {status} OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
620                body.len(),
621                body
622            );
623            stream
624                .write_all(response.as_bytes())
625                .expect("write response");
626        });
627        (format!("http://{addr}"), handle)
628    }
629}