1use serde::{Deserialize, Serialize};
7
8use crate::domain::InferenceConfig;
9
10pub const DEFAULT_PROXY_PORT: u16 = 8080;
12
13pub const DEFAULT_LLAMA_BASE_PORT: u16 = 9000;
15
16pub const DEFAULT_CONTEXT_SIZE: u64 = 4096;
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
23#[serde(default)]
24pub struct Settings {
25 pub default_download_path: Option<String>,
27
28 pub default_context_size: Option<u64>,
30
31 pub proxy_port: Option<u16>,
33
34 pub llama_base_port: Option<u16>,
37
38 pub max_download_queue_size: Option<u32>,
40
41 pub show_memory_fit_indicators: Option<bool>,
43
44 pub max_tool_iterations: Option<u32>,
46
47 pub max_stagnation_steps: Option<u32>,
49
50 pub default_model_id: Option<i64>,
52
53 #[serde(default)]
58 pub inference_defaults: Option<InferenceConfig>,
59
60 pub setup_completed: Option<bool>,
63
64 pub title_generation_prompt: Option<String>,
66}
67
68impl Settings {
69 #[must_use]
71 pub const fn with_defaults() -> Self {
72 Self {
73 default_download_path: None,
74 default_context_size: Some(DEFAULT_CONTEXT_SIZE),
75 proxy_port: Some(DEFAULT_PROXY_PORT),
76 llama_base_port: Some(DEFAULT_LLAMA_BASE_PORT),
77 max_download_queue_size: Some(10),
78 show_memory_fit_indicators: Some(true),
79 #[allow(clippy::cast_possible_truncation)] max_tool_iterations: Some(crate::domain::agent::DEFAULT_MAX_ITERATIONS as u32),
81 #[allow(clippy::cast_possible_truncation)]
82 max_stagnation_steps: Some(crate::domain::agent::DEFAULT_MAX_STAGNATION_STEPS as u32),
83 default_model_id: None,
84 inference_defaults: None,
85 setup_completed: None,
86 title_generation_prompt: None,
87 }
88 }
89
90 #[must_use]
92 pub const fn effective_proxy_port(&self) -> u16 {
93 match self.proxy_port {
94 Some(port) => port,
95 None => DEFAULT_PROXY_PORT,
96 }
97 }
98
99 #[must_use]
101 pub const fn effective_llama_base_port(&self) -> u16 {
102 match self.llama_base_port {
103 Some(port) => port,
104 None => DEFAULT_LLAMA_BASE_PORT,
105 }
106 }
107
108 pub fn merge(&mut self, other: &SettingsUpdate) {
110 if let Some(ref path) = other.default_download_path {
111 self.default_download_path.clone_from(path);
112 }
113 if let Some(ref ctx_size) = other.default_context_size {
114 self.default_context_size = *ctx_size;
115 }
116 if let Some(ref port) = other.proxy_port {
117 self.proxy_port = *port;
118 }
119 if let Some(ref port) = other.llama_base_port {
120 self.llama_base_port = *port;
121 }
122 if let Some(ref queue_size) = other.max_download_queue_size {
123 self.max_download_queue_size = *queue_size;
124 }
125 if let Some(ref show_fit) = other.show_memory_fit_indicators {
126 self.show_memory_fit_indicators = *show_fit;
127 }
128 if let Some(ref iters) = other.max_tool_iterations {
129 self.max_tool_iterations = *iters;
130 }
131 if let Some(ref steps) = other.max_stagnation_steps {
132 self.max_stagnation_steps = *steps;
133 }
134 if let Some(ref model_id) = other.default_model_id {
135 self.default_model_id = *model_id;
136 }
137 if let Some(ref inference_defaults) = other.inference_defaults {
138 self.inference_defaults.clone_from(inference_defaults);
139 }
140 if let Some(ref v) = other.setup_completed {
141 self.setup_completed = *v;
142 }
143 if let Some(ref v) = other.title_generation_prompt {
144 self.title_generation_prompt.clone_from(v);
145 }
146 }
147}
148
149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156pub struct SettingsUpdate {
157 pub default_download_path: Option<Option<String>>,
158 pub default_context_size: Option<Option<u64>>,
159 pub proxy_port: Option<Option<u16>>,
160 pub llama_base_port: Option<Option<u16>>,
161 pub max_download_queue_size: Option<Option<u32>>,
162 pub show_memory_fit_indicators: Option<Option<bool>>,
163 pub max_tool_iterations: Option<Option<u32>>,
164 pub max_stagnation_steps: Option<Option<u32>>,
165 pub default_model_id: Option<Option<i64>>,
166 pub inference_defaults: Option<Option<InferenceConfig>>,
167 pub setup_completed: Option<Option<bool>>,
168 pub title_generation_prompt: Option<Option<String>>,
169}
170
171#[derive(Debug, Clone, thiserror::Error)]
173pub enum SettingsError {
174 #[error("Context size must be between 512 and 1,000,000, got {0}")]
175 InvalidContextSize(u64),
176
177 #[error("Port should be >= 1024 (privileged ports require root), got {0}")]
178 InvalidPort(u16),
179
180 #[error("Max download queue size must be between 1 and 50, got {0}")]
181 InvalidQueueSize(u32),
182
183 #[error("Download path cannot be empty")]
184 EmptyDownloadPath,
185
186 #[error("Invalid inference parameter: {0}")]
187 InvalidInferenceConfig(String),
188}
189
190pub fn validate_settings(settings: &Settings) -> Result<(), SettingsError> {
192 if let Some(ctx_size) = settings.default_context_size
194 && !(512..=1_000_000).contains(&ctx_size)
195 {
196 return Err(SettingsError::InvalidContextSize(ctx_size));
197 }
198
199 if let Some(port) = settings.proxy_port
201 && port < 1024
202 {
203 return Err(SettingsError::InvalidPort(port));
204 }
205
206 if let Some(port) = settings.llama_base_port
208 && port < 1024
209 {
210 return Err(SettingsError::InvalidPort(port));
211 }
212
213 if let Some(queue_size) = settings.max_download_queue_size
215 && !(1..=50).contains(&queue_size)
216 {
217 return Err(SettingsError::InvalidQueueSize(queue_size));
218 }
219
220 if settings
222 .default_download_path
223 .as_ref()
224 .is_some_and(|p| p.trim().is_empty())
225 {
226 return Err(SettingsError::EmptyDownloadPath);
227 }
228
229 if let Some(ref inference_config) = settings.inference_defaults {
231 validate_inference_config(inference_config)
232 .map_err(SettingsError::InvalidInferenceConfig)?;
233 }
234
235 Ok(())
236}
237
238pub fn validate_inference_config(config: &InferenceConfig) -> Result<(), String> {
242 if let Some(temp) = config.temperature
244 && !(0.0..=2.0).contains(&temp)
245 {
246 return Err(format!(
247 "Temperature must be between 0.0 and 2.0, got {temp}"
248 ));
249 }
250
251 if let Some(top_p) = config.top_p
253 && !(0.0..=1.0).contains(&top_p)
254 {
255 return Err(format!("Top P must be between 0.0 and 1.0, got {top_p}"));
256 }
257
258 if let Some(top_k) = config.top_k
260 && top_k <= 0
261 {
262 return Err(format!("Top K must be positive, got {top_k}"));
263 }
264
265 if let Some(max_tokens) = config.max_tokens
267 && max_tokens == 0
268 {
269 return Err("Max tokens must be positive".to_string());
270 }
271
272 if let Some(repeat_penalty) = config.repeat_penalty
274 && repeat_penalty <= 0.0
275 {
276 return Err(format!(
277 "Repeat penalty must be positive, got {repeat_penalty}"
278 ));
279 }
280
281 if let Some(pp) = config.presence_penalty
283 && !(0.0..=2.0).contains(&pp)
284 {
285 return Err(format!(
286 "Presence penalty must be between 0.0 and 2.0, got {pp}"
287 ));
288 }
289
290 if let Some(mp) = config.min_p
292 && !(0.0..=1.0).contains(&mp)
293 {
294 return Err(format!("Min P must be between 0.0 and 1.0, got {mp}"));
295 }
296
297 Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn test_default_settings() {
306 let settings = Settings::with_defaults();
307 assert_eq!(settings.default_context_size, Some(4096));
308 assert_eq!(settings.proxy_port, Some(DEFAULT_PROXY_PORT));
309 assert_eq!(settings.llama_base_port, Some(DEFAULT_LLAMA_BASE_PORT));
310 assert_eq!(settings.default_download_path, None);
311 assert_eq!(settings.max_download_queue_size, Some(10));
312 assert_eq!(settings.show_memory_fit_indicators, Some(true));
313 }
314
315 #[test]
316 fn test_validate_settings_valid() {
317 let settings = Settings::with_defaults();
318 assert!(validate_settings(&settings).is_ok());
319 }
320
321 #[test]
322 fn test_validate_context_size_too_small() {
323 let settings = Settings {
324 default_context_size: Some(100),
325 ..Default::default()
326 };
327 assert!(matches!(
328 validate_settings(&settings),
329 Err(SettingsError::InvalidContextSize(100))
330 ));
331 }
332
333 #[test]
334 fn test_validate_context_size_too_large() {
335 let settings = Settings {
336 default_context_size: Some(2_000_000),
337 ..Default::default()
338 };
339 assert!(matches!(
340 validate_settings(&settings),
341 Err(SettingsError::InvalidContextSize(2_000_000))
342 ));
343 }
344
345 #[test]
346 fn test_validate_port_too_low() {
347 let settings = Settings {
348 proxy_port: Some(80),
349 ..Default::default()
350 };
351 assert!(matches!(
352 validate_settings(&settings),
353 Err(SettingsError::InvalidPort(80))
354 ));
355 }
356
357 #[test]
358 fn test_validate_empty_path() {
359 let settings = Settings {
360 default_download_path: Some(String::new()),
361 ..Default::default()
362 };
363 assert!(matches!(
364 validate_settings(&settings),
365 Err(SettingsError::EmptyDownloadPath)
366 ));
367 }
368
369 #[test]
370 fn test_validate_inference_config_valid() {
371 let config = InferenceConfig {
372 temperature: Some(0.7),
373 top_p: Some(0.9),
374 top_k: Some(40),
375 max_tokens: Some(2048),
376 repeat_penalty: Some(1.1),
377 presence_penalty: Some(0.0),
378 min_p: Some(0.0),
379 };
380 assert!(validate_inference_config(&config).is_ok());
381 }
382
383 #[test]
384 fn test_validate_inference_config_temperature_out_of_range() {
385 let config = InferenceConfig {
386 temperature: Some(2.5),
387 ..Default::default()
388 };
389 assert!(validate_inference_config(&config).is_err());
390
391 let config = InferenceConfig {
392 temperature: Some(-0.1),
393 ..Default::default()
394 };
395 assert!(validate_inference_config(&config).is_err());
396 }
397
398 #[test]
399 fn test_validate_inference_config_top_p_out_of_range() {
400 let config = InferenceConfig {
401 top_p: Some(1.5),
402 ..Default::default()
403 };
404 assert!(validate_inference_config(&config).is_err());
405
406 let config = InferenceConfig {
407 top_p: Some(-0.1),
408 ..Default::default()
409 };
410 assert!(validate_inference_config(&config).is_err());
411 }
412
413 #[test]
414 fn test_validate_inference_config_negative_values() {
415 let config = InferenceConfig {
416 top_k: Some(-1),
417 ..Default::default()
418 };
419 assert!(validate_inference_config(&config).is_err());
420
421 let config = InferenceConfig {
422 repeat_penalty: Some(0.0),
423 ..Default::default()
424 };
425 assert!(validate_inference_config(&config).is_err());
426 }
427
428 #[test]
429 fn test_settings_with_valid_inference_defaults() {
430 let settings = Settings {
431 inference_defaults: Some(InferenceConfig {
432 temperature: Some(0.8),
433 top_p: Some(0.95),
434 ..Default::default()
435 }),
436 ..Settings::with_defaults()
437 };
438 assert!(validate_settings(&settings).is_ok());
439 }
440
441 #[test]
442 fn test_settings_with_invalid_inference_defaults() {
443 let settings = Settings {
444 inference_defaults: Some(InferenceConfig {
445 temperature: Some(3.0), ..Default::default()
447 }),
448 ..Settings::with_defaults()
449 };
450 assert!(validate_settings(&settings).is_err());
451 }
452
453 #[test]
454 fn test_validate_queue_size_too_small() {
455 let settings = Settings {
456 max_download_queue_size: Some(0),
457 ..Default::default()
458 };
459 assert!(matches!(
460 validate_settings(&settings),
461 Err(SettingsError::InvalidQueueSize(0))
462 ));
463 }
464
465 #[test]
466 fn test_validate_queue_size_too_large() {
467 let settings = Settings {
468 max_download_queue_size: Some(100),
469 ..Default::default()
470 };
471 assert!(matches!(
472 validate_settings(&settings),
473 Err(SettingsError::InvalidQueueSize(100))
474 ));
475 }
476
477 #[test]
478 fn test_merge_settings() {
479 let mut settings = Settings::with_defaults();
480 let update = SettingsUpdate {
481 default_context_size: Some(Some(8192)),
482 proxy_port: Some(None), ..Default::default()
484 };
485 settings.merge(&update);
486
487 assert_eq!(settings.default_context_size, Some(8192));
488 assert_eq!(settings.proxy_port, None);
489 assert_eq!(settings.llama_base_port, Some(DEFAULT_LLAMA_BASE_PORT)); }
491
492 #[test]
493 fn test_effective_ports() {
494 let settings = Settings::with_defaults();
495 assert_eq!(settings.effective_proxy_port(), DEFAULT_PROXY_PORT);
496 assert_eq!(
497 settings.effective_llama_base_port(),
498 DEFAULT_LLAMA_BASE_PORT
499 );
500
501 let settings_none = Settings::default();
502 assert_eq!(settings_none.effective_proxy_port(), DEFAULT_PROXY_PORT);
503 assert_eq!(
504 settings_none.effective_llama_base_port(),
505 DEFAULT_LLAMA_BASE_PORT
506 );
507 }
508}