1use serde::{Deserialize, Serialize};
7
8use crate::domain::{InferenceConfig, InferenceProfile};
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 #[serde(default)]
67 pub inference_profiles: Option<Vec<InferenceProfile>>,
68
69 pub setup_completed: Option<bool>,
72
73 pub title_generation_prompt: Option<String>,
75
76 pub bind_host: Option<String>,
82
83 pub share_lan: Option<bool>,
88
89 pub trust_client_sampling: Option<bool>,
112}
113
114impl Settings {
115 #[must_use]
117 pub const fn with_defaults() -> Self {
118 Self {
119 default_download_path: None,
120 default_context_size: Some(DEFAULT_CONTEXT_SIZE),
121 proxy_port: Some(DEFAULT_PROXY_PORT),
122 llama_base_port: Some(DEFAULT_LLAMA_BASE_PORT),
123 max_download_queue_size: Some(10),
124 show_memory_fit_indicators: Some(true),
125 #[allow(clippy::cast_possible_truncation)] max_tool_iterations: Some(crate::domain::agent::DEFAULT_MAX_ITERATIONS as u32),
127 #[allow(clippy::cast_possible_truncation)]
128 max_stagnation_steps: Some(crate::domain::agent::DEFAULT_MAX_STAGNATION_STEPS as u32),
129 default_model_id: None,
130 inference_defaults: None,
131 inference_profiles: None,
132 setup_completed: None,
133 title_generation_prompt: None,
134 bind_host: None,
135 share_lan: None,
136 trust_client_sampling: None,
137 }
138 }
139
140 #[must_use]
142 pub const fn effective_proxy_port(&self) -> u16 {
143 match self.proxy_port {
144 Some(port) => port,
145 None => DEFAULT_PROXY_PORT,
146 }
147 }
148
149 #[must_use]
151 pub const fn effective_llama_base_port(&self) -> u16 {
152 match self.llama_base_port {
153 Some(port) => port,
154 None => DEFAULT_LLAMA_BASE_PORT,
155 }
156 }
157
158 pub fn merge(&mut self, other: &SettingsUpdate) {
160 if let Some(ref path) = other.default_download_path {
161 self.default_download_path.clone_from(path);
162 }
163 if let Some(ref ctx_size) = other.default_context_size {
164 self.default_context_size = *ctx_size;
165 }
166 if let Some(ref port) = other.proxy_port {
167 self.proxy_port = *port;
168 }
169 if let Some(ref port) = other.llama_base_port {
170 self.llama_base_port = *port;
171 }
172 if let Some(ref queue_size) = other.max_download_queue_size {
173 self.max_download_queue_size = *queue_size;
174 }
175 if let Some(ref show_fit) = other.show_memory_fit_indicators {
176 self.show_memory_fit_indicators = *show_fit;
177 }
178 if let Some(ref iters) = other.max_tool_iterations {
179 self.max_tool_iterations = *iters;
180 }
181 if let Some(ref steps) = other.max_stagnation_steps {
182 self.max_stagnation_steps = *steps;
183 }
184 if let Some(ref model_id) = other.default_model_id {
185 self.default_model_id = *model_id;
186 }
187 if let Some(ref inference_defaults) = other.inference_defaults {
188 self.inference_defaults.clone_from(inference_defaults);
189 }
190 if let Some(ref inference_profiles) = other.inference_profiles {
191 self.inference_profiles.clone_from(inference_profiles);
192 }
193 if let Some(ref v) = other.setup_completed {
194 self.setup_completed = *v;
195 }
196 if let Some(ref v) = other.title_generation_prompt {
197 self.title_generation_prompt.clone_from(v);
198 }
199 if let Some(ref v) = other.bind_host {
200 self.bind_host.clone_from(v);
201 }
202 if let Some(ref v) = other.share_lan {
203 self.share_lan = *v;
204 }
205 if let Some(ref v) = other.trust_client_sampling {
206 self.trust_client_sampling = *v;
207 }
208 }
209}
210
211#[derive(Debug, Clone, Default, Serialize, Deserialize)]
218pub struct SettingsUpdate {
219 pub default_download_path: Option<Option<String>>,
220 pub default_context_size: Option<Option<u64>>,
221 pub proxy_port: Option<Option<u16>>,
222 pub llama_base_port: Option<Option<u16>>,
223 pub max_download_queue_size: Option<Option<u32>>,
224 pub show_memory_fit_indicators: Option<Option<bool>>,
225 pub max_tool_iterations: Option<Option<u32>>,
226 pub max_stagnation_steps: Option<Option<u32>>,
227 pub default_model_id: Option<Option<i64>>,
228 pub inference_defaults: Option<Option<InferenceConfig>>,
229 pub inference_profiles: Option<Option<Vec<InferenceProfile>>>,
230 pub setup_completed: Option<Option<bool>>,
231 pub title_generation_prompt: Option<Option<String>>,
232 pub bind_host: Option<Option<String>>,
233 pub share_lan: Option<Option<bool>>,
234 pub trust_client_sampling: Option<Option<bool>>,
235}
236
237#[derive(Debug, Clone, thiserror::Error)]
239pub enum SettingsError {
240 #[error("Context size must be between 512 and 1,000,000, got {0}")]
241 InvalidContextSize(u64),
242
243 #[error("Port should be >= 1024 (privileged ports require root), got {0}")]
244 InvalidPort(u16),
245
246 #[error("Max download queue size must be between 1 and 50, got {0}")]
247 InvalidQueueSize(u32),
248
249 #[error("Download path cannot be empty")]
250 EmptyDownloadPath,
251
252 #[error("Invalid inference parameter: {0}")]
253 InvalidInferenceConfig(String),
254
255 #[error("Invalid inference profile: {0}")]
256 InvalidInferenceProfile(String),
257
258 #[error("Bind host must be an IP address (e.g. 127.0.0.1 or 0.0.0.0), got '{0}'")]
259 InvalidBindHost(String),
260}
261
262pub fn validate_settings(settings: &Settings) -> Result<(), SettingsError> {
264 if let Some(ctx_size) = settings.default_context_size
266 && !(512..=1_000_000).contains(&ctx_size)
267 {
268 return Err(SettingsError::InvalidContextSize(ctx_size));
269 }
270
271 if let Some(port) = settings.proxy_port
273 && port < 1024
274 {
275 return Err(SettingsError::InvalidPort(port));
276 }
277
278 if let Some(port) = settings.llama_base_port
280 && port < 1024
281 {
282 return Err(SettingsError::InvalidPort(port));
283 }
284
285 if let Some(queue_size) = settings.max_download_queue_size
287 && !(1..=50).contains(&queue_size)
288 {
289 return Err(SettingsError::InvalidQueueSize(queue_size));
290 }
291
292 if settings
294 .default_download_path
295 .as_ref()
296 .is_some_and(|p| p.trim().is_empty())
297 {
298 return Err(SettingsError::EmptyDownloadPath);
299 }
300
301 if let Some(ref host) = settings.bind_host
305 && host.parse::<std::net::IpAddr>().is_err()
306 {
307 return Err(SettingsError::InvalidBindHost(host.clone()));
308 }
309
310 if let Some(ref inference_config) = settings.inference_defaults {
312 validate_inference_config(inference_config)
313 .map_err(SettingsError::InvalidInferenceConfig)?;
314 }
315
316 if let Some(ref profiles) = settings.inference_profiles {
318 validate_inference_profiles(profiles).map_err(SettingsError::InvalidInferenceProfile)?;
319 }
320
321 Ok(())
322}
323
324pub fn validate_inference_profiles(profiles: &[InferenceProfile]) -> Result<(), String> {
335 let mut seen: Vec<&str> = Vec::with_capacity(profiles.len());
336
337 for profile in profiles {
338 profile.validate().map_err(|e| e.to_string())?;
339
340 if seen.contains(&profile.name.as_str()) {
341 return Err(format!("duplicate profile name '{}'", profile.name));
342 }
343 seen.push(&profile.name);
344
345 validate_inference_config(&profile.config)
346 .map_err(|e| format!("profile '{}': {e}", profile.name))?;
347 }
348
349 Ok(())
350}
351
352pub fn validate_inference_config(config: &InferenceConfig) -> Result<(), String> {
356 if let Some(temp) = config.temperature
358 && !(0.0..=2.0).contains(&temp)
359 {
360 return Err(format!(
361 "Temperature must be between 0.0 and 2.0, got {temp}"
362 ));
363 }
364
365 if let Some(top_p) = config.top_p
367 && !(0.0..=1.0).contains(&top_p)
368 {
369 return Err(format!("Top P must be between 0.0 and 1.0, got {top_p}"));
370 }
371
372 if let Some(top_k) = config.top_k
374 && top_k <= 0
375 {
376 return Err(format!("Top K must be positive, got {top_k}"));
377 }
378
379 if let Some(max_tokens) = config.max_tokens
381 && max_tokens == 0
382 {
383 return Err("Max tokens must be positive".to_string());
384 }
385
386 if let Some(repeat_penalty) = config.repeat_penalty
388 && repeat_penalty <= 0.0
389 {
390 return Err(format!(
391 "Repeat penalty must be positive, got {repeat_penalty}"
392 ));
393 }
394
395 if let Some(pp) = config.presence_penalty
397 && !(0.0..=2.0).contains(&pp)
398 {
399 return Err(format!(
400 "Presence penalty must be between 0.0 and 2.0, got {pp}"
401 ));
402 }
403
404 if let Some(mp) = config.min_p
406 && !(0.0..=1.0).contains(&mp)
407 {
408 return Err(format!("Min P must be between 0.0 and 1.0, got {mp}"));
409 }
410
411 Ok(())
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn test_default_settings() {
420 let settings = Settings::with_defaults();
421 assert_eq!(settings.default_context_size, Some(4096));
422 assert_eq!(settings.proxy_port, Some(DEFAULT_PROXY_PORT));
423 assert_eq!(settings.llama_base_port, Some(DEFAULT_LLAMA_BASE_PORT));
424 assert_eq!(settings.default_download_path, None);
425 assert_eq!(settings.max_download_queue_size, Some(10));
426 assert_eq!(settings.show_memory_fit_indicators, Some(true));
427 }
428
429 #[test]
430 fn test_validate_settings_valid() {
431 let settings = Settings::with_defaults();
432 assert!(validate_settings(&settings).is_ok());
433 }
434
435 #[test]
436 fn test_validate_context_size_too_small() {
437 let settings = Settings {
438 default_context_size: Some(100),
439 ..Default::default()
440 };
441 assert!(matches!(
442 validate_settings(&settings),
443 Err(SettingsError::InvalidContextSize(100))
444 ));
445 }
446
447 #[test]
448 fn test_validate_context_size_too_large() {
449 let settings = Settings {
450 default_context_size: Some(2_000_000),
451 ..Default::default()
452 };
453 assert!(matches!(
454 validate_settings(&settings),
455 Err(SettingsError::InvalidContextSize(2_000_000))
456 ));
457 }
458
459 #[test]
460 fn test_validate_port_too_low() {
461 let settings = Settings {
462 proxy_port: Some(80),
463 ..Default::default()
464 };
465 assert!(matches!(
466 validate_settings(&settings),
467 Err(SettingsError::InvalidPort(80))
468 ));
469 }
470
471 #[test]
472 fn test_validate_empty_path() {
473 let settings = Settings {
474 default_download_path: Some(String::new()),
475 ..Default::default()
476 };
477 assert!(matches!(
478 validate_settings(&settings),
479 Err(SettingsError::EmptyDownloadPath)
480 ));
481 }
482
483 #[test]
484 fn test_validate_inference_config_valid() {
485 let config = InferenceConfig {
486 temperature: Some(0.7),
487 top_p: Some(0.9),
488 top_k: Some(40),
489 max_tokens: Some(2048),
490 repeat_penalty: Some(1.1),
491 presence_penalty: Some(0.0),
492 min_p: Some(0.0),
493 };
494 assert!(validate_inference_config(&config).is_ok());
495 }
496
497 #[test]
498 fn test_validate_inference_config_temperature_out_of_range() {
499 let config = InferenceConfig {
500 temperature: Some(2.5),
501 ..Default::default()
502 };
503 assert!(validate_inference_config(&config).is_err());
504
505 let config = InferenceConfig {
506 temperature: Some(-0.1),
507 ..Default::default()
508 };
509 assert!(validate_inference_config(&config).is_err());
510 }
511
512 #[test]
513 fn test_validate_inference_config_top_p_out_of_range() {
514 let config = InferenceConfig {
515 top_p: Some(1.5),
516 ..Default::default()
517 };
518 assert!(validate_inference_config(&config).is_err());
519
520 let config = InferenceConfig {
521 top_p: Some(-0.1),
522 ..Default::default()
523 };
524 assert!(validate_inference_config(&config).is_err());
525 }
526
527 #[test]
528 fn test_validate_inference_config_negative_values() {
529 let config = InferenceConfig {
530 top_k: Some(-1),
531 ..Default::default()
532 };
533 assert!(validate_inference_config(&config).is_err());
534
535 let config = InferenceConfig {
536 repeat_penalty: Some(0.0),
537 ..Default::default()
538 };
539 assert!(validate_inference_config(&config).is_err());
540 }
541
542 #[test]
543 fn test_settings_with_valid_inference_defaults() {
544 let settings = Settings {
545 inference_defaults: Some(InferenceConfig {
546 temperature: Some(0.8),
547 top_p: Some(0.95),
548 ..Default::default()
549 }),
550 ..Settings::with_defaults()
551 };
552 assert!(validate_settings(&settings).is_ok());
553 }
554
555 #[test]
556 fn test_settings_with_invalid_inference_defaults() {
557 let settings = Settings {
558 inference_defaults: Some(InferenceConfig {
559 temperature: Some(3.0), ..Default::default()
561 }),
562 ..Settings::with_defaults()
563 };
564 assert!(validate_settings(&settings).is_err());
565 }
566
567 #[test]
568 fn test_validate_queue_size_too_small() {
569 let settings = Settings {
570 max_download_queue_size: Some(0),
571 ..Default::default()
572 };
573 assert!(matches!(
574 validate_settings(&settings),
575 Err(SettingsError::InvalidQueueSize(0))
576 ));
577 }
578
579 #[test]
580 fn test_validate_queue_size_too_large() {
581 let settings = Settings {
582 max_download_queue_size: Some(100),
583 ..Default::default()
584 };
585 assert!(matches!(
586 validate_settings(&settings),
587 Err(SettingsError::InvalidQueueSize(100))
588 ));
589 }
590
591 #[test]
592 fn test_merge_settings() {
593 let mut settings = Settings::with_defaults();
594 let update = SettingsUpdate {
595 default_context_size: Some(Some(8192)),
596 proxy_port: Some(None), ..Default::default()
598 };
599 settings.merge(&update);
600
601 assert_eq!(settings.default_context_size, Some(8192));
602 assert_eq!(settings.proxy_port, None);
603 assert_eq!(settings.llama_base_port, Some(DEFAULT_LLAMA_BASE_PORT)); }
605
606 #[test]
607 fn test_trust_client_sampling_defaults_to_none_and_merges_like_any_bool_setting() {
608 let defaults = Settings::with_defaults();
609 assert_eq!(defaults.trust_client_sampling, None);
610
611 let mut settings = Settings::with_defaults();
612 settings.merge(&SettingsUpdate {
613 trust_client_sampling: Some(Some(true)),
614 ..Default::default()
615 });
616 assert_eq!(settings.trust_client_sampling, Some(true));
617
618 settings.merge(&SettingsUpdate {
619 trust_client_sampling: Some(None),
620 ..Default::default()
621 });
622 assert_eq!(settings.trust_client_sampling, None);
623 }
624
625 #[test]
626 fn test_effective_ports() {
627 let settings = Settings::with_defaults();
628 assert_eq!(settings.effective_proxy_port(), DEFAULT_PROXY_PORT);
629 assert_eq!(
630 settings.effective_llama_base_port(),
631 DEFAULT_LLAMA_BASE_PORT
632 );
633
634 let settings_none = Settings::default();
635 assert_eq!(settings_none.effective_proxy_port(), DEFAULT_PROXY_PORT);
636 assert_eq!(
637 settings_none.effective_llama_base_port(),
638 DEFAULT_LLAMA_BASE_PORT
639 );
640 }
641
642 fn profile(name: &str, temperature: f32) -> InferenceProfile {
645 InferenceProfile {
646 name: name.to_owned(),
647 description: None,
648 config: InferenceConfig {
649 temperature: Some(temperature),
650 ..Default::default()
651 },
652 list_in_models: false,
653 }
654 }
655
656 #[test]
657 fn test_builtin_templates_pass_settings_validation() {
658 let settings = Settings {
659 inference_profiles: Some(crate::domain::builtin_templates()),
660 ..Settings::with_defaults()
661 };
662 assert!(validate_settings(&settings).is_ok());
663 }
664
665 #[test]
666 fn test_validate_profiles_rejects_duplicate_names() {
667 let err = validate_inference_profiles(&[profile("coding", 0.2), profile("coding", 0.9)])
668 .expect_err("duplicates must be rejected");
669 assert!(err.contains("duplicate"), "unexpected message: {err}");
670 assert!(err.contains("coding"), "message should name the profile");
671 }
672
673 #[test]
674 fn test_validate_profiles_rejects_invalid_name() {
675 let err = validate_inference_profiles(&[profile("Not_A_Slug", 0.5)])
676 .expect_err("invalid slug must be rejected");
677 assert!(err.contains("Not_A_Slug"), "unexpected message: {err}");
678 }
679
680 #[test]
683 fn test_validate_profiles_reuses_inference_config_ranges() {
684 let err = validate_inference_profiles(&[profile("coding", 5.0)])
685 .expect_err("out-of-range temperature must be rejected");
686 assert!(err.contains("coding"), "message should name the profile");
687 assert!(err.contains("Temperature"), "unexpected message: {err}");
688 }
689
690 #[test]
691 fn test_settings_with_invalid_profile_fails_validation() {
692 let settings = Settings {
693 inference_profiles: Some(vec![profile("coding", 5.0)]),
694 ..Settings::with_defaults()
695 };
696 assert!(matches!(
697 validate_settings(&settings),
698 Err(SettingsError::InvalidInferenceProfile(_))
699 ));
700 }
701
702 #[test]
703 fn test_merge_replaces_and_clears_profiles() {
704 let mut settings = Settings {
705 inference_profiles: Some(vec![profile("coding", 0.2)]),
706 ..Settings::with_defaults()
707 };
708
709 settings.merge(&SettingsUpdate {
710 inference_profiles: Some(Some(vec![profile("chat", 0.7)])),
711 ..Default::default()
712 });
713 let profiles = settings.inference_profiles.as_ref().expect("still set");
714 assert_eq!(profiles.len(), 1);
715 assert_eq!(profiles[0].name, "chat");
716
717 settings.merge(&SettingsUpdate {
718 inference_profiles: Some(None),
719 ..Default::default()
720 });
721 assert_eq!(settings.inference_profiles, None);
722
723 settings.inference_profiles = Some(vec![profile("coding", 0.2)]);
725 settings.merge(&SettingsUpdate::default());
726 assert!(settings.inference_profiles.is_some());
727 }
728
729 #[test]
733 fn test_profiles_round_trip_through_json_and_default_when_absent() {
734 let settings = Settings {
735 inference_profiles: Some(vec![profile("coding", 0.2)]),
736 ..Settings::with_defaults()
737 };
738 let value = serde_json::to_value(&settings).expect("serializes");
739 assert!(value.get("inference_profiles").is_some());
740
741 let restored: Settings = serde_json::from_value(value).expect("round-trips");
742 assert_eq!(restored.inference_profiles, settings.inference_profiles);
743
744 let absent: Settings = serde_json::from_str("{}").expect("deserializes without the field");
745 assert_eq!(absent.inference_profiles, None);
746 }
747}