1use serde::{Deserialize, Serialize};
7
8use crate::domain::{InferenceConfig, InferenceProfile};
9
10pub const DEFAULT_PROXY_PORT: u16 = 8080;
12
13pub const DAEMON_PORT: u16 = 9887;
20
21pub const DEFAULT_LLAMA_BASE_PORT: u16 = 9000;
23
24pub const DEFAULT_CONTEXT_SIZE: u64 = 4096;
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
31#[serde(default)]
32pub struct Settings {
33 pub default_download_path: Option<String>,
35
36 pub default_context_size: Option<u64>,
38
39 pub proxy_port: Option<u16>,
41
42 pub llama_base_port: Option<u16>,
45
46 pub max_download_queue_size: Option<u32>,
48
49 pub show_memory_fit_indicators: Option<bool>,
51
52 pub max_tool_iterations: Option<u32>,
54
55 pub max_stagnation_steps: Option<u32>,
57
58 pub default_model_id: Option<i64>,
60
61 #[serde(default)]
66 pub inference_defaults: Option<InferenceConfig>,
67
68 #[serde(default)]
75 pub inference_profiles: Option<Vec<InferenceProfile>>,
76
77 pub setup_completed: Option<bool>,
80
81 pub title_generation_prompt: Option<String>,
83
84 pub bind_host: Option<String>,
90
91 pub share_lan: Option<bool>,
96
97 pub proxy_api_key: Option<String>,
108
109 pub trust_client_sampling: Option<bool>,
132
133 pub proxy_autostart: Option<bool>,
146
147 pub close_to_tray: Option<bool>,
155
156 pub start_at_login: Option<bool>,
163}
164
165impl Settings {
166 #[must_use]
168 pub const fn with_defaults() -> Self {
169 Self {
170 default_download_path: None,
171 default_context_size: Some(DEFAULT_CONTEXT_SIZE),
172 proxy_port: Some(DEFAULT_PROXY_PORT),
173 llama_base_port: Some(DEFAULT_LLAMA_BASE_PORT),
174 max_download_queue_size: Some(10),
175 show_memory_fit_indicators: Some(true),
176 #[allow(clippy::cast_possible_truncation)] max_tool_iterations: Some(crate::domain::agent::DEFAULT_MAX_ITERATIONS as u32),
178 #[allow(clippy::cast_possible_truncation)]
179 max_stagnation_steps: Some(crate::domain::agent::DEFAULT_MAX_STAGNATION_STEPS as u32),
180 default_model_id: None,
181 inference_defaults: None,
182 inference_profiles: None,
183 setup_completed: None,
184 title_generation_prompt: None,
185 bind_host: None,
186 share_lan: None,
187 proxy_api_key: None,
188 trust_client_sampling: None,
189 proxy_autostart: None,
190 close_to_tray: None,
191 start_at_login: None,
192 }
193 }
194
195 #[must_use]
197 pub const fn effective_proxy_port(&self) -> u16 {
198 match self.proxy_port {
199 Some(port) => port,
200 None => DEFAULT_PROXY_PORT,
201 }
202 }
203
204 #[must_use]
206 pub const fn effective_llama_base_port(&self) -> u16 {
207 match self.llama_base_port {
208 Some(port) => port,
209 None => DEFAULT_LLAMA_BASE_PORT,
210 }
211 }
212
213 pub fn merge(&mut self, other: &SettingsUpdate) {
215 if let Some(ref path) = other.default_download_path {
216 self.default_download_path.clone_from(path);
217 }
218 if let Some(ref ctx_size) = other.default_context_size {
219 self.default_context_size = *ctx_size;
220 }
221 if let Some(ref port) = other.proxy_port {
222 self.proxy_port = *port;
223 }
224 if let Some(ref port) = other.llama_base_port {
225 self.llama_base_port = *port;
226 }
227 if let Some(ref queue_size) = other.max_download_queue_size {
228 self.max_download_queue_size = *queue_size;
229 }
230 if let Some(ref show_fit) = other.show_memory_fit_indicators {
231 self.show_memory_fit_indicators = *show_fit;
232 }
233 if let Some(ref iters) = other.max_tool_iterations {
234 self.max_tool_iterations = *iters;
235 }
236 if let Some(ref steps) = other.max_stagnation_steps {
237 self.max_stagnation_steps = *steps;
238 }
239 if let Some(ref model_id) = other.default_model_id {
240 self.default_model_id = *model_id;
241 }
242 if let Some(ref inference_defaults) = other.inference_defaults {
243 self.inference_defaults.clone_from(inference_defaults);
244 }
245 if let Some(ref inference_profiles) = other.inference_profiles {
246 self.inference_profiles.clone_from(inference_profiles);
247 }
248 if let Some(ref v) = other.setup_completed {
249 self.setup_completed = *v;
250 }
251 if let Some(ref v) = other.title_generation_prompt {
252 self.title_generation_prompt.clone_from(v);
253 }
254 if let Some(ref v) = other.bind_host {
255 self.bind_host.clone_from(v);
256 }
257 if let Some(ref v) = other.share_lan {
258 self.share_lan = *v;
259 }
260 if let Some(ref v) = other.proxy_api_key {
261 self.proxy_api_key.clone_from(v);
262 }
263 if let Some(ref v) = other.trust_client_sampling {
264 self.trust_client_sampling = *v;
265 }
266 if let Some(ref v) = other.proxy_autostart {
267 self.proxy_autostart = *v;
268 }
269 if let Some(ref v) = other.close_to_tray {
270 self.close_to_tray = *v;
271 }
272 if let Some(ref v) = other.start_at_login {
273 self.start_at_login = *v;
274 }
275 }
276}
277
278#[derive(Debug, Clone, Default, Serialize, Deserialize)]
285pub struct SettingsUpdate {
286 pub default_download_path: Option<Option<String>>,
287 pub default_context_size: Option<Option<u64>>,
288 pub proxy_port: Option<Option<u16>>,
289 pub llama_base_port: Option<Option<u16>>,
290 pub max_download_queue_size: Option<Option<u32>>,
291 pub show_memory_fit_indicators: Option<Option<bool>>,
292 pub max_tool_iterations: Option<Option<u32>>,
293 pub max_stagnation_steps: Option<Option<u32>>,
294 pub default_model_id: Option<Option<i64>>,
295 pub inference_defaults: Option<Option<InferenceConfig>>,
296 pub inference_profiles: Option<Option<Vec<InferenceProfile>>>,
297 pub setup_completed: Option<Option<bool>>,
298 pub title_generation_prompt: Option<Option<String>>,
299 pub bind_host: Option<Option<String>>,
300 pub share_lan: Option<Option<bool>>,
301 pub proxy_api_key: Option<Option<String>>,
302 pub trust_client_sampling: Option<Option<bool>>,
303 pub proxy_autostart: Option<Option<bool>>,
304 pub close_to_tray: Option<Option<bool>>,
305 pub start_at_login: Option<Option<bool>>,
306}
307
308#[derive(Debug, Clone, thiserror::Error)]
310pub enum SettingsError {
311 #[error("Context size must be between 512 and 1,000,000, got {0}")]
312 InvalidContextSize(u64),
313
314 #[error("Port should be >= 1024 (privileged ports require root), got {0}")]
315 InvalidPort(u16),
316
317 #[error("Max download queue size must be between 1 and 50, got {0}")]
318 InvalidQueueSize(u32),
319
320 #[error("Download path cannot be empty")]
321 EmptyDownloadPath,
322
323 #[error("Invalid inference parameter: {0}")]
324 InvalidInferenceConfig(String),
325
326 #[error("Invalid inference profile: {0}")]
327 InvalidInferenceProfile(String),
328
329 #[error("Bind host must be an IP address (e.g. 127.0.0.1 or 0.0.0.0), got '{0}'")]
330 InvalidBindHost(String),
331
332 #[error("Proxy API key cannot be blank — clear it instead to disable authentication")]
333 BlankProxyApiKey,
334}
335
336pub fn validate_settings(settings: &Settings) -> Result<(), SettingsError> {
338 if let Some(ctx_size) = settings.default_context_size
340 && !(512..=1_000_000).contains(&ctx_size)
341 {
342 return Err(SettingsError::InvalidContextSize(ctx_size));
343 }
344
345 if let Some(port) = settings.proxy_port
347 && port < 1024
348 {
349 return Err(SettingsError::InvalidPort(port));
350 }
351
352 if let Some(port) = settings.llama_base_port
354 && port < 1024
355 {
356 return Err(SettingsError::InvalidPort(port));
357 }
358
359 if let Some(queue_size) = settings.max_download_queue_size
361 && !(1..=50).contains(&queue_size)
362 {
363 return Err(SettingsError::InvalidQueueSize(queue_size));
364 }
365
366 if settings
368 .default_download_path
369 .as_ref()
370 .is_some_and(|p| p.trim().is_empty())
371 {
372 return Err(SettingsError::EmptyDownloadPath);
373 }
374
375 if let Some(ref host) = settings.bind_host
379 && host.parse::<std::net::IpAddr>().is_err()
380 {
381 return Err(SettingsError::InvalidBindHost(host.clone()));
382 }
383
384 if settings
387 .proxy_api_key
388 .as_ref()
389 .is_some_and(|key| key.trim().is_empty())
390 {
391 return Err(SettingsError::BlankProxyApiKey);
392 }
393
394 if let Some(ref inference_config) = settings.inference_defaults {
396 validate_inference_config(inference_config)
397 .map_err(SettingsError::InvalidInferenceConfig)?;
398 }
399
400 if let Some(ref profiles) = settings.inference_profiles {
402 validate_inference_profiles(profiles).map_err(SettingsError::InvalidInferenceProfile)?;
403 }
404
405 Ok(())
406}
407
408pub fn validate_inference_profiles(profiles: &[InferenceProfile]) -> Result<(), String> {
419 let mut seen: Vec<&str> = Vec::with_capacity(profiles.len());
420
421 for profile in profiles {
422 profile.validate().map_err(|e| e.to_string())?;
423
424 if seen.contains(&profile.name.as_str()) {
425 return Err(format!("duplicate profile name '{}'", profile.name));
426 }
427 seen.push(&profile.name);
428
429 validate_inference_config(&profile.config)
430 .map_err(|e| format!("profile '{}': {e}", profile.name))?;
431 }
432
433 Ok(())
434}
435
436pub fn validate_inference_config(config: &InferenceConfig) -> Result<(), String> {
440 if let Some(temp) = config.temperature
442 && !(0.0..=2.0).contains(&temp)
443 {
444 return Err(format!(
445 "Temperature must be between 0.0 and 2.0, got {temp}"
446 ));
447 }
448
449 if let Some(top_p) = config.top_p
451 && !(0.0..=1.0).contains(&top_p)
452 {
453 return Err(format!("Top P must be between 0.0 and 1.0, got {top_p}"));
454 }
455
456 if let Some(top_k) = config.top_k
458 && top_k <= 0
459 {
460 return Err(format!("Top K must be positive, got {top_k}"));
461 }
462
463 if let Some(max_tokens) = config.max_tokens
465 && max_tokens == 0
466 {
467 return Err("Max tokens must be positive".to_string());
468 }
469
470 if let Some(repeat_penalty) = config.repeat_penalty
472 && repeat_penalty <= 0.0
473 {
474 return Err(format!(
475 "Repeat penalty must be positive, got {repeat_penalty}"
476 ));
477 }
478
479 if let Some(pp) = config.presence_penalty
481 && !(0.0..=2.0).contains(&pp)
482 {
483 return Err(format!(
484 "Presence penalty must be between 0.0 and 2.0, got {pp}"
485 ));
486 }
487
488 if let Some(mp) = config.min_p
490 && !(0.0..=1.0).contains(&mp)
491 {
492 return Err(format!("Min P must be between 0.0 and 1.0, got {mp}"));
493 }
494
495 Ok(())
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn test_default_settings() {
504 let settings = Settings::with_defaults();
505 assert_eq!(settings.default_context_size, Some(4096));
506 assert_eq!(settings.proxy_port, Some(DEFAULT_PROXY_PORT));
507 assert_eq!(settings.llama_base_port, Some(DEFAULT_LLAMA_BASE_PORT));
508 assert_eq!(settings.default_download_path, None);
509 assert_eq!(settings.max_download_queue_size, Some(10));
510 assert_eq!(settings.show_memory_fit_indicators, Some(true));
511 }
512
513 #[test]
514 fn test_validate_settings_valid() {
515 let settings = Settings::with_defaults();
516 assert!(validate_settings(&settings).is_ok());
517 }
518
519 #[test]
520 fn test_validate_context_size_too_small() {
521 let settings = Settings {
522 default_context_size: Some(100),
523 ..Default::default()
524 };
525 assert!(matches!(
526 validate_settings(&settings),
527 Err(SettingsError::InvalidContextSize(100))
528 ));
529 }
530
531 #[test]
532 fn test_validate_context_size_too_large() {
533 let settings = Settings {
534 default_context_size: Some(2_000_000),
535 ..Default::default()
536 };
537 assert!(matches!(
538 validate_settings(&settings),
539 Err(SettingsError::InvalidContextSize(2_000_000))
540 ));
541 }
542
543 #[test]
544 fn test_validate_port_too_low() {
545 let settings = Settings {
546 proxy_port: Some(80),
547 ..Default::default()
548 };
549 assert!(matches!(
550 validate_settings(&settings),
551 Err(SettingsError::InvalidPort(80))
552 ));
553 }
554
555 #[test]
556 fn test_validate_empty_path() {
557 let settings = Settings {
558 default_download_path: Some(String::new()),
559 ..Default::default()
560 };
561 assert!(matches!(
562 validate_settings(&settings),
563 Err(SettingsError::EmptyDownloadPath)
564 ));
565 }
566
567 #[test]
568 fn test_validate_inference_config_valid() {
569 let config = InferenceConfig {
570 temperature: Some(0.7),
571 top_p: Some(0.9),
572 top_k: Some(40),
573 max_tokens: Some(2048),
574 repeat_penalty: Some(1.1),
575 presence_penalty: Some(0.0),
576 min_p: Some(0.0),
577 };
578 assert!(validate_inference_config(&config).is_ok());
579 }
580
581 #[test]
582 fn test_validate_inference_config_temperature_out_of_range() {
583 let config = InferenceConfig {
584 temperature: Some(2.5),
585 ..Default::default()
586 };
587 assert!(validate_inference_config(&config).is_err());
588
589 let config = InferenceConfig {
590 temperature: Some(-0.1),
591 ..Default::default()
592 };
593 assert!(validate_inference_config(&config).is_err());
594 }
595
596 #[test]
597 fn test_validate_inference_config_top_p_out_of_range() {
598 let config = InferenceConfig {
599 top_p: Some(1.5),
600 ..Default::default()
601 };
602 assert!(validate_inference_config(&config).is_err());
603
604 let config = InferenceConfig {
605 top_p: Some(-0.1),
606 ..Default::default()
607 };
608 assert!(validate_inference_config(&config).is_err());
609 }
610
611 #[test]
612 fn test_validate_inference_config_negative_values() {
613 let config = InferenceConfig {
614 top_k: Some(-1),
615 ..Default::default()
616 };
617 assert!(validate_inference_config(&config).is_err());
618
619 let config = InferenceConfig {
620 repeat_penalty: Some(0.0),
621 ..Default::default()
622 };
623 assert!(validate_inference_config(&config).is_err());
624 }
625
626 #[test]
627 fn test_settings_with_valid_inference_defaults() {
628 let settings = Settings {
629 inference_defaults: Some(InferenceConfig {
630 temperature: Some(0.8),
631 top_p: Some(0.95),
632 ..Default::default()
633 }),
634 ..Settings::with_defaults()
635 };
636 assert!(validate_settings(&settings).is_ok());
637 }
638
639 #[test]
640 fn test_settings_with_invalid_inference_defaults() {
641 let settings = Settings {
642 inference_defaults: Some(InferenceConfig {
643 temperature: Some(3.0), ..Default::default()
645 }),
646 ..Settings::with_defaults()
647 };
648 assert!(validate_settings(&settings).is_err());
649 }
650
651 #[test]
652 fn test_validate_queue_size_too_small() {
653 let settings = Settings {
654 max_download_queue_size: Some(0),
655 ..Default::default()
656 };
657 assert!(matches!(
658 validate_settings(&settings),
659 Err(SettingsError::InvalidQueueSize(0))
660 ));
661 }
662
663 #[test]
664 fn test_validate_queue_size_too_large() {
665 let settings = Settings {
666 max_download_queue_size: Some(100),
667 ..Default::default()
668 };
669 assert!(matches!(
670 validate_settings(&settings),
671 Err(SettingsError::InvalidQueueSize(100))
672 ));
673 }
674
675 #[test]
676 fn test_merge_settings() {
677 let mut settings = Settings::with_defaults();
678 let update = SettingsUpdate {
679 default_context_size: Some(Some(8192)),
680 proxy_port: Some(None), ..Default::default()
682 };
683 settings.merge(&update);
684
685 assert_eq!(settings.default_context_size, Some(8192));
686 assert_eq!(settings.proxy_port, None);
687 assert_eq!(settings.llama_base_port, Some(DEFAULT_LLAMA_BASE_PORT)); }
689
690 #[test]
691 fn test_trust_client_sampling_defaults_to_none_and_merges_like_any_bool_setting() {
692 let defaults = Settings::with_defaults();
693 assert_eq!(defaults.trust_client_sampling, None);
694
695 let mut settings = Settings::with_defaults();
696 settings.merge(&SettingsUpdate {
697 trust_client_sampling: Some(Some(true)),
698 ..Default::default()
699 });
700 assert_eq!(settings.trust_client_sampling, Some(true));
701
702 settings.merge(&SettingsUpdate {
703 trust_client_sampling: Some(None),
704 ..Default::default()
705 });
706 assert_eq!(settings.trust_client_sampling, None);
707 }
708
709 #[test]
710 fn test_effective_ports() {
711 let settings = Settings::with_defaults();
712 assert_eq!(settings.effective_proxy_port(), DEFAULT_PROXY_PORT);
713 assert_eq!(
714 settings.effective_llama_base_port(),
715 DEFAULT_LLAMA_BASE_PORT
716 );
717
718 let settings_none = Settings::default();
719 assert_eq!(settings_none.effective_proxy_port(), DEFAULT_PROXY_PORT);
720 assert_eq!(
721 settings_none.effective_llama_base_port(),
722 DEFAULT_LLAMA_BASE_PORT
723 );
724 }
725
726 fn profile(name: &str, temperature: f32) -> InferenceProfile {
729 InferenceProfile {
730 name: name.to_owned(),
731 description: None,
732 config: InferenceConfig {
733 temperature: Some(temperature),
734 ..Default::default()
735 },
736 list_in_models: false,
737 }
738 }
739
740 #[test]
741 fn test_builtin_templates_pass_settings_validation() {
742 let settings = Settings {
743 inference_profiles: Some(crate::domain::builtin_templates()),
744 ..Settings::with_defaults()
745 };
746 assert!(validate_settings(&settings).is_ok());
747 }
748
749 #[test]
750 fn test_validate_profiles_rejects_duplicate_names() {
751 let err = validate_inference_profiles(&[profile("coding", 0.2), profile("coding", 0.9)])
752 .expect_err("duplicates must be rejected");
753 assert!(err.contains("duplicate"), "unexpected message: {err}");
754 assert!(err.contains("coding"), "message should name the profile");
755 }
756
757 #[test]
758 fn test_validate_profiles_rejects_invalid_name() {
759 let err = validate_inference_profiles(&[profile("Not_A_Slug", 0.5)])
760 .expect_err("invalid slug must be rejected");
761 assert!(err.contains("Not_A_Slug"), "unexpected message: {err}");
762 }
763
764 #[test]
767 fn test_validate_profiles_reuses_inference_config_ranges() {
768 let err = validate_inference_profiles(&[profile("coding", 5.0)])
769 .expect_err("out-of-range temperature must be rejected");
770 assert!(err.contains("coding"), "message should name the profile");
771 assert!(err.contains("Temperature"), "unexpected message: {err}");
772 }
773
774 #[test]
775 fn test_settings_with_invalid_profile_fails_validation() {
776 let settings = Settings {
777 inference_profiles: Some(vec![profile("coding", 5.0)]),
778 ..Settings::with_defaults()
779 };
780 assert!(matches!(
781 validate_settings(&settings),
782 Err(SettingsError::InvalidInferenceProfile(_))
783 ));
784 }
785
786 #[test]
787 fn test_merge_replaces_and_clears_profiles() {
788 let mut settings = Settings {
789 inference_profiles: Some(vec![profile("coding", 0.2)]),
790 ..Settings::with_defaults()
791 };
792
793 settings.merge(&SettingsUpdate {
794 inference_profiles: Some(Some(vec![profile("chat", 0.7)])),
795 ..Default::default()
796 });
797 let profiles = settings.inference_profiles.as_ref().expect("still set");
798 assert_eq!(profiles.len(), 1);
799 assert_eq!(profiles[0].name, "chat");
800
801 settings.merge(&SettingsUpdate {
802 inference_profiles: Some(None),
803 ..Default::default()
804 });
805 assert_eq!(settings.inference_profiles, None);
806
807 settings.inference_profiles = Some(vec![profile("coding", 0.2)]);
809 settings.merge(&SettingsUpdate::default());
810 assert!(settings.inference_profiles.is_some());
811 }
812
813 #[test]
817 fn test_profiles_round_trip_through_json_and_default_when_absent() {
818 let settings = Settings {
819 inference_profiles: Some(vec![profile("coding", 0.2)]),
820 ..Settings::with_defaults()
821 };
822 let value = serde_json::to_value(&settings).expect("serializes");
823 assert!(value.get("inference_profiles").is_some());
824
825 let restored: Settings = serde_json::from_value(value).expect("round-trips");
826 assert_eq!(restored.inference_profiles, settings.inference_profiles);
827
828 let absent: Settings = serde_json::from_str("{}").expect("deserializes without the field");
829 assert_eq!(absent.inference_profiles, None);
830 }
831}