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 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}