mod managed;
mod probe;
mod startup;
pub use probe::fetch_llamacpp_models;
use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use super::common::resolve_model;
use crate::client::LLMClient;
use crate::error_display;
use crate::provider::{LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, Message};
use crate::providers::common::override_base_url;
use vtcode_config::TimeoutsConfig;
use vtcode_config::constants::{env_vars, models, urls};
use vtcode_config::core::{AnthropicConfig, ModelConfig, PromptCachingConfig};
const LLAMACPP_CONNECTION_ERROR: &str = "llama.cpp is not responding. Install from https://llama.app and either start `llama-server -m /path/to/model.gguf --port 8080` yourself or set LLAMACPP_MODEL_PATH so VT Code can manage startup.";
pub struct LlamaCppProvider {
inner: Box<dyn LLMProvider>,
api_key: Option<String>,
configured_model: Option<String>,
model_id: String,
base_url: String,
prompt_cache: Option<PromptCachingConfig>,
timeouts: Option<TimeoutsConfig>,
anthropic: Option<AnthropicConfig>,
model_behavior: Option<ModelConfig>,
}
impl LlamaCppProvider {
fn resolve_base_url(base_url: Option<String>) -> String {
override_base_url(urls::LLAMACPP_API_BASE, base_url, Some(env_vars::LLAMACPP_BASE_URL))
}
fn build_inner(
api_key: Option<String>,
model: Option<String>,
base_url: Option<String>,
prompt_cache: Option<PromptCachingConfig>,
timeouts: Option<TimeoutsConfig>,
anthropic: Option<AnthropicConfig>,
model_behavior: Option<ModelConfig>,
) -> (Box<dyn LLMProvider>, String) {
let resolved_model = resolve_model(model, models::llamacpp::DEFAULT_MODEL);
let resolved_base = Self::resolve_base_url(base_url);
let inner = Box::new(crate::providers::OpenAIProvider::from_config(
api_key,
None,
Some(resolved_model.clone()),
Some(resolved_base),
prompt_cache,
timeouts,
anthropic,
None,
model_behavior,
));
(inner, resolved_model)
}
fn should_replace_request_model(&self, request_model: &str, discovered_models: &[String]) -> bool {
let trimmed = request_model.trim();
if trimmed.is_empty() || looks_like_local_model_path(trimmed) {
return true;
}
if discovered_models.len() == 1 {
let configured = self.configured_model.as_deref().map(str::trim).unwrap_or_default();
if trimmed == models::llamacpp::DEFAULT_MODEL || trimmed == configured {
return true;
}
}
!discovered_models.iter().any(|model| model == trimmed) && discovered_models.len() == 1
}
fn request_model_or_default(&self, request_model: &str) -> String {
let trimmed = request_model.trim();
if trimmed.is_empty() {
resolve_model(self.configured_model.clone(), models::llamacpp::DEFAULT_MODEL)
} else {
trimmed.to_string()
}
}
fn build_request_provider(&self, model: String) -> Box<dyn LLMProvider> {
Self::build_inner(
self.api_key.clone(),
Some(model),
Some(self.base_url.clone()),
self.prompt_cache.clone(),
self.timeouts.clone(),
self.anthropic.clone(),
self.model_behavior.clone(),
)
.0
}
async fn prepare_request(&self, mut request: LLMRequest) -> Result<(Box<dyn LLMProvider>, LLMRequest), LLMError> {
let discovered_model = managed::ensure_server_ready(&self.base_url, self.configured_model.as_deref()).await?;
let discovered_models = vec![discovered_model.clone()];
if self.should_replace_request_model(&request.model, &discovered_models) || request.model.trim().is_empty() {
request.model = discovered_model.clone();
} else {
request.model = self.request_model_or_default(&request.model);
}
Ok((self.build_request_provider(request.model.clone()), request))
}
pub fn from_config(
api_key: Option<String>,
model: Option<String>,
base_url: Option<String>,
prompt_cache: Option<PromptCachingConfig>,
timeouts: Option<TimeoutsConfig>,
anthropic: Option<AnthropicConfig>,
model_behavior: Option<ModelConfig>,
) -> Self {
let resolved_base_url = Self::resolve_base_url(base_url.clone());
let (inner, model_id) = Self::build_inner(
api_key.clone(),
model.clone(),
base_url,
prompt_cache.clone(),
timeouts.clone(),
anthropic.clone(),
model_behavior.clone(),
);
Self {
inner,
api_key,
configured_model: model,
model_id,
base_url: resolved_base_url,
prompt_cache,
timeouts,
anthropic,
model_behavior,
}
}
}
#[async_trait]
impl LLMProvider for LlamaCppProvider {
fn name(&self) -> &str {
"llamacpp"
}
fn supports_streaming(&self) -> bool {
self.inner.supports_streaming()
}
fn supports_reasoning(&self, model: &str) -> bool {
self.inner.supports_reasoning(model)
}
fn supports_reasoning_effort(&self, model: &str) -> bool {
self.inner.supports_reasoning_effort(model)
}
fn supports_tools(&self, model: &str) -> bool {
self.inner.supports_tools(model)
}
fn supports_parallel_tool_config(&self, model: &str) -> bool {
self.inner.supports_parallel_tool_config(model)
}
async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
let (provider, request) = self.prepare_request(request).await?;
provider.generate(request).await
}
async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
let (provider, request) = self.prepare_request(request).await?;
provider.stream(request).await
}
fn supported_models(&self) -> Vec<String> {
models::llamacpp::SUPPORTED_MODELS
.iter()
.map(|model| model.to_string())
.collect()
}
fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
if request.messages.is_empty() {
let formatted_error = error_display::format_llm_error("llama.cpp", "Messages cannot be empty");
return Err(LLMError::InvalidRequest { message: formatted_error, metadata: None });
}
for message in request.messages.iter() {
if let Err(err) = message.validate_for_provider("openai") {
let formatted = error_display::format_llm_error("llama.cpp", &err);
return Err(LLMError::InvalidRequest { message: formatted, metadata: None });
}
}
Ok(())
}
}
#[async_trait]
impl LLMClient for LlamaCppProvider {
async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
LLMProvider::generate(
self,
LLMRequest {
messages: Arc::new(vec![Message::user(prompt.to_string())]),
model: self
.configured_model
.clone()
.unwrap_or_else(|| models::llamacpp::DEFAULT_MODEL.to_string()),
..Default::default()
},
)
.await
}
fn model_id(&self) -> &str {
&self.model_id
}
}
pub(super) fn looks_like_local_model_path(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.is_empty() {
return false;
}
trimmed.ends_with(".gguf")
|| trimmed.contains(std::path::MAIN_SEPARATOR)
|| trimmed.contains('/')
|| trimmed.starts_with('.')
|| Path::new(trimmed).exists()
}
#[cfg(test)]
mod tests {
use super::looks_like_local_model_path;
#[test]
fn empty_input_is_not_a_path() {
assert!(!looks_like_local_model_path(""));
assert!(!looks_like_local_model_path(" "));
}
#[test]
fn gguf_extension_is_local() {
assert!(looks_like_local_model_path("qwen.gguf"));
assert!(!looks_like_local_model_path("QWEN.GGUF"));
}
#[test]
fn paths_with_separator_are_local() {
assert!(looks_like_local_model_path("/abs/path/model.gguf"));
assert!(looks_like_local_model_path("models/qwen"));
assert!(looks_like_local_model_path("./relative/model"));
}
#[test]
fn leading_dot_is_local() {
assert!(looks_like_local_model_path("./model"));
assert!(looks_like_local_model_path(".hidden"));
}
#[test]
fn bare_model_ids_are_not_local() {
assert!(!looks_like_local_model_path("qwen2.5-7b"));
assert!(!looks_like_local_model_path("gpt-4o-mini"));
}
#[test]
fn existing_filesystem_path_is_local() {
let path = env!("CARGO_MANIFEST_DIR").to_string() + "/Cargo.toml";
assert!(looks_like_local_model_path(&path));
}
}