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