Skip to main content

netget/llm/
config.rs

1//! Persistent configuration for LLM backends and settings
2//!
3//! Stores user preferences in ~/.netget/config.toml
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use tracing::debug;
9
10/// LLM backend type
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum LlmBackendType {
14    /// Ollama HTTP API (external service)
15    Ollama,
16    /// OpenAI-compatible HTTP API
17    OpenAI,
18    /// Embedded llama.cpp inference
19    #[cfg(feature = "embedded-llm")]
20    Embedded,
21}
22
23impl std::fmt::Display for LlmBackendType {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            LlmBackendType::Ollama => write!(f, "Ollama"),
27            LlmBackendType::OpenAI => write!(f, "OpenAI-compatible"),
28            #[cfg(feature = "embedded-llm")]
29            LlmBackendType::Embedded => write!(f, "Embedded (llama.cpp)"),
30        }
31    }
32}
33
34/// OpenAI-specific configuration
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct OpenAIConfig {
37    /// OpenAI-compatible API base URL
38    #[serde(default)]
39    pub base_url: Option<String>,
40
41    /// Last used model name
42    #[serde(default)]
43    pub last_model: Option<String>,
44    // Note: API key is NOT stored in config for security — must come from env/CLI
45}
46
47impl Default for OpenAIConfig {
48    fn default() -> Self {
49        Self {
50            base_url: None,
51            last_model: None,
52        }
53    }
54}
55
56/// Persistent configuration for NetGet LLM settings
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct NetGetConfig {
59    /// Last used backend type
60    #[serde(default)]
61    pub last_backend: Option<LlmBackendType>,
62
63    /// Ollama configuration
64    #[serde(default)]
65    pub ollama: OllamaConfig,
66
67    /// OpenAI-compatible API configuration
68    #[serde(default)]
69    pub openai: OpenAIConfig,
70
71    /// Embedded LLM configuration
72    #[cfg(feature = "embedded-llm")]
73    #[serde(default)]
74    pub embedded: EmbeddedLlmConfig,
75
76    /// Model download directory
77    #[serde(default = "default_models_dir")]
78    pub models_dir: PathBuf,
79}
80
81/// Ollama-specific configuration
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct OllamaConfig {
84    /// Ollama API base URL
85    #[serde(default = "default_ollama_url")]
86    pub base_url: String,
87
88    /// Last used Ollama model
89    #[serde(default)]
90    pub last_model: Option<String>,
91
92    /// Prefer Ollama over embedded (when both available)
93    #[serde(default = "default_true")]
94    pub prefer: bool,
95}
96
97impl Default for OllamaConfig {
98    fn default() -> Self {
99        Self {
100            base_url: default_ollama_url(),
101            last_model: None,
102            prefer: true,
103        }
104    }
105}
106
107/// Embedded LLM configuration
108#[cfg(feature = "embedded-llm")]
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct EmbeddedLlmConfig {
111    /// Last used model path
112    #[serde(default)]
113    pub last_model_path: Option<PathBuf>,
114
115    /// Context size (tokens)
116    #[serde(default = "default_context_size")]
117    pub context_size: u32,
118
119    /// Max generation tokens
120    #[serde(default = "default_max_tokens")]
121    pub max_tokens: usize,
122
123    /// GPU layers to offload (u32::MAX = auto)
124    #[serde(default = "default_gpu_layers")]
125    pub n_gpu_layers: u32,
126
127    /// Number of threads (0 = auto)
128    #[serde(default)]
129    pub n_threads: u32,
130}
131
132#[cfg(feature = "embedded-llm")]
133impl Default for EmbeddedLlmConfig {
134    fn default() -> Self {
135        Self {
136            last_model_path: None,
137            context_size: default_context_size(),
138            max_tokens: default_max_tokens(),
139            n_gpu_layers: default_gpu_layers(),
140            n_threads: 0,
141        }
142    }
143}
144
145impl Default for NetGetConfig {
146    fn default() -> Self {
147        Self {
148            last_backend: None,
149            ollama: OllamaConfig::default(),
150            openai: OpenAIConfig::default(),
151            #[cfg(feature = "embedded-llm")]
152            embedded: EmbeddedLlmConfig::default(),
153            models_dir: default_models_dir(),
154        }
155    }
156}
157
158impl NetGetConfig {
159    /// Load configuration from ~/.netget/config.toml
160    pub fn load() -> Result<Self> {
161        let config_path = Self::config_file_path()?;
162
163        if !config_path.exists() {
164            debug!(
165                "Config file not found, using defaults: {}",
166                config_path.display()
167            );
168            return Ok(Self::default());
169        }
170
171        let contents = crate::utils::file_io::read_text(&config_path, 1024 * 1024)
172            .context("Failed to read config file")?;
173
174        let config: NetGetConfig =
175            toml::from_str(&contents).context("Failed to parse config file")?;
176
177        debug!("Loaded config from: {}", config_path.display());
178        Ok(config)
179    }
180
181    /// Save configuration to ~/.netget/config.toml
182    pub fn save(&self) -> Result<()> {
183        let home = dirs::home_dir().context("Cannot find home directory")?;
184        let config_path =
185            crate::utils::file_io::ensure_config_directory(&home)?.join("config.toml");
186        let contents = toml::to_string_pretty(self).context("Failed to serialize config")?;
187        crate::utils::file_io::write_atomic(&config_path, contents.as_bytes())?;
188
189        debug!("Saved config to: {}", config_path.display());
190        Ok(())
191    }
192
193    /// Get path to config file (~/.netget/config.toml)
194    pub fn config_file_path() -> Result<PathBuf> {
195        let home = dirs::home_dir().context("Cannot find home directory")?;
196        Ok(home.join(".netget").join("config.toml"))
197    }
198
199    /// Get path to models directory (~/.netget/models/)
200    pub fn models_directory(&self) -> &Path {
201        &self.models_dir
202    }
203
204    /// Ensure models directory exists
205    pub fn ensure_models_dir(&self) -> Result<PathBuf> {
206        std::fs::create_dir_all(&self.models_dir).context("Failed to create models directory")?;
207        Ok(self.models_dir.clone())
208    }
209
210    /// Update last used backend and save
211    pub fn set_last_backend(&mut self, backend: LlmBackendType) -> Result<()> {
212        self.last_backend = Some(backend);
213        self.save()
214    }
215
216    /// Update Ollama model and save
217    pub fn set_ollama_model(&mut self, model: impl Into<String>) -> Result<()> {
218        self.ollama.last_model = Some(model.into());
219        self.last_backend = Some(LlmBackendType::Ollama);
220        self.save()
221    }
222
223    /// Update embedded model path and save
224    #[cfg(feature = "embedded-llm")]
225    pub fn set_embedded_model(&mut self, path: impl Into<PathBuf>) -> Result<()> {
226        self.embedded.last_model_path = Some(path.into());
227        self.last_backend = Some(LlmBackendType::Embedded);
228        self.save()
229    }
230}
231
232// Default value functions
233fn default_ollama_url() -> String {
234    "http://localhost:11434".to_string()
235}
236
237fn default_models_dir() -> PathBuf {
238    dirs::home_dir()
239        .unwrap_or_else(|| PathBuf::from("."))
240        .join(".netget")
241        .join("models")
242}
243
244// Default values for serde deserialization
245#[allow(dead_code)]
246fn default_context_size() -> u32 {
247    4096
248}
249
250#[allow(dead_code)]
251fn default_max_tokens() -> usize {
252    2048
253}
254
255#[allow(dead_code)]
256fn default_gpu_layers() -> u32 {
257    u32::MAX // Auto-detect
258}
259
260fn default_true() -> bool {
261    true
262}