Skip to main content

vtcode_llm/providers/llamacpp/
probe.rs

1//! HTTP model discovery and server health probing for llama.cpp.
2//!
3//! Extracted verbatim from the original monolithic `llamacpp.rs`. Owns the
4//! `/v1/models` fetch, the `ServerProbe` result enum, and the `/health` probe
5//! loop that classifies a server as ready / loading / unavailable.
6
7use serde::{Deserialize, Serialize};
8use std::time::Duration;
9
10use crate::providers::common::override_base_url;
11use crate::providers::ollama::base_url_to_host_root;
12
13use vtcode_config::constants::{env_vars, urls};
14
15use super::LLAMACPP_CONNECTION_ERROR;
16
17#[derive(Debug, Deserialize, Serialize)]
18struct LlamaCppModelsResponse {
19    data: Vec<LlamaCppModel>,
20}
21
22#[derive(Debug, Deserialize, Serialize)]
23struct LlamaCppModel {
24    id: String,
25}
26
27#[derive(Debug)]
28pub(super) enum ServerProbe {
29    Ready(String),
30    Loading,
31    Unavailable(String),
32}
33
34pub async fn fetch_llamacpp_models(base_url: Option<String>) -> Result<Vec<String>, anyhow::Error> {
35    let resolved_base_url = override_base_url(urls::LLAMACPP_API_BASE, base_url, Some(env_vars::LLAMACPP_BASE_URL));
36    let models_url = format!("{}/models", resolved_base_url.trim_end_matches('/'));
37    let client = vtcode_commons::http::create_client_with_timeout(Duration::from_secs(5));
38    let response = client
39        .get(&models_url)
40        .header("Content-Type", "application/json")
41        .send()
42        .await
43        .map_err(|e| {
44            // Connection refused is expected when llama.cpp isn't running —
45            // debug-level to keep startup quiet. Error is still propagated.
46            tracing::debug!("Failed to connect to llama.cpp server: {e:?}");
47            anyhow::anyhow!(LLAMACPP_CONNECTION_ERROR)
48        })?;
49
50    if !response.status().is_success() {
51        return Err(anyhow::anyhow!(
52            "Failed to fetch llama.cpp models: HTTP {}. {}",
53            response.status(),
54            if response.status() == reqwest::StatusCode::NOT_FOUND {
55                "Ensure llama-server is running and exposing the OpenAI-compatible /v1 API."
56            } else {
57                ""
58            }
59        ));
60    }
61
62    let models_response: LlamaCppModelsResponse = response
63        .json()
64        .await
65        .map_err(|e| anyhow::anyhow!("Failed to parse llama.cpp models response: {e}"))?;
66
67    Ok(models_response.data.into_iter().map(|model| model.id).collect())
68}
69
70pub(super) async fn probe_server(base_url: &str) -> ServerProbe {
71    let host_root = base_url_to_host_root(base_url);
72    let health_url = format!("{}/health", host_root.trim_end_matches('/'));
73    let client = vtcode_commons::http::create_client_with_timeout(Duration::from_secs(5));
74
75    let response = match client.get(&health_url).send().await {
76        Ok(response) => response,
77        Err(error) => {
78            tracing::debug!("llama.cpp health probe failed for {health_url}: {error}");
79            return ServerProbe::Unavailable(LLAMACPP_CONNECTION_ERROR.to_string());
80        }
81    };
82
83    if response.status().is_success() {
84        return probe_reported_model(base_url).await;
85    }
86
87    let status = response.status();
88    let body = crate::providers::common::read_provider_error_body(response).await;
89    if status == reqwest::StatusCode::SERVICE_UNAVAILABLE && body.to_ascii_lowercase().contains("loading") {
90        return ServerProbe::Loading;
91    }
92
93    if status == reqwest::StatusCode::NOT_FOUND {
94        return probe_reported_model(base_url).await;
95    }
96
97    ServerProbe::Unavailable(format!(
98        "llama.cpp health check failed with HTTP {}{}",
99        status,
100        if body.trim().is_empty() {
101            String::new()
102        } else {
103            format!(": {}", body.trim())
104        }
105    ))
106}
107
108/// Fetch `/v1/models` and classify the result. Extracted from the two
109/// duplicated inline blocks in `probe_server` (HTTP 200 success path and the
110/// 404 fallback path), which were byte-identical aside from their call site.
111async fn probe_reported_model(base_url: &str) -> ServerProbe {
112    match fetch_llamacpp_models(Some(base_url.to_string())).await {
113        Ok(models) if !models.is_empty() => ServerProbe::Ready(models[0].clone()),
114        Ok(_) => ServerProbe::Unavailable(
115            "llama.cpp is running but did not report any loaded models from /v1/models.".to_string(),
116        ),
117        Err(error) => ServerProbe::Unavailable(error.to_string()),
118    }
119}