1use std::collections::HashMap;
2use std::process::Stdio;
3use std::sync::{LazyLock, Mutex};
4use std::time::Duration;
5
6use anyhow::{Context, Result};
7use serde::Deserialize;
8use tokio::process::Child;
9
10use crate::process_env::{sanitize_std_command_environment, sanitize_tokio_command_environment};
11use vtcode_config::constants::{env_vars, urls};
12
13const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum LocalProvider {
21 Ollama,
22 LmStudio,
23 LlamaCpp,
24}
25
26impl LocalProvider {
27 pub fn key(self) -> &'static str {
28 match self {
29 Self::Ollama => "ollama",
30 Self::LmStudio => "lmstudio",
31 Self::LlamaCpp => "llamacpp",
32 }
33 }
34
35 pub fn display_name(self) -> &'static str {
36 match self {
37 Self::Ollama => "Ollama",
38 Self::LmStudio => "LM Studio",
39 Self::LlamaCpp => "llama.cpp",
40 }
41 }
42
43 pub fn from_key(key: &str) -> Option<Self> {
44 match key {
45 "ollama" => Some(Self::Ollama),
46 "lmstudio" | "lm-studio" => Some(Self::LmStudio),
47 "llamacpp" | "llama.cpp" | "llama-cpp" => Some(Self::LlamaCpp),
48 _ => None,
49 }
50 }
51
52 pub fn all() -> &'static [LocalProvider] {
53 &[Self::Ollama, Self::LmStudio, Self::LlamaCpp]
54 }
55
56 fn default_port(self) -> u16 {
57 match self {
58 Self::Ollama => 11434,
59 Self::LmStudio => 1234,
60 Self::LlamaCpp => 8080,
61 }
62 }
63
64 fn default_base_url(self) -> &'static str {
65 match self {
66 Self::Ollama => urls::OLLAMA_API_BASE,
67 Self::LmStudio => urls::LMSTUDIO_API_BASE,
68 Self::LlamaCpp => urls::LLAMACPP_API_BASE,
69 }
70 }
71
72 fn base_url_env(self) -> &'static str {
73 match self {
74 Self::Ollama => env_vars::OLLAMA_BASE_URL,
75 Self::LmStudio => env_vars::LMSTUDIO_BASE_URL,
76 Self::LlamaCpp => env_vars::LLAMACPP_BASE_URL,
77 }
78 }
79
80 fn base_url(self) -> String {
81 resolve_base_url(self.default_base_url(), self.base_url_env())
82 }
83
84 fn host_root(self) -> String {
85 let base = self.base_url();
86 strip_path_suffix(&base)
87 }
88}
89
90#[derive(Debug, Clone)]
91pub struct LocalServerStatus {
92 pub provider: LocalProvider,
93 pub running: bool,
94 pub endpoint: String,
95 pub available_models: Vec<String>,
96 pub running_models: Vec<String>,
97 pub version: Option<String>,
98 pub error: Option<String>,
99}
100
101impl LocalServerStatus {
102 fn not_running(provider: LocalProvider, reason: impl Into<String>) -> Self {
103 Self {
104 provider,
105 running: false,
106 endpoint: provider.base_url(),
107 available_models: Vec::new(),
108 running_models: Vec::new(),
109 version: None,
110 error: Some(reason.into()),
111 }
112 }
113}
114
115#[derive(Debug, Clone)]
116pub struct LocalServerCapabilities {
117 can_start: bool,
118 can_stop: bool,
119 binary_found: bool,
120 binary_name: &'static str,
121 binary_path: Option<String>,
122}
123
124#[derive(Debug, Clone)]
125pub struct EnvVarInfo {
126 pub name: &'static str,
127 pub current_value: Option<String>,
128 pub description: &'static str,
129}
130
131struct ManagedProcess {
136 child: Option<Child>,
137}
138
139static MANAGED_PROCESSES: LazyLock<Mutex<HashMap<LocalProvider, ManagedProcess>>> =
140 LazyLock::new(|| Mutex::new(HashMap::new()));
141
142fn take_managed_child(provider: LocalProvider) -> Option<Child> {
143 let mut guard = MANAGED_PROCESSES.lock().unwrap_or_else(|e| e.into_inner());
144 guard.get_mut(&provider)?.child.take()
145}
146
147fn store_managed_child(provider: LocalProvider, child: Child) {
148 if let Ok(mut guard) = MANAGED_PROCESSES.lock() {
149 guard.entry(provider).or_insert_with(|| ManagedProcess { child: None }).child = Some(child);
150 }
151}
152
153fn is_managed_running(provider: LocalProvider) -> bool {
154 MANAGED_PROCESSES
155 .lock()
156 .ok()
157 .and_then(|guard| guard.get(&provider).map(|p| p.child.is_some()))
158 .unwrap_or(false)
159}
160
161pub async fn probe_all() -> Vec<LocalServerStatus> {
166 let mut statuses = Vec::with_capacity(LocalProvider::all().len());
167 for &provider in LocalProvider::all() {
168 statuses.push(probe(provider).await);
169 }
170 statuses
171}
172
173pub async fn probe(provider: LocalProvider) -> LocalServerStatus {
174 match provider {
175 LocalProvider::Ollama => probe_ollama().await,
176 LocalProvider::LmStudio => probe_lmstudio().await,
177 LocalProvider::LlamaCpp => probe_llamacpp().await,
178 }
179}
180
181pub async fn start(provider: LocalProvider) -> Result<String> {
182 match provider {
183 LocalProvider::Ollama => start_ollama().await,
184 LocalProvider::LmStudio => start_lmstudio().await,
185 LocalProvider::LlamaCpp => start_llamacpp().await,
186 }
187}
188
189pub async fn stop(provider: LocalProvider) -> Result<String> {
190 match provider {
191 LocalProvider::Ollama => stop_ollama().await,
192 LocalProvider::LmStudio => stop_lmstudio().await,
193 LocalProvider::LlamaCpp => stop_llamacpp().await,
194 }
195}
196
197pub fn capabilities(provider: LocalProvider) -> LocalServerCapabilities {
198 match provider {
199 LocalProvider::Ollama => caps_ollama(),
200 LocalProvider::LmStudio => caps_lmstudio(),
201 LocalProvider::LlamaCpp => caps_llamacpp(),
202 }
203}
204
205pub fn env_config(provider: LocalProvider) -> Vec<EnvVarInfo> {
206 match provider {
207 LocalProvider::Ollama => vec![EnvVarInfo {
208 name: env_vars::OLLAMA_BASE_URL,
209 current_value: std::env::var(env_vars::OLLAMA_BASE_URL).ok(),
210 description: "Ollama server base URL (default: http://localhost:11434)",
211 }],
212 LocalProvider::LmStudio => vec![EnvVarInfo {
213 name: env_vars::LMSTUDIO_BASE_URL,
214 current_value: std::env::var(env_vars::LMSTUDIO_BASE_URL).ok(),
215 description: "LM Studio server base URL (default: http://localhost:1234/v1)",
216 }],
217 LocalProvider::LlamaCpp => vec![
218 EnvVarInfo {
219 name: env_vars::LLAMACPP_BASE_URL,
220 current_value: std::env::var(env_vars::LLAMACPP_BASE_URL).ok(),
221 description: "llama.cpp server base URL (default: http://localhost:8080/v1)",
222 },
223 EnvVarInfo {
224 name: env_vars::LLAMACPP_MODEL_PATH,
225 current_value: std::env::var(env_vars::LLAMACPP_MODEL_PATH).ok(),
226 description: "Path to .gguf model file for auto-start",
227 },
228 EnvVarInfo {
229 name: env_vars::LLAMACPP_BINARY_PATH,
230 current_value: std::env::var(env_vars::LLAMACPP_BINARY_PATH).ok(),
231 description: "Path to llama-server binary (default: search PATH)",
232 },
233 EnvVarInfo {
234 name: env_vars::LLAMACPP_EXTRA_ARGS,
235 current_value: std::env::var(env_vars::LLAMACPP_EXTRA_ARGS).ok(),
236 description: "Extra arguments passed to llama-server",
237 },
238 ],
239 }
240}
241
242pub fn troubleshoot(status: &LocalServerStatus, caps: &LocalServerCapabilities) -> Vec<String> {
243 let mut lines = Vec::new();
244 lines.push(format!("{} Troubleshoot", status.provider.display_name()));
245 lines.push(String::new());
246
247 if status.running {
248 lines.push("Server is running and responding.".to_string());
249 if status.available_models.is_empty() {
250 lines.push("No models are currently available.".to_string());
251 match status.provider {
252 LocalProvider::Ollama => {
253 lines.push(" Pull a model: ollama pull gemma3".to_string());
254 }
255 LocalProvider::LmStudio => {
256 lines.push(" Download a model in LM Studio or run: lms get <model>".to_string());
257 }
258 LocalProvider::LlamaCpp => {
259 lines.push(format!(" Set {}=/path/to/model.gguf and restart", env_vars::LLAMACPP_MODEL_PATH));
260 }
261 }
262 }
263 return lines;
264 }
265
266 lines.push("Status: Not running".to_string());
267 if let Some(err) = &status.error {
268 lines.push(format!("Error: {err}"));
269 }
270 lines.push(String::new());
271
272 match status.provider {
273 LocalProvider::Ollama => {
274 if !caps.binary_found {
275 lines.push("Ollama is not installed.".to_string());
276 lines.push(" Install: brew install ollama".to_string());
277 lines.push(" Or: https://github.com/ollama/ollama?tab=readme-ov-file".to_string());
278 } else {
279 lines.push("Ollama is installed but the server is not running.".to_string());
280 lines.push(" Start: ollama serve".to_string());
281 lines.push(" Or: /local start ollama".to_string());
282 }
283 lines.push(" Logs: ~/.ollama/logs/server.log".to_string());
284 }
285 LocalProvider::LmStudio => {
286 if !caps.binary_found {
287 lines.push("LM Studio CLI (lms) not found.".to_string());
288 lines.push(" Install LM Studio: https://lmstudio.ai/download".to_string());
289 lines.push(" The lms CLI ships with LM Studio.".to_string());
290 } else {
291 lines.push("LM Studio server is not running.".to_string());
292 lines.push(" Start: lms server start".to_string());
293 lines.push(" Or: /local start lmstudio".to_string());
294 lines.push(" Status: lms server status --json".to_string());
295 }
296 }
297 LocalProvider::LlamaCpp => {
298 if !caps.binary_found {
299 lines.push("llama-server binary not found.".to_string());
300 lines.push(" Install: https://llama.app".to_string());
301 lines.push(format!(" Or set {}=/path/to/llama-server", env_vars::LLAMACPP_BINARY_PATH));
302 } else {
303 lines.push("llama.cpp server is not running.".to_string());
304 let model_path = std::env::var(env_vars::LLAMACPP_MODEL_PATH).ok();
305 if model_path.is_none() {
306 lines.push(format!(" Set {}=/path/to/model.gguf for auto-start", env_vars::LLAMACPP_MODEL_PATH));
307 }
308 lines.push(" Or: /local start llamacpp".to_string());
309 }
310 }
311 }
312
313 lines
314}
315
316async fn probe_ollama() -> LocalServerStatus {
321 let base = LocalProvider::Ollama.host_root();
322 let client = vtcode_commons::http::create_client_with_timeout(PROBE_TIMEOUT);
323
324 let tags_url = format!("{}/api/tags", base.trim_end_matches('/'));
326 let tags_resp = match client.get(&tags_url).send().await {
327 Ok(resp) => resp,
328 Err(e) => {
329 let mut s = LocalServerStatus::not_running(LocalProvider::Ollama, e.to_string());
330 if is_managed_running(LocalProvider::Ollama) {
331 s.error = Some("Managed process exists but server not responding yet".into());
332 }
333 return s;
334 }
335 };
336
337 if !tags_resp.status().is_success() {
338 return LocalServerStatus::not_running(LocalProvider::Ollama, format!("HTTP {}", tags_resp.status()));
339 }
340
341 let available_models = tags_resp
342 .json::<OllamaTagsResponse>()
343 .await
344 .map(|r| r.models.into_iter().map(|m| m.name).collect())
345 .unwrap_or_default();
346
347 let ps_url = format!("{}/api/ps", base.trim_end_matches('/'));
349 let running_models = parse_json_opt::<OllamaPsResponse>(client.get(&ps_url).send().await.ok())
350 .await
351 .map(|r| r.models.into_iter().map(|m| m.name).collect())
352 .unwrap_or_default();
353
354 let version_url = format!("{}/api/version", base.trim_end_matches('/'));
356 let version = parse_json_opt::<OllamaVersionResponse>(client.get(&version_url).send().await.ok())
357 .await
358 .and_then(|r| r.version);
359
360 LocalServerStatus {
361 provider: LocalProvider::Ollama,
362 running: true,
363 endpoint: LocalProvider::Ollama.base_url(),
364 available_models,
365 running_models,
366 version,
367 error: None,
368 }
369}
370
371async fn probe_lmstudio() -> LocalServerStatus {
372 let base = LocalProvider::LmStudio.base_url();
373 let client = vtcode_commons::http::create_client_with_timeout(PROBE_TIMEOUT);
374
375 let models_url = format!("{}/models", base.trim_end_matches('/'));
376 let resp = match client.get(&models_url).send().await {
377 Ok(resp) => resp,
378 Err(e) => return LocalServerStatus::not_running(LocalProvider::LmStudio, e.to_string()),
379 };
380
381 if !resp.status().is_success() {
382 return LocalServerStatus::not_running(LocalProvider::LmStudio, format!("HTTP {}", resp.status()));
383 }
384
385 let available_models = resp
386 .json::<LmStudioModelsResponse>()
387 .await
388 .map(|r| r.data.into_iter().map(|m| m.id).collect())
389 .unwrap_or_default();
390
391 LocalServerStatus {
392 provider: LocalProvider::LmStudio,
393 running: true,
394 endpoint: LocalProvider::LmStudio.base_url(),
395 available_models,
396 running_models: Vec::new(),
397 version: None,
398 error: None,
399 }
400}
401
402async fn probe_llamacpp() -> LocalServerStatus {
403 let base = LocalProvider::LlamaCpp.host_root();
404 let client = vtcode_commons::http::create_client_with_timeout(PROBE_TIMEOUT);
405
406 let health_url = format!("{}/health", base.trim_end_matches('/'));
408 let health_resp = match client.get(&health_url).send().await {
409 Ok(resp) => resp,
410 Err(e) => return LocalServerStatus::not_running(LocalProvider::LlamaCpp, e.to_string()),
411 };
412
413 if !health_resp.status().is_success() {
414 return LocalServerStatus::not_running(LocalProvider::LlamaCpp, format!("HTTP {}", health_resp.status()));
415 }
416
417 let models_url = format!("{}/models", base.trim_end_matches('/'));
419 let available_models = parse_json_opt::<LlamaCppModelsResponse>(client.get(&models_url).send().await.ok())
420 .await
421 .map(|r| r.data.into_iter().map(|m| m.id).collect())
422 .unwrap_or_default();
423
424 LocalServerStatus {
425 provider: LocalProvider::LlamaCpp,
426 running: true,
427 endpoint: LocalProvider::LlamaCpp.base_url(),
428 available_models,
429 running_models: Vec::new(),
430 version: None,
431 error: None,
432 }
433}
434
435async fn start_ollama() -> Result<String> {
440 let caps = caps_ollama();
441 if !caps.binary_found {
442 anyhow::bail!(
443 "Ollama is not installed. Install with: brew install ollama\n\
444 Or visit: https://github.com/ollama/ollama?tab=readme-ov-file"
445 );
446 }
447
448 let status = probe_ollama().await;
450 if status.running {
451 return Ok("Ollama is already running.".to_string());
452 }
453
454 let binary = caps.binary_path.unwrap_or_else(|| "ollama".to_string());
455 let mut cmd = tokio::process::Command::new(&binary);
456 sanitize_tokio_command_environment(&mut cmd, &[]);
457 cmd.arg("serve")
458 .stdin(Stdio::null())
459 .stdout(Stdio::null())
460 .stderr(Stdio::null())
461 .kill_on_drop(true);
462
463 let child = cmd
464 .spawn()
465 .with_context(|| format!("Failed to start Ollama with `{binary} serve`"))?;
466
467 store_managed_child(LocalProvider::Ollama, child);
468
469 wait_for_ready(LocalProvider::Ollama, Duration::from_secs(10)).await?;
471
472 Ok("Ollama server started.".to_string())
473}
474
475async fn start_lmstudio() -> Result<String> {
476 let caps = caps_lmstudio();
477 if !caps.binary_found {
478 anyhow::bail!(
479 "LM Studio CLI (lms) not found.\n\
480 Install LM Studio from https://lmstudio.ai/download\n\
481 The lms CLI ships with the app."
482 );
483 }
484
485 let status = probe_lmstudio().await;
487 if status.running {
488 return Ok("LM Studio server is already running.".to_string());
489 }
490
491 let binary = caps.binary_path.unwrap_or_else(|| "lms".to_string());
492 let mut command = tokio::process::Command::new(&binary);
493 sanitize_tokio_command_environment(&mut command, &[]);
494 let output = command
495 .args(["server", "start"])
496 .output()
497 .await
498 .with_context(|| format!("Failed to run `{binary} server start`"))?;
499
500 if !output.status.success() {
501 let stderr = String::from_utf8_lossy(&output.stderr);
502 anyhow::bail!("lms server start failed: {}", stderr.trim());
503 }
504
505 wait_for_ready(LocalProvider::LmStudio, Duration::from_secs(10)).await?;
507
508 Ok("LM Studio server started.".to_string())
509}
510
511async fn start_llamacpp() -> Result<String> {
512 let caps = caps_llamacpp();
513 if !caps.binary_found {
514 anyhow::bail!(
515 "llama-server binary not found.\n\
516 Install from https://llama.app\n\
517 Or set {}=/path/to/llama-server",
518 env_vars::LLAMACPP_BINARY_PATH
519 );
520 }
521
522 let status = probe_llamacpp().await;
524 if status.running {
525 return Ok("llama.cpp server is already running.".to_string());
526 }
527
528 let model_path = std::env::var(env_vars::LLAMACPP_MODEL_PATH)
529 .ok()
530 .filter(|v| !v.trim().is_empty());
531 let model_path = match model_path {
532 Some(path) => path,
533 None => anyhow::bail!(
534 "Set {}=/path/to/model.gguf to enable auto-start for llama.cpp",
535 env_vars::LLAMACPP_MODEL_PATH
536 ),
537 };
538
539 let binary = caps.binary_path.unwrap_or_else(|| "llama-server".to_string());
540 let port = extract_port(&LocalProvider::LlamaCpp.base_url()).unwrap_or(LocalProvider::LlamaCpp.default_port());
541
542 let mut args = vec!["-m".to_string(), model_path, "--port".to_string(), port.to_string()];
543 if let Ok(extra) = std::env::var(env_vars::LLAMACPP_EXTRA_ARGS)
544 && !extra.trim().is_empty()
545 {
546 args.extend(shell_words::split(&extra).unwrap_or_default());
547 }
548
549 let mut cmd = tokio::process::Command::new(&binary);
550 sanitize_tokio_command_environment(&mut cmd, &[]);
551 cmd.args(&args)
552 .stdin(Stdio::null())
553 .stdout(Stdio::null())
554 .stderr(Stdio::null())
555 .kill_on_drop(true);
556
557 let child = cmd
558 .spawn()
559 .with_context(|| format!("Failed to start llama-server (`{binary} -m <model> --port {port}`)"))?;
560
561 store_managed_child(LocalProvider::LlamaCpp, child);
562
563 wait_for_ready(LocalProvider::LlamaCpp, Duration::from_secs(30)).await?;
565
566 Ok("llama.cpp server started.".to_string())
567}
568
569async fn stop_ollama() -> Result<String> {
574 if let Some(mut child) = take_managed_child(LocalProvider::Ollama) {
575 child.kill().await.ok();
576 return Ok("Ollama server stopped.".to_string());
577 }
578
579 let status = probe_ollama().await;
581 if !status.running {
582 return Ok("Ollama is not running.".to_string());
583 }
584
585 anyhow::bail!(
586 "Ollama is running but was not started by VT Code.\n\
587 Stop it manually or kill the process."
588 )
589}
590
591async fn stop_lmstudio() -> Result<String> {
592 let caps = caps_lmstudio();
593 if !caps.binary_found {
594 return Ok("LM Studio CLI not found; nothing to stop.".to_string());
595 }
596
597 let status = probe_lmstudio().await;
598 if !status.running {
599 return Ok("LM Studio server is not running.".to_string());
600 }
601
602 let binary = caps.binary_path.unwrap_or_else(|| "lms".to_string());
603 let mut command = tokio::process::Command::new(&binary);
604 sanitize_tokio_command_environment(&mut command, &[]);
605 let output = command
606 .args(["server", "stop"])
607 .output()
608 .await
609 .with_context(|| format!("Failed to run `{binary} server stop`"))?;
610
611 if !output.status.success() {
612 let stderr = String::from_utf8_lossy(&output.stderr);
613 anyhow::bail!("lms server stop failed: {}", stderr.trim());
614 }
615
616 Ok("LM Studio server stopped.".to_string())
617}
618
619async fn stop_llamacpp() -> Result<String> {
620 if let Some(mut child) = take_managed_child(LocalProvider::LlamaCpp) {
622 child.kill().await.ok();
623 return Ok("llama.cpp server stopped.".to_string());
624 }
625
626 let status = probe_llamacpp().await;
628 if !status.running {
629 return Ok("llama.cpp server is not running.".to_string());
630 }
631
632 anyhow::bail!(
633 "llama.cpp is running but was not started by VT Code.\n\
634 Stop it manually or kill the process."
635 )
636}
637
638fn caps_ollama() -> LocalServerCapabilities {
643 let (found, path) = find_binary("ollama");
644 LocalServerCapabilities {
645 can_start: found,
646 can_stop: is_managed_running(LocalProvider::Ollama),
647 binary_found: found,
648 binary_name: "ollama",
649 binary_path: path,
650 }
651}
652
653fn caps_lmstudio() -> LocalServerCapabilities {
654 let (found, path) = find_binary("lms");
656 let (found, path) = if !found {
657 find_lms_fallback().unwrap_or((false, None))
658 } else {
659 (found, path)
660 };
661 LocalServerCapabilities {
662 can_start: found,
663 can_stop: found,
664 binary_found: found,
665 binary_name: "lms",
666 binary_path: path,
667 }
668}
669
670fn caps_llamacpp() -> LocalServerCapabilities {
671 let (found, path) = if let Ok(explicit) = std::env::var(env_vars::LLAMACPP_BINARY_PATH) {
673 if !explicit.trim().is_empty() && std::path::Path::new(explicit.trim()).exists() {
674 (true, Some(explicit.trim().to_string()))
675 } else {
676 find_binary("llama-server")
677 }
678 } else {
679 find_binary("llama-server")
680 };
681
682 LocalServerCapabilities {
683 can_start: found
684 && std::env::var(env_vars::LLAMACPP_MODEL_PATH)
685 .ok()
686 .filter(|v| !v.trim().is_empty())
687 .is_some(),
688 can_stop: is_managed_running(LocalProvider::LlamaCpp),
689 binary_found: found,
690 binary_name: "llama-server",
691 binary_path: path,
692 }
693}
694
695fn resolve_base_url(default: &str, env_var: &str) -> String {
700 std::env::var(env_var)
701 .ok()
702 .filter(|v| !v.trim().is_empty())
703 .unwrap_or_else(|| default.to_string())
704}
705
706pub(crate) fn is_local_base_url(base_url: &str) -> bool {
712 let lowered = base_url.trim().to_ascii_lowercase();
713 const LOCAL_PREFIXES: &[&str] = &[
714 "http://localhost",
715 "https://localhost",
716 "http://127.",
717 "https://127.",
718 "http://0.0.0.0",
719 "https://0.0.0.0",
720 "http://[::1]",
721 "https://[::1]",
722 ];
723 if LOCAL_PREFIXES.iter().any(|prefix| lowered.starts_with(*prefix)) {
724 return true;
725 }
726 if let Ok(parsed) = url::Url::parse(lowered.trim_end_matches('/'))
727 && let Some(host) = parsed.host_str()
728 {
729 return matches!(host, "localhost" | "127.0.0.1" | "::1" | "0.0.0.0");
730 }
731 false
732}
733
734fn strip_path_suffix(url: &str) -> String {
735 let trimmed = url.trim_end_matches('/');
737 if let Some(pos) = trimmed.rfind("/v1") {
738 trimmed[..pos].to_string()
739 } else {
740 trimmed.to_string()
741 }
742}
743
744fn extract_port(url: &str) -> Option<u16> {
745 let stripped = strip_path_suffix(url);
746 url::Url::parse(&stripped).ok().and_then(|u| u.port())
747}
748
749fn find_binary(name: &str) -> (bool, Option<String>) {
750 which::which(name)
751 .map(|p| (true, Some(p.to_string_lossy().into_owned())))
752 .unwrap_or((false, None))
753}
754
755fn find_lms_fallback() -> Option<(bool, Option<String>)> {
756 let home = std::env::var("HOME").ok()?;
757 let fallback = format!("{home}/.lmstudio/bin/lms");
758 if std::path::Path::new(&fallback).exists() {
759 Some((true, Some(fallback)))
760 } else {
761 None
762 }
763}
764
765async fn wait_for_ready(provider: LocalProvider, timeout: Duration) -> Result<()> {
766 let deadline = tokio::time::Instant::now() + timeout;
767 let mut last_error = String::new();
768
769 while tokio::time::Instant::now() < deadline {
770 let status = probe(provider).await;
771 if status.running {
772 return Ok(());
773 }
774 if let Some(err) = &status.error {
775 last_error = err.clone();
776 }
777 tokio::time::sleep(Duration::from_millis(500)).await;
778 }
779
780 anyhow::bail!(
781 "Timed out waiting for {} to start after {}s. Last: {}",
782 provider.display_name(),
783 timeout.as_secs(),
784 last_error
785 )
786}
787
788#[derive(Deserialize)]
791struct OllamaTagsResponse {
792 models: Vec<OllamaModelSummary>,
793}
794
795#[derive(Deserialize)]
796struct OllamaModelSummary {
797 name: String,
798}
799
800#[derive(Deserialize)]
801struct OllamaPsResponse {
802 models: Vec<OllamaRunningModel>,
803}
804
805#[derive(Deserialize)]
806struct OllamaRunningModel {
807 name: String,
808}
809
810#[derive(Deserialize)]
811struct OllamaVersionResponse {
812 version: Option<String>,
813}
814
815#[derive(Deserialize)]
816struct LmStudioModelsResponse {
817 data: Vec<LmStudioModel>,
818}
819
820#[derive(Deserialize)]
821struct LmStudioModel {
822 id: String,
823}
824
825#[derive(Deserialize)]
826struct LlamaCppModelsResponse {
827 data: Vec<LlamaCppModel>,
828}
829
830#[derive(Deserialize)]
831struct LlamaCppModel {
832 id: String,
833}
834
835async fn parse_json_opt<T: serde::de::DeserializeOwned>(resp: Option<reqwest::Response>) -> Option<T> {
837 let resp = resp?;
838 if !resp.status().is_success() {
839 return None;
840 }
841 resp.json::<T>().await.ok()
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847
848 #[test]
849 fn test_provider_from_key() {
850 assert_eq!(LocalProvider::from_key("ollama"), Some(LocalProvider::Ollama));
851 assert_eq!(LocalProvider::from_key("lmstudio"), Some(LocalProvider::LmStudio));
852 assert_eq!(LocalProvider::from_key("lm-studio"), Some(LocalProvider::LmStudio));
853 assert_eq!(LocalProvider::from_key("llamacpp"), Some(LocalProvider::LlamaCpp));
854 assert_eq!(LocalProvider::from_key("llama.cpp"), Some(LocalProvider::LlamaCpp));
855 assert_eq!(LocalProvider::from_key("unknown"), None);
856 }
857
858 #[test]
859 fn test_provider_key_roundtrip() {
860 for &p in LocalProvider::all() {
861 assert_eq!(LocalProvider::from_key(p.key()), Some(p));
862 }
863 }
864
865 #[test]
866 fn test_provider_display_names() {
867 assert_eq!(LocalProvider::Ollama.display_name(), "Ollama");
868 assert_eq!(LocalProvider::LmStudio.display_name(), "LM Studio");
869 assert_eq!(LocalProvider::LlamaCpp.display_name(), "llama.cpp");
870 }
871
872 #[test]
873 fn test_strip_path_suffix() {
874 assert_eq!(strip_path_suffix("http://localhost:11434/v1"), "http://localhost:11434");
875 assert_eq!(strip_path_suffix("http://localhost:1234/v1/"), "http://localhost:1234");
876 assert_eq!(strip_path_suffix("http://localhost:8080"), "http://localhost:8080");
877 }
878
879 #[test]
880 fn test_is_local_base_url_accepts_loopback() {
881 for url in [
882 "http://localhost:11434",
883 "https://localhost:1234/v1",
884 "http://127.0.0.1:8080/v1",
885 "http://127.1.2.3:9999",
886 "http://0.0.0.0:8080",
887 "http://[::1]:1234/v1",
888 ] {
889 assert!(is_local_base_url(url), "expected local: {url}");
890 }
891 }
892
893 #[test]
894 fn test_is_local_base_url_rejects_remote() {
895 for url in [
896 "http://192.168.1.10:11434",
897 "https://api.openai.com/v1",
898 "http://example.com:8080/v1",
899 "http://10.0.0.5:1234",
900 ] {
901 assert!(!is_local_base_url(url), "expected remote: {url}");
902 }
903 }
904}