vtcode_llm/providers/llamacpp/
mod.rs1mod managed;
14mod probe;
15mod startup;
16
17pub use probe::fetch_llamacpp_models;
18
19use std::path::Path;
20use std::sync::Arc;
21
22use anyhow::Result;
23use async_trait::async_trait;
24
25use super::OpenAIProvider;
26use super::common::resolve_model;
27use crate::client::LLMClient;
28use crate::error_display;
29use crate::provider::{LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, Message};
30use crate::providers::common::override_base_url;
31
32use vtcode_config::TimeoutsConfig;
33use vtcode_config::constants::{env_vars, models, urls};
34use vtcode_config::core::{AnthropicConfig, ModelConfig, PromptCachingConfig};
35
36const 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.";
37
38pub struct LlamaCppProvider {
39 inner: OpenAIProvider,
49 api_key: Option<String>,
50 configured_model: Option<String>,
51 model_id: String,
52 base_url: String,
53 prompt_cache: Option<PromptCachingConfig>,
54 timeouts: Option<TimeoutsConfig>,
55 anthropic: Option<AnthropicConfig>,
56 model_behavior: Option<ModelConfig>,
57}
58
59impl LlamaCppProvider {
60 fn resolve_base_url(base_url: Option<String>) -> String {
61 override_base_url(urls::LLAMACPP_API_BASE, base_url, Some(env_vars::LLAMACPP_BASE_URL))
62 }
63
64 fn build_inner(
65 api_key: Option<String>,
66 model: Option<String>,
67 base_url: Option<String>,
68 prompt_cache: Option<PromptCachingConfig>,
69 timeouts: Option<TimeoutsConfig>,
70 anthropic: Option<AnthropicConfig>,
71 model_behavior: Option<ModelConfig>,
72 ) -> (OpenAIProvider, String) {
73 let resolved_model = resolve_model(model, models::llamacpp::DEFAULT_MODEL);
74 let resolved_base = Self::resolve_base_url(base_url);
75 let inner = OpenAIProvider::from_config(
76 api_key,
77 None,
78 Some(resolved_model.clone()),
79 Some(resolved_base),
80 prompt_cache,
81 timeouts,
82 anthropic,
83 None,
84 model_behavior,
85 );
86 (inner, resolved_model)
87 }
88
89 fn should_replace_request_model(&self, request_model: &str, discovered_models: &[String]) -> bool {
90 let trimmed = request_model.trim();
91 if trimmed.is_empty() || looks_like_local_model_path(trimmed) {
92 return true;
93 }
94
95 if discovered_models.len() == 1 {
96 let configured = self.configured_model.as_deref().map(str::trim).unwrap_or_default();
97 if trimmed == models::llamacpp::DEFAULT_MODEL || trimmed == configured {
98 return true;
99 }
100 }
101
102 !discovered_models.iter().any(|model| model == trimmed) && discovered_models.len() == 1
103 }
104
105 fn request_model_or_default(&self, request_model: &str) -> String {
106 let trimmed = request_model.trim();
107 if trimmed.is_empty() {
108 resolve_model(self.configured_model.clone(), models::llamacpp::DEFAULT_MODEL)
109 } else {
110 trimmed.to_string()
111 }
112 }
113
114 fn build_request_provider(&self, model: String) -> OpenAIProvider {
115 Self::build_inner(
116 self.api_key.clone(),
117 Some(model),
118 Some(self.base_url.clone()),
119 self.prompt_cache.clone(),
120 self.timeouts.clone(),
121 self.anthropic.clone(),
122 self.model_behavior.clone(),
123 )
124 .0
125 }
126
127 async fn prepare_request(&self, mut request: LLMRequest) -> Result<(OpenAIProvider, LLMRequest), LLMError> {
128 let discovered_model = managed::ensure_server_ready(&self.base_url, self.configured_model.as_deref()).await?;
129 let discovered_models = vec![discovered_model.clone()];
130
131 if self.should_replace_request_model(&request.model, &discovered_models) || request.model.trim().is_empty() {
132 request.model = discovered_model.clone();
133 } else {
134 request.model = self.request_model_or_default(&request.model);
135 }
136
137 Ok((self.build_request_provider(request.model.clone()), request))
138 }
139
140 pub fn from_config(
141 api_key: Option<String>,
142 model: Option<String>,
143 base_url: Option<String>,
144 prompt_cache: Option<PromptCachingConfig>,
145 timeouts: Option<TimeoutsConfig>,
146 anthropic: Option<AnthropicConfig>,
147 model_behavior: Option<ModelConfig>,
148 ) -> Self {
149 let resolved_base_url = Self::resolve_base_url(base_url.clone());
150 let (inner, model_id) = Self::build_inner(
151 api_key.clone(),
152 model.clone(),
153 base_url,
154 prompt_cache.clone(),
155 timeouts.clone(),
156 anthropic.clone(),
157 model_behavior.clone(),
158 );
159 Self {
160 inner,
161 api_key,
162 configured_model: model,
163 model_id,
164 base_url: resolved_base_url,
165 prompt_cache,
166 timeouts,
167 anthropic,
168 model_behavior,
169 }
170 }
171}
172
173#[async_trait]
174impl LLMProvider for LlamaCppProvider {
175 fn name(&self) -> &str {
176 "llamacpp"
177 }
178
179 fn supports_streaming(&self) -> bool {
180 self.inner.supports_streaming()
181 }
182
183 fn supports_non_streaming(&self, model: &str) -> bool {
184 self.inner.supports_non_streaming(model)
186 }
187
188 fn supports_reasoning(&self, model: &str) -> bool {
189 self.inner.supports_reasoning(model)
190 }
191
192 fn supports_reasoning_effort(&self, model: &str) -> bool {
193 self.inner.supports_reasoning_effort(model)
194 }
195
196 fn supports_tools(&self, model: &str) -> bool {
197 self.inner.supports_tools(model)
198 }
199
200 fn supports_parallel_tool_config(&self, model: &str) -> bool {
201 self.inner.supports_parallel_tool_config(model)
202 }
203
204 async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
205 let (provider, request) = self.prepare_request(request).await?;
206 provider.generate(request).await
207 }
208
209 async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
210 let (provider, request) = self.prepare_request(request).await?;
211 provider.stream(request).await
212 }
213
214 fn supported_models(&self) -> Vec<String> {
215 models::llamacpp::SUPPORTED_MODELS
216 .iter()
217 .map(|model| model.to_string())
218 .collect()
219 }
220
221 fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
222 if request.messages.is_empty() {
223 let formatted_error = error_display::format_llm_error("llama.cpp", "Messages cannot be empty");
224 return Err(LLMError::InvalidRequest { message: formatted_error, metadata: None });
225 }
226
227 for message in request.messages.iter() {
228 if let Err(err) = message.validate_for_provider("openai") {
229 let formatted = error_display::format_llm_error("llama.cpp", &err);
230 return Err(LLMError::InvalidRequest { message: formatted, metadata: None });
231 }
232 }
233
234 Ok(())
235 }
236}
237
238#[async_trait]
239impl LLMClient for LlamaCppProvider {
240 async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
241 LLMProvider::generate(
242 self,
243 LLMRequest {
244 messages: Arc::new(vec![Message::user(prompt.to_string())]),
245 model: self
246 .configured_model
247 .clone()
248 .unwrap_or_else(|| models::llamacpp::DEFAULT_MODEL.to_string()),
249 ..Default::default()
250 },
251 )
252 .await
253 }
254
255 fn model_id(&self) -> &str {
256 &self.model_id
257 }
258}
259
260pub(super) fn looks_like_local_model_path(value: &str) -> bool {
265 let trimmed = value.trim();
266 if trimmed.is_empty() {
267 return false;
268 }
269
270 trimmed.ends_with(".gguf")
271 || trimmed.contains(std::path::MAIN_SEPARATOR)
272 || trimmed.contains('/')
273 || trimmed.starts_with('.')
274 || Path::new(trimmed).exists()
275}
276
277#[cfg(test)]
278mod tests {
279 use super::looks_like_local_model_path;
280
281 #[test]
282 fn empty_input_is_not_a_path() {
283 assert!(!looks_like_local_model_path(""));
284 assert!(!looks_like_local_model_path(" "));
285 }
286
287 #[test]
288 fn gguf_extension_is_local() {
289 assert!(looks_like_local_model_path("qwen.gguf"));
290 assert!(!looks_like_local_model_path("QWEN.GGUF"));
294 }
295
296 #[test]
297 fn paths_with_separator_are_local() {
298 assert!(looks_like_local_model_path("/abs/path/model.gguf"));
299 assert!(looks_like_local_model_path("models/qwen"));
300 assert!(looks_like_local_model_path("./relative/model"));
301 }
302
303 #[test]
304 fn leading_dot_is_local() {
305 assert!(looks_like_local_model_path("./model"));
306 assert!(looks_like_local_model_path(".hidden"));
307 }
308
309 #[test]
310 fn bare_model_ids_are_not_local() {
311 assert!(!looks_like_local_model_path("qwen2.5-7b"));
312 assert!(!looks_like_local_model_path("gpt-4o-mini"));
313 }
314
315 #[test]
316 fn existing_filesystem_path_is_local() {
317 let path = env!("CARGO_MANIFEST_DIR").to_string() + "/Cargo.toml";
319 assert!(looks_like_local_model_path(&path));
320 }
321}