Skip to main content

ai_model_vault/
interop.rs

1//! Inference engine interop — register vault models with Ollama and LM Studio.
2//!
3//! Provides helpers to:
4//! - Create an Ollama `Modelfile` and register a model via `ollama create`
5//! - Symlink or copy a GGUF model into the LM Studio model directory
6
7use std::fs;
8use std::path::PathBuf;
9use std::process::Command;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Result, VaultError};
14
15// ── Types ────────────────────────────────────────────────────────────────────
16
17/// Which inference engine to register with.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19pub enum InferenceEngine {
20    Ollama,
21    LmStudio,
22}
23
24impl std::fmt::Display for InferenceEngine {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            Self::Ollama => write!(f, "Ollama"),
28            Self::LmStudio => write!(f, "LM Studio"),
29        }
30    }
31}
32
33/// Result of a registration attempt.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct RegistrationResult {
36    pub engine: String,
37    pub model_name: String,
38    pub success: bool,
39    pub message: String,
40    /// Path where the model was placed or the Modelfile was created
41    pub path: Option<String>,
42}
43
44/// Options for Ollama registration.
45#[derive(Debug, Clone)]
46pub struct OllamaOptions {
47    /// Model name for Ollama
48    pub name: String,
49    /// Path to the GGUF file on disk
50    pub model_path: PathBuf,
51    /// Optional system prompt
52    pub system_prompt: Option<String>,
53    /// Optional template string
54    pub template: Option<String>,
55    /// Optional parameter overrides (e.g. temperature, top_k)
56    pub parameters: Vec<(String, String)>,
57}
58
59/// Options for LM Studio registration.
60#[derive(Debug, Clone)]
61pub struct LmStudioOptions {
62    /// Model name (used as directory name)
63    pub name: String,
64    /// Path to the model file on disk
65    pub model_path: PathBuf,
66    /// Custom LM Studio models directory (overrides default)
67    pub models_dir: Option<PathBuf>,
68}
69
70// ── Ollama registration ──────────────────────────────────────────────────────
71
72/// Register a model with Ollama by creating a Modelfile and running `ollama create`.
73pub fn register_ollama(opts: &OllamaOptions) -> Result<RegistrationResult> {
74    // Verify the model file exists and is GGUF
75    if !opts.model_path.exists() {
76        return Err(VaultError::InvalidInput(format!(
77            "Model file not found: {}",
78            opts.model_path.display()
79        )));
80    }
81
82    // Build the Modelfile content
83    let modelfile_content = build_modelfile(opts)?;
84
85    // Write Modelfile to a temp location next to the model
86    let modelfile_path = opts.model_path.with_extension("Modelfile");
87    fs::write(&modelfile_path, &modelfile_content)?;
88
89    // Check if ollama is available
90    let ollama_check = Command::new("ollama").arg("--version").output();
91    if ollama_check.is_err() {
92        // Clean up Modelfile but still report where it is
93        return Ok(RegistrationResult {
94            engine: "Ollama".to_string(),
95            model_name: opts.name.clone(),
96            success: false,
97            message: format!(
98                "Ollama CLI not found. Modelfile created at: {}\nRun manually: ollama create {} -f {}",
99                modelfile_path.display(),
100                opts.name,
101                modelfile_path.display()
102            ),
103            path: Some(modelfile_path.display().to_string()),
104        });
105    }
106
107    // Run `ollama create`
108    let output = Command::new("ollama")
109        .arg("create")
110        .arg(&opts.name)
111        .arg("-f")
112        .arg(&modelfile_path)
113        .output()
114        .map_err(VaultError::IoError)?;
115
116    let success = output.status.success();
117    let message = if success {
118        format!(
119            "Model '{}' registered with Ollama. Run: ollama run {}",
120            opts.name, opts.name
121        )
122    } else {
123        let stderr = String::from_utf8_lossy(&output.stderr);
124        format!(
125            "Failed to register with Ollama: {}. Modelfile at: {}",
126            stderr.trim(),
127            modelfile_path.display()
128        )
129    };
130
131    Ok(RegistrationResult {
132        engine: "Ollama".to_string(),
133        model_name: opts.name.clone(),
134        success,
135        message,
136        path: Some(modelfile_path.display().to_string()),
137    })
138}
139
140/// Build an Ollama Modelfile from options.
141fn build_modelfile(opts: &OllamaOptions) -> Result<String> {
142    let mut lines = vec![format!("FROM {}", opts.model_path.display())];
143
144    if let Some(system) = &opts.system_prompt {
145        lines.push(format!("SYSTEM \"{}\"", system.replace('"', "\\\"")));
146    }
147
148    if let Some(template) = &opts.template {
149        lines.push(format!("TEMPLATE \"{}\"", template.replace('"', "\\\"")));
150    }
151
152    for (key, value) in &opts.parameters {
153        lines.push(format!("PARAMETER {} {}", key, value));
154    }
155
156    Ok(lines.join("\n") + "\n")
157}
158
159// ── LM Studio registration ──────────────────────────────────────────────────
160
161/// Register a model with LM Studio by copying it to the models directory.
162pub fn register_lm_studio(opts: &LmStudioOptions) -> Result<RegistrationResult> {
163    if !opts.model_path.exists() {
164        return Err(VaultError::InvalidInput(format!(
165            "Model file not found: {}",
166            opts.model_path.display()
167        )));
168    }
169
170    let models_dir = match &opts.models_dir {
171        Some(d) => d.clone(),
172        None => default_lm_studio_dir()?,
173    };
174
175    // Create model subdirectory
176    let model_dir = models_dir.join(&opts.name);
177    fs::create_dir_all(&model_dir)?;
178
179    // Copy model file
180    let file_name = opts
181        .model_path
182        .file_name()
183        .ok_or_else(|| VaultError::InvalidInput("Invalid model file path".to_string()))?;
184    let dest = model_dir.join(file_name);
185
186    fs::copy(&opts.model_path, &dest)?;
187
188    Ok(RegistrationResult {
189        engine: "LM Studio".to_string(),
190        model_name: opts.name.clone(),
191        success: true,
192        message: format!(
193            "Model '{}' copied to LM Studio models directory. Restart LM Studio to see it.",
194            opts.name
195        ),
196        path: Some(dest.display().to_string()),
197    })
198}
199
200/// Determine the default LM Studio models directory.
201fn default_lm_studio_dir() -> Result<PathBuf> {
202    #[cfg(target_os = "windows")]
203    {
204        if let Ok(home) = std::env::var("USERPROFILE") {
205            return Ok(PathBuf::from(home)
206                .join(".cache")
207                .join("lm-studio")
208                .join("models"));
209        }
210    }
211
212    #[cfg(target_os = "macos")]
213    {
214        if let Ok(home) = std::env::var("HOME") {
215            return Ok(PathBuf::from(home)
216                .join(".cache")
217                .join("lm-studio")
218                .join("models"));
219        }
220    }
221
222    #[cfg(target_os = "linux")]
223    {
224        if let Ok(home) = std::env::var("HOME") {
225            return Ok(PathBuf::from(home)
226                .join(".cache")
227                .join("lm-studio")
228                .join("models"));
229        }
230    }
231
232    Err(VaultError::InvalidInput(
233        "Cannot determine LM Studio models directory. Use --models-dir to specify.".to_string(),
234    ))
235}
236
237// ── Tests ────────────────────────────────────────────────────────────────────
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use std::io::Write;
243
244    #[test]
245    fn test_build_modelfile_basic() {
246        let opts = OllamaOptions {
247            name: "my-model".to_string(),
248            model_path: PathBuf::from("/tmp/model.gguf"),
249            system_prompt: None,
250            template: None,
251            parameters: vec![],
252        };
253        let mf = build_modelfile(&opts).unwrap();
254        assert!(mf.starts_with("FROM /tmp/model.gguf"));
255    }
256
257    #[test]
258    fn test_build_modelfile_full() {
259        let opts = OllamaOptions {
260            name: "test".to_string(),
261            model_path: PathBuf::from("/models/test.gguf"),
262            system_prompt: Some("You are a helpful assistant.".to_string()),
263            template: Some("{{.Prompt}}".to_string()),
264            parameters: vec![
265                ("temperature".to_string(), "0.7".to_string()),
266                ("top_k".to_string(), "40".to_string()),
267            ],
268        };
269        let mf = build_modelfile(&opts).unwrap();
270        assert!(mf.contains("FROM /models/test.gguf"));
271        assert!(mf.contains("SYSTEM"));
272        assert!(mf.contains("PARAMETER temperature 0.7"));
273        assert!(mf.contains("PARAMETER top_k 40"));
274    }
275
276    #[test]
277    fn test_register_ollama_missing_file() {
278        let opts = OllamaOptions {
279            name: "bad".to_string(),
280            model_path: PathBuf::from("/nonexistent/model.gguf"),
281            system_prompt: None,
282            template: None,
283            parameters: vec![],
284        };
285        let result = register_ollama(&opts);
286        assert!(result.is_err());
287    }
288
289    #[test]
290    fn test_register_lm_studio_missing_file() {
291        let opts = LmStudioOptions {
292            name: "bad".to_string(),
293            model_path: PathBuf::from("/nonexistent/model.gguf"),
294            models_dir: None,
295        };
296        let result = register_lm_studio(&opts);
297        assert!(result.is_err());
298    }
299
300    #[test]
301    fn test_register_lm_studio_copy() {
302        let dir = tempfile::tempdir().unwrap();
303        let model_path = dir.path().join("model.gguf");
304        {
305            let mut f = fs::File::create(&model_path).unwrap();
306            f.write_all(b"fake gguf data").unwrap();
307        }
308
309        let models_dir = dir.path().join("lm-studio-models");
310        let opts = LmStudioOptions {
311            name: "test-model".to_string(),
312            model_path,
313            models_dir: Some(models_dir.clone()),
314        };
315
316        let result = register_lm_studio(&opts).unwrap();
317        assert!(result.success);
318        assert!(models_dir.join("test-model").join("model.gguf").exists());
319    }
320
321    #[test]
322    fn test_inference_engine_display() {
323        assert_eq!(InferenceEngine::Ollama.to_string(), "Ollama");
324        assert_eq!(InferenceEngine::LmStudio.to_string(), "LM Studio");
325    }
326}