Skip to main content

llm_manager/
config.rs

1mod model_config;
2mod presets;
3mod profiles;
4mod store;
5
6use std::collections::HashSet;
7use std::path::PathBuf;
8
9use chrono::Local;
10use serde::{Deserialize, Serialize};
11
12#[allow(unused_imports)]
13pub use model_config::{ModelConfigStore, display_from_key, key_from_display};
14
15pub use profiles::ProfileStore;
16
17use crate::models::{
18    Backend, CacheType, CacheTypeK, CacheTypeV, Mirostat, NumMode, RopeScaling, Samplers, SplitMode,
19};
20use crate::tui::app::ActivePanel;
21pub use presets::PresetStore;
22
23/// Default system prompt used when no preset is selected.
24pub const DEFAULT_SYSTEM_PROMPT: &str = "You are an expert software developer. Write clean, well-documented code. Explain your reasoning and suggest improvements.";
25
26/// Resolve the base config directory with a safe fallback chain.
27///
28/// Prefers `dirs::config_dir()` (XDG on Linux, ~/Library/Application Support on macOS,
29/// etc.), falls back to `~/.config`, and lastly `./.llm-manager` if both fail.
30pub fn config_base_dir() -> PathBuf {
31    if let Some(d) = dirs::config_dir() {
32        return d;
33    }
34    if let Some(home) = dirs::home_dir() {
35        return home.join(".config");
36    }
37    PathBuf::from(".").join(".llm-manager")
38}
39
40/// Count physical CPU cores on Linux (ignores hyperthreading).
41/// Falls back to 1 if the file can't be read or parsing fails.
42pub fn physical_cores() -> u32 {
43    let content = match std::fs::read_to_string("/proc/cpuinfo") {
44        Ok(c) => c,
45        Err(_) => {
46            return std::thread::available_parallelism()
47                .map(|p| p.get() as u32)
48                .unwrap_or(1);
49        }
50    };
51    let mut seen = HashSet::new();
52    let mut cur_phys: Option<&str> = None;
53    let mut cur_core: Option<&str> = None;
54    for line in content.lines() {
55        if let Some((key, val)) = line.split_once(':') {
56            let key = key.trim();
57            let val = val.trim();
58            match key {
59                "physical id" => cur_phys = Some(val),
60                "core id" => cur_core = Some(val),
61                _ => {}
62            }
63            if let (Some(phys), Some(core)) = (cur_phys, cur_core) {
64                seen.insert((phys, core));
65            }
66        }
67    }
68    seen.len().max(1) as u32
69}
70
71/// A remote RPC worker for distributed inference.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct RpcWorker {
74    #[serde(default)]
75    pub selected: bool,
76    #[serde(default)]
77    pub name: String,
78    pub ip: String,
79    #[serde(default = "default_rpc_port")]
80    pub port: u16,
81}
82
83fn default_rpc_port() -> u16 {
84    50052
85}
86
87/// Global configuration.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Config {
90    pub models_dirs: Vec<PathBuf>,
91    pub llama_server: PathBuf,
92    pub default: DefaultParams,
93    /// Per-model overrides (keyed by display_name/path relative to model dir, stored as YAML in models/).
94    #[serde(default, skip)]
95    pub model_overrides: ModelConfigStore,
96    /// Named profiles of settings presets (stored as YAML in profiles/).
97    #[serde(default, skip)]
98    pub profiles: ProfileStore,
99    /// System prompt presets (stored as YAML in presets/).
100    #[serde(default, skip)]
101    pub system_prompt_presets: PresetStore,
102    /// RPC Workers for distributed inference.
103    #[serde(default)]
104    pub rpc_workers: Vec<RpcWorker>,
105    /// Number of results per HuggingFace search query.
106    #[serde(default = "default_search_limit")]
107    pub search_limit: u32,
108    /// The last focused panel position (for restoring on next launch).
109    #[serde(default)]
110    pub active_panel: crate::tui::app::ActivePanel,
111    /// Left panel width percentage (20-80).
112    #[serde(default = "default_left_pct")]
113    pub left_pct: u16,
114    /// Server Settings panel height in rows (3-20).
115    #[serde(default = "default_server_settings_height")]
116    pub server_settings_height: u16,
117    /// UI language (en, fr, it). Falls back to en.
118    #[serde(default = "default_language")]
119    pub language: String,
120    /// Whether the first-launch onboarding wizard has been completed.
121    #[serde(default = "default_onboarding")]
122    pub onboarding_complete: bool,
123}
124
125fn default_language() -> String {
126    "en".to_string()
127}
128
129fn default_left_pct() -> u16 {
130    55
131}
132
133fn default_server_settings_height() -> u16 {
134    9
135}
136
137fn default_onboarding() -> bool {
138    false
139}
140
141fn default_search_limit() -> u32 {
142    50
143}
144
145/// A named profile of settings.
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct Profile {
148    pub name: String,
149    /// Brief description shown in the profile list.
150    pub description: String,
151    /// The settings for this profile.
152    #[serde(default)]
153    pub settings: ModelOverride,
154}
155
156impl Profile {
157    /// Apply this profile's settings to a base ModelSettings.
158    pub fn apply(&self, mut base: crate::models::ModelSettings) -> crate::models::ModelSettings {
159        self.settings.apply(&mut base);
160        base
161    }
162}
163
164impl crate::config::store::NamedItem for Profile {
165    fn name(&self) -> &str {
166        &self.name
167    }
168}
169
170/// A named system prompt preset.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct SystemPromptPreset {
173    pub name: String,
174    pub description: String,
175    pub content: String,
176}
177
178impl crate::config::store::NamedItem for SystemPromptPreset {
179    fn name(&self) -> &str {
180        &self.name
181    }
182}
183
184/// Built-in system prompt presets.
185pub fn builtin_system_prompt_presets() -> Vec<SystemPromptPreset> {
186    vec![
187        SystemPromptPreset {
188            name: "General".into(),
189            description: "General-purpose assistant".into(),
190            content: "You are a helpful assistant.".into(),
191        },
192        SystemPromptPreset {
193            name: "Coder".into(),
194            description: "Expert software developer".into(),
195            content: "You are an expert software developer. Write clean, well-documented code. Explain your reasoning and suggest improvements. Split code to avoid too big files. Always try to re-use existing code, avoid duplicate function. In case of reviewing code: every claim must have file:line reference. For kernel Every Kconfig symbol must be verified against actual Kconfig files. Every XML example must be validated against Relax NG schema. Every limitation must be verified against source code. Do NOT include claims you cannot verify. Use tables for structured data. Use code blocks for commands. Cross-reference between components. Workflows using SVG.".into(),
196        },
197        SystemPromptPreset {
198            name: "Thinker".into(),
199            description: "Analytical and thoughtful".into(),
200            content: "You are a thoughtful and analytical AI assistant. Think carefully before answering. Provide well-reasoned responses with clear explanations. Do NOT include claims you cannot verify".into(),
201        },
202        SystemPromptPreset {
203            name: "Mathematician".into(),
204            description: "Expert in mathematics".into(),
205            content: "You are an expert in mathematics. Provide clear, step-by-step solutions to mathematical problems. Show your reasoning and explain key concepts.".into(),
206        },
207    ]
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
211pub struct ModelOverride {
212    // Loading
213    pub context_length: Option<u32>,
214    pub batch_size: Option<u32>,
215    pub ubatch_size: Option<u32>,
216    pub cache_type_k: Option<CacheTypeK>,
217    pub cache_type_v: Option<CacheTypeV>,
218    pub keep: Option<i32>,
219    pub swa_full: Option<bool>,
220    pub mlock: Option<bool>,
221    pub mmap: Option<bool>,
222    pub numa: Option<NumMode>,
223    pub uniform_cache: Option<bool>,
224    pub system_prompt: Option<String>,
225    pub system_prompt_preset_name: Option<String>,
226    pub max_concurrent_predictions: Option<u32>,
227    pub threads: Option<u32>,
228    pub threads_batch: Option<u32>,
229    pub parallel: Option<u32>,
230
231    // GPU
232    pub gpu_layers: Option<i32>,
233    pub split_mode: Option<SplitMode>,
234    pub tensor_split: Option<String>,
235    pub main_gpu: Option<i32>,
236    pub fit: Option<bool>,
237    pub lora: Option<PathBuf>,
238    pub lora_scaled: Option<(PathBuf, f32)>,
239    pub rpc: Option<String>,
240    pub embedding: Option<bool>,
241    pub kv_cache_offload: Option<bool>,
242    pub flash_attn: Option<bool>,
243    pub jinja: Option<bool>,
244    pub auto_chat_template: Option<bool>,
245    pub chat_template: Option<String>,
246    pub chat_template_kwargs: Option<String>,
247    pub expert_count: Option<i32>,
248    pub gpu_layers_mode: Option<crate::models::GpuLayersMode>,
249
250    // Sampling
251    pub seed: Option<i32>,
252    pub temperature: Option<f32>,
253    pub top_k: Option<i32>,
254    pub top_p: Option<f32>,
255    pub min_p: Option<f32>,
256    pub typical_p: Option<f32>,
257    pub mirostat: Option<Mirostat>,
258    pub mirostat_lr: Option<f32>,
259    pub mirostat_ent: Option<f32>,
260    pub ignore_eos: Option<bool>,
261    pub samplers: Option<Samplers>,
262
263    // Repetition
264    pub repeat_penalty: Option<f32>,
265    pub repeat_last_n: Option<i32>,
266    pub presence_penalty: Option<f32>,
267    pub frequency_penalty: Option<f32>,
268    pub dry_multiplier: Option<f32>,
269    pub dry_base: Option<f32>,
270    pub dry_allowed_length: Option<i32>,
271    pub dry_penalty_last_n: Option<i32>,
272
273    // RoPE
274    pub rope_scaling: Option<RopeScaling>,
275    pub rope_scale: Option<f32>,
276    pub rope_freq_base: Option<f32>,
277    pub rope_freq_scale: Option<f32>,
278    pub rope_yarn_enabled: Option<bool>,
279
280    // Server
281    pub cache_prompt: Option<bool>,
282    pub cache_reuse: Option<u32>,
283    pub webui: Option<bool>,
284
285    // Other
286    pub max_tokens: Option<u32>,
287    pub cache_type: Option<CacheType>,
288    pub llama_cpp_version_cpu: Option<String>,
289    pub llama_cpp_version_vulkan: Option<String>,
290    pub llama_cpp_version_rocm: Option<String>,
291    pub llama_cpp_version_rocm_lemonade: Option<String>,
292    pub llama_cpp_version_cuda: Option<String>,
293    pub spec_type: Option<String>,
294    pub draft_tokens: Option<u32>,
295    pub tags: Option<Vec<String>>,
296}
297
298/// Apply a scalar Copy field from override: `base.f = self.f.unwrap_or(base.f)`.
299macro_rules! apply_scalar {
300    ($self:ident, $base:ident, $($field:ident),+ $(,)?) => {
301        $(
302            $base.$field = $self.$field.unwrap_or($base.$field);
303        )+
304    };
305}
306
307/// Apply a Clone field from override: `if let Some(v) = &self.f { base.f = v.clone(); }`.
308macro_rules! apply_clone {
309    ($self:ident, $base:ident, $($field:ident),+ $(,)?) => {
310        $(
311            if let Some(v) = &$self.$field {
312                $base.$field = v.clone();
313            }
314        )+
315    };
316}
317
318/// Apply an Option<T> field from override: `if let Some(v) = &self.f { base.f = Some(v.clone()); }`.
319macro_rules! apply_option {
320    ($self:ident, $base:ident, $($field:ident),+ $(,)?) => {
321        $(
322            if let Some(v) = &$self.$field {
323                $base.$field = Some(v.clone());
324            }
325        )+
326    };
327}
328
329impl ModelOverride {
330    pub fn from_settings(s: &crate::models::ModelSettings) -> Self {
331        Self {
332            context_length: Some(s.context_length),
333            batch_size: Some(s.batch_size),
334            ubatch_size: Some(s.ubatch_size),
335            cache_type_k: s.cache_type_k,
336            cache_type_v: s.cache_type_v,
337            keep: Some(s.keep),
338            swa_full: Some(s.swa_full),
339            mlock: Some(s.mlock),
340            mmap: Some(s.mmap),
341            numa: Some(s.numa),
342            uniform_cache: Some(s.uniform_cache),
343            system_prompt: Some(s.system_prompt.clone()),
344            system_prompt_preset_name: Some(s.system_prompt_preset_name.clone()),
345            max_concurrent_predictions: s.max_concurrent_predictions,
346            threads: Some(s.threads),
347            threads_batch: Some(s.threads_batch),
348            parallel: Some(s.parallel),
349            gpu_layers: Some(match s.gpu_layers_mode {
350                crate::models::GpuLayersMode::Auto => -2,
351                crate::models::GpuLayersMode::Specific(n) => n as i32,
352                crate::models::GpuLayersMode::All => -1,
353            }),
354            gpu_layers_mode: Some(s.gpu_layers_mode),
355            split_mode: Some(s.split_mode),
356            tensor_split: Some(s.tensor_split.clone()),
357            main_gpu: Some(s.main_gpu),
358            fit: Some(s.fit),
359            lora: s.lora.clone(),
360            lora_scaled: s.lora_scaled.clone(),
361            rpc: Some(s.rpc.clone()),
362            embedding: Some(s.embedding),
363            kv_cache_offload: Some(s.kv_cache_offload),
364            flash_attn: Some(s.flash_attn),
365            jinja: Some(s.jinja),
366            auto_chat_template: Some(s.auto_chat_template),
367            chat_template: s.chat_template.clone(),
368            chat_template_kwargs: s.chat_template_kwargs.clone(),
369            expert_count: Some(s.expert_count),
370            seed: Some(s.seed),
371            temperature: Some(s.temperature),
372            top_k: Some(s.top_k),
373            top_p: Some(s.top_p),
374            min_p: Some(s.min_p),
375            typical_p: Some(s.typical_p),
376            mirostat: Some(s.mirostat),
377            mirostat_lr: Some(s.mirostat_lr),
378            mirostat_ent: Some(s.mirostat_ent),
379            ignore_eos: Some(s.ignore_eos),
380            samplers: Some(s.samplers.clone()),
381            repeat_penalty: Some(s.repeat_penalty),
382            repeat_last_n: Some(s.repeat_last_n),
383            presence_penalty: s.presence_penalty,
384            frequency_penalty: s.frequency_penalty,
385            dry_multiplier: Some(s.dry_multiplier),
386            dry_base: Some(s.dry_base),
387            dry_allowed_length: Some(s.dry_allowed_length),
388            dry_penalty_last_n: Some(s.dry_penalty_last_n),
389            rope_scaling: Some(s.rope_scaling),
390            rope_scale: Some(s.rope_scale),
391            rope_freq_base: Some(s.rope_freq_base),
392            rope_freq_scale: Some(s.rope_freq_scale),
393            rope_yarn_enabled: Some(s.rope_yarn_enabled),
394            cache_prompt: Some(s.cache_prompt),
395            cache_reuse: Some(s.cache_reuse),
396            webui: Some(s.webui),
397            max_tokens: s.max_tokens,
398            cache_type: Some(s.cache_type),
399            llama_cpp_version_cpu: s.llama_cpp_version_cpu.clone(),
400            llama_cpp_version_vulkan: s.llama_cpp_version_vulkan.clone(),
401            llama_cpp_version_rocm: s.llama_cpp_version_rocm.clone(),
402            llama_cpp_version_rocm_lemonade: s.llama_cpp_version_rocm_lemonade.clone(),
403            llama_cpp_version_cuda: s.llama_cpp_version_cuda.clone(),
404            spec_type: Some(s.spec_type.clone()),
405            draft_tokens: Some(s.draft_tokens),
406            tags: Some(s.tags.clone()),
407        }
408    }
409
410    /// Merge override into a base ModelSettings (in-place).
411    pub fn apply(&self, base: &mut crate::models::ModelSettings) {
412        // Override values always take precedence. For Option<T> fields,
413        // the override value (even None) is explicitly set by the user.
414
415        // Scalar Copy fields: base.f = self.f.unwrap_or(base.f)
416        apply_scalar!(
417            self,
418            base,
419            context_length,
420            batch_size,
421            ubatch_size,
422            keep,
423            swa_full,
424            mlock,
425            mmap,
426            numa,
427            uniform_cache,
428            kv_cache_offload,
429            threads,
430            threads_batch,
431            parallel,
432            split_mode,
433            main_gpu,
434            fit,
435            embedding,
436            flash_attn,
437            jinja,
438            auto_chat_template,
439            expert_count,
440            seed,
441            temperature,
442            top_k,
443            top_p,
444            min_p,
445            typical_p,
446            mirostat,
447            mirostat_lr,
448            mirostat_ent,
449            ignore_eos,
450            repeat_penalty,
451            repeat_last_n,
452            dry_multiplier,
453            dry_base,
454            dry_allowed_length,
455            dry_penalty_last_n,
456            rope_scaling,
457            rope_scale,
458            rope_freq_base,
459            rope_freq_scale,
460            rope_yarn_enabled,
461            cache_prompt,
462            cache_reuse,
463            webui,
464            cache_type,
465            draft_tokens,
466            gpu_layers_mode,
467        );
468
469        // Cloneable fields: if let Some(v) = &self.f { base.f = v.clone(); }
470        apply_clone!(
471            self,
472            base,
473            system_prompt,
474            system_prompt_preset_name,
475            tensor_split,
476            rpc,
477            samplers,
478            spec_type,
479            tags,
480        );
481
482        // Option<T> fields: if let Some(v) = &self.f { base.f = Some(v.clone()); }
483        apply_option!(
484            self,
485            base,
486            lora,
487            lora_scaled,
488            chat_template,
489            chat_template_kwargs,
490            llama_cpp_version_cpu,
491            llama_cpp_version_vulkan,
492            llama_cpp_version_rocm,
493            llama_cpp_version_rocm_lemonade,
494            llama_cpp_version_cuda,
495        );
496
497        // Direct Option<T> assignment (same type in both structs) — only apply if Some
498        if let Some(v) = self.cache_type_k {
499            base.cache_type_k = Some(v);
500        }
501        if let Some(v) = self.cache_type_v {
502            base.cache_type_v = Some(v);
503        }
504        if let Some(v) = self.presence_penalty {
505            base.presence_penalty = Some(v);
506        }
507        if let Some(v) = self.frequency_penalty {
508            base.frequency_penalty = Some(v);
509        }
510        base.max_tokens = self.max_tokens.or(base.max_tokens);
511
512        // Special: max_concurrent_predictions uses or() for Option chaining
513        base.max_concurrent_predictions = self
514            .max_concurrent_predictions
515            .or(base.max_concurrent_predictions);
516
517        // Special: gpu_layers converts i32 legacy field to GpuLayersMode enum
518        // Only applies when gpu_layers is explicitly set in the override.
519        // -2 is sentinel for Auto (preserves round-trip from from_settings).
520        if let Some(n) = self.gpu_layers {
521            base.gpu_layers_mode = match n {
522                -2 => crate::models::GpuLayersMode::Auto,
523                n if n < 0 => crate::models::GpuLayersMode::All,
524                n => crate::models::GpuLayersMode::Specific(n as u32),
525            };
526        }
527
528        // FIELD ACCOUNTING (ModelOverride: 87 fields):
529        // - apply_scalar: 53 fields
530        // - apply_clone: 7 fields
531        // - apply_option: 10 fields
532        // - direct Option assign: 5 fields (cache_type_k, cache_type_v, presence_penalty,
533        //   frequency_penalty, max_tokens)
534        // - special: 1 field (max_concurrent_predictions)
535        // - conditional: gpu_layers overrides gpu_layers_mode only when Some
536        // - NOT in ModelSettings: 0 (all ModelOverride fields mapped above)
537        //
538        // ModelSettings fields NOT in ModelOverride (not overridable):
539        // host, port, timeout, backend, platform, router_max_models, server_mode,
540        // api_endpoint_enabled, api_endpoint_port
541        //
542        // When adding a field: ensure it appears in exactly one category above.
543    }
544}
545
546/// Built-in profiles with sensible defaults for popular model families.
547pub fn builtin_profiles() -> Vec<Profile> {
548    vec![
549        Profile {
550            name: "Qwen".into(),
551            description: "Optimized for Qwen models (dense)".into(),
552            settings: ModelOverride {
553                context_length: Some(131072),
554                temperature: Some(0.7),
555                top_k: Some(20),
556                top_p: Some(0.95),
557                max_tokens: Some(4096),
558                presence_penalty: Some(0.0),
559                uniform_cache: Some(true),
560                jinja: Some(true),
561                ..Default::default()
562            },
563        },
564        Profile {
565            name: "Qwen-MoE".into(),
566            description: "Optimized for Qwen MoE models (35B-A3B)".into(),
567            settings: ModelOverride {
568                context_length: Some(131072),
569                temperature: Some(0.8),
570                top_k: Some(20),
571                top_p: Some(0.95),
572                max_tokens: Some(4096),
573                presence_penalty: Some(1.5),
574                uniform_cache: Some(true),
575                jinja: Some(true),
576                ..Default::default()
577            },
578        },
579        Profile {
580            name: "Qwen-Coding".into(),
581            description: "Optimized for Qwen models in coding mode".into(),
582            settings: ModelOverride {
583                context_length: Some(131072),
584                temperature: Some(0.6),
585                top_k: Some(20),
586                top_p: Some(0.95),
587                max_tokens: Some(4096),
588                presence_penalty: Some(0.0),
589                uniform_cache: Some(true),
590                jinja: Some(true),
591                ..Default::default()
592            },
593        },
594        Profile {
595            name: "Gemma".into(),
596            description: "Optimized for Gemma 2/4 models".into(),
597            settings: ModelOverride {
598                context_length: Some(131072),
599                min_p: Some(0.1),
600                temperature: Some(1.0),
601                top_k: Some(65),
602                top_p: Some(0.95),
603                max_tokens: Some(4096),
604                uniform_cache: Some(true),
605                jinja: Some(true),
606                ..Default::default()
607            },
608        },
609        Profile {
610            name: "Llama".into(),
611            description: "Optimized for Llama 3.1/3.3 models".into(),
612            settings: ModelOverride {
613                context_length: Some(131072),
614                temperature: Some(0.7),
615                top_p: Some(0.9),
616                repeat_penalty: Some(1.1),
617                max_tokens: Some(4096),
618                uniform_cache: Some(true),
619                jinja: Some(true),
620                ..Default::default()
621            },
622        },
623        Profile {
624            name: "Mistral".into(),
625            description: "Optimized for Mistral 7B/NeMo models".into(),
626            settings: ModelOverride {
627                context_length: Some(131072),
628                temperature: Some(0.7),
629                top_k: Some(50),
630                top_p: Some(0.9),
631                max_tokens: Some(4096),
632                uniform_cache: Some(true),
633                jinja: Some(true),
634                ..Default::default()
635            },
636        },
637        Profile {
638            name: "Phi".into(),
639            description: "Optimized for Phi 3.5 Mini models".into(),
640            settings: ModelOverride {
641                context_length: Some(131072),
642                temperature: Some(0.7),
643                top_k: Some(50),
644                top_p: Some(0.9),
645                repeat_penalty: Some(1.1),
646                max_tokens: Some(4096),
647                uniform_cache: Some(true),
648                ..Default::default()
649            },
650        },
651    ]
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
655#[serde(default)]
656pub struct DefaultParams {
657    // Loading
658    #[serde(default)]
659    pub context_length: u32,
660    #[serde(default)]
661    pub threads: u32,
662    #[serde(default)]
663    pub threads_batch: u32,
664    #[serde(default)]
665    pub batch_size: u32,
666    #[serde(default)]
667    pub ubatch_size: u32,
668    #[serde(default = "default_cache_type_k")]
669    pub cache_type_k: Option<CacheTypeK>,
670    #[serde(default = "default_cache_type_v")]
671    pub cache_type_v: Option<CacheTypeV>,
672    #[serde(default)]
673    pub keep: i32,
674    #[serde(default)]
675    pub swa_full: bool,
676    #[serde(default)]
677    pub mlock: bool,
678    #[serde(default)]
679    pub mmap: bool,
680    #[serde(default)]
681    pub numa: NumMode,
682    #[serde(default)]
683    pub uniform_cache: bool,
684    #[serde(default)]
685    pub kv_cache_offload: bool,
686    #[serde(default)]
687    pub parallel: u32,
688    #[serde(default)]
689    pub max_concurrent_predictions: Option<u32>,
690    #[serde(default)]
691    pub system_prompt: String,
692    #[serde(default = "default_system_prompt_preset_name")]
693    pub system_prompt_preset_name: String,
694    // GPU
695    #[serde(default)]
696    pub gpu_layers: i32,
697    #[serde(default = "default_gpu_layers_mode")]
698    pub gpu_layers_mode: crate::models::GpuLayersMode,
699    #[serde(default)]
700    pub split_mode: SplitMode,
701    #[serde(default)]
702    pub tensor_split: String,
703    #[serde(default)]
704    pub main_gpu: i32,
705    #[serde(default)]
706    pub fit: bool,
707    #[serde(default)]
708    pub lora: Option<PathBuf>,
709    #[serde(default)]
710    pub lora_scaled: Option<(PathBuf, f32)>,
711    #[serde(default)]
712    pub rpc: String,
713    #[serde(default)]
714    pub embedding: bool,
715    #[serde(default)]
716    pub flash_attn: bool,
717    #[serde(default)]
718    pub jinja: bool,
719    #[serde(default)]
720    pub auto_chat_template: bool,
721    #[serde(default)]
722    pub chat_template: Option<String>,
723    #[serde(default)]
724    pub chat_template_kwargs: Option<String>,
725    #[serde(default)]
726    pub expert_count: i32,
727
728    // Sampling
729    #[serde(default)]
730    pub seed: i32,
731    #[serde(default)]
732    pub temperature: f32,
733    #[serde(default)]
734    pub top_k: i32,
735    #[serde(default)]
736    pub top_p: f32,
737    #[serde(default)]
738    pub min_p: f32,
739    #[serde(default)]
740    pub typical_p: f32,
741    #[serde(default)]
742    pub mirostat: Mirostat,
743    #[serde(default)]
744    pub mirostat_lr: f32,
745    #[serde(default)]
746    pub mirostat_ent: f32,
747    #[serde(default)]
748    pub ignore_eos: bool,
749    #[serde(default)]
750    pub samplers: Samplers,
751
752    // Repetition
753    #[serde(default)]
754    pub repeat_penalty: f32,
755    #[serde(default)]
756    pub repeat_last_n: i32,
757    #[serde(default = "default_presence_penalty")]
758    pub presence_penalty: Option<f32>,
759    #[serde(default = "default_frequency_penalty")]
760    pub frequency_penalty: Option<f32>,
761    #[serde(default)]
762    pub dry_multiplier: f32,
763    #[serde(default)]
764    pub dry_base: f32,
765    #[serde(default)]
766    pub dry_allowed_length: i32,
767    #[serde(default)]
768    pub dry_penalty_last_n: i32,
769
770    // RoPE
771    #[serde(default)]
772    pub rope_scaling: RopeScaling,
773    #[serde(default)]
774    pub rope_scale: f32,
775    #[serde(default)]
776    pub rope_freq_base: f32,
777    #[serde(default)]
778    pub rope_freq_scale: f32,
779    #[serde(default)]
780    pub rope_yarn_enabled: bool,
781
782    // Server
783    #[serde(default)]
784    pub host: String,
785    #[serde(default)]
786    pub port: u16,
787    #[serde(default)]
788    pub timeout: u32,
789    #[serde(default = "default_cache_prompt")]
790    pub cache_prompt: bool,
791    #[serde(default)]
792    pub cache_reuse: u32,
793    #[serde(default)]
794    pub webui: bool,
795    #[serde(default)]
796    pub ws_server_enabled: bool,
797    #[serde(default = "default_ws_server_port")]
798    pub ws_server_port: u16,
799    #[serde(default = "default_server_tls_enabled")]
800    pub server_tls_enabled: bool,
801    #[serde(default)]
802    pub server_tls_cert: Option<String>,
803    #[serde(default)]
804    pub server_tls_key: Option<String>,
805    #[serde(default)]
806    pub router_max_models: u32,
807    #[serde(default)]
808    pub server_mode: crate::models::ServerMode,
809    #[serde(default = "default_log_level")]
810    pub log_level: String,
811
812    // Other
813    #[serde(default = "default_max_tokens")]
814    pub max_tokens: Option<u32>,
815    #[serde(default)]
816    pub cache_type: CacheType,
817    #[serde(default)]
818    pub backend: Backend,
819    /// Platform override: "linux", "windows", or "macos". If None, auto-detected.
820    #[serde(default)]
821    pub platform: Option<String>,
822    #[serde(default)]
823    pub llama_cpp_version_cpu: Option<String>,
824    #[serde(default)]
825    pub llama_cpp_version_vulkan: Option<String>,
826    #[serde(default)]
827    pub llama_cpp_version_rocm: Option<String>,
828    #[serde(default)]
829    pub llama_cpp_version_rocm_lemonade: Option<String>,
830    #[serde(default)]
831    pub llama_cpp_version_cuda: Option<String>,
832
833    // API
834    #[serde(default)]
835    pub api_endpoint_enabled: bool,
836    #[serde(default = "default_api_endpoint_port")]
837    pub api_endpoint_port: u16,
838    #[serde(default = "default_web_search_engine")]
839    pub web_search_engine: String,
840    #[serde(default)]
841    pub web_search_engine_url: String,
842    #[serde(default = "default_web_search_enabled")]
843    pub web_search_enabled: bool,
844    #[serde(default)]
845    pub web_search_api_key: Option<String>,
846    #[serde(default)]
847    pub api_endpoint_key: Option<String>,
848    #[serde(default)]
849    pub spec_type: String,
850    #[serde(default)]
851    pub draft_tokens: u32,
852    #[serde(default)]
853    pub tags: Vec<String>,
854}
855
856fn default_api_endpoint_port() -> u16 {
857    49222
858}
859
860fn default_web_search_engine() -> String {
861    "searxng".to_string()
862}
863
864fn default_web_search_enabled() -> bool {
865    false
866}
867
868fn default_system_prompt_preset_name() -> String {
869    "General".to_string()
870}
871
872fn default_cache_type_k() -> Option<CacheTypeK> {
873    None
874}
875fn default_cache_type_v() -> Option<CacheTypeV> {
876    None
877}
878fn default_presence_penalty() -> Option<f32> {
879    None
880}
881fn default_frequency_penalty() -> Option<f32> {
882    None
883}
884fn default_max_tokens() -> Option<u32> {
885    None
886}
887fn default_cache_prompt() -> bool {
888    true
889}
890fn default_ws_server_port() -> u16 {
891    49223
892}
893
894fn default_server_tls_enabled() -> bool {
895    true
896}
897
898fn default_log_level() -> String {
899    "trace".to_string()
900}
901
902fn default_gpu_layers_mode() -> crate::models::GpuLayersMode {
903    crate::models::GpuLayersMode::Auto
904}
905
906impl Default for DefaultParams {
907    fn default() -> Self {
908        Self {
909            // Loading
910            context_length: 131072,
911            threads: physical_cores(),
912            threads_batch: 8,
913            batch_size: 512,
914            ubatch_size: 512,
915            cache_type_k: None,
916            cache_type_v: None,
917            keep: 0,
918            swa_full: false,
919            mlock: false,
920            mmap: true,
921            numa: NumMode::None,
922            uniform_cache: true,
923            kv_cache_offload: true,
924            parallel: 1,
925            max_concurrent_predictions: None,
926            system_prompt: DEFAULT_SYSTEM_PROMPT.to_string(),
927            system_prompt_preset_name: "Coder".to_string(),
928
929            // GPU
930            gpu_layers: -1,
931            gpu_layers_mode: crate::models::GpuLayersMode::Auto,
932            split_mode: SplitMode::Layer,
933            tensor_split: String::new(),
934            main_gpu: 0,
935            fit: true,
936            lora: None,
937            lora_scaled: None,
938            rpc: String::new(),
939            embedding: false,
940            flash_attn: true,
941            jinja: true,
942            auto_chat_template: false,
943            chat_template: None,
944            chat_template_kwargs: None,
945            expert_count: -1,
946
947            // Sampling
948            seed: -1,
949            temperature: 0.8,
950            top_k: 40,
951            top_p: 0.95,
952            min_p: 0.0,
953            typical_p: 1.0,
954            mirostat: Mirostat::Off,
955            mirostat_lr: 0.1,
956            mirostat_ent: 5.0,
957            ignore_eos: false,
958            samplers: Samplers::default(),
959
960            // Repetition
961            repeat_penalty: 1.1,
962            repeat_last_n: 64,
963            presence_penalty: None,
964            frequency_penalty: None,
965            dry_multiplier: 0.0,
966            dry_base: 1.75,
967            dry_allowed_length: 2,
968            dry_penalty_last_n: -1,
969
970            // RoPE
971            rope_scaling: RopeScaling::None,
972            rope_scale: 1.0,
973            rope_freq_base: 0.0,
974            rope_freq_scale: 1.0,
975            rope_yarn_enabled: false,
976
977            // Server
978            host: "127.0.0.1".to_string(),
979            port: 8080,
980            timeout: 600,
981            cache_prompt: true,
982            cache_reuse: 0,
983            webui: false,
984            ws_server_enabled: false,
985            ws_server_port: 49223,
986            server_tls_enabled: true,
987            server_tls_cert: None,
988            server_tls_key: None,
989            router_max_models: 4,
990            server_mode: crate::models::ServerMode::Normal,
991            log_level: "trace".to_string(),
992
993            // Other
994            max_tokens: None,
995            cache_type: CacheType::F16,
996            backend: {
997                use crate::backend::hardware::{GpuVendor, detect_gpu_vendors};
998                let vendors = detect_gpu_vendors();
999                let mut result = Backend::Cpu;
1000                for v in &vendors {
1001                    if matches!(v, GpuVendor::Nvidia) {
1002                        result = Backend::Cuda;
1003                        break;
1004                    }
1005                    if matches!(v, GpuVendor::Amd) {
1006                        result = Backend::Rocm;
1007                        break;
1008                    }
1009                    if matches!(v, GpuVendor::Intel) {
1010                        result = Backend::Vulkan;
1011                        break;
1012                    }
1013                }
1014                result
1015            },
1016            platform: None,
1017            llama_cpp_version_cpu: None,
1018            llama_cpp_version_vulkan: None,
1019            llama_cpp_version_rocm: None,
1020            llama_cpp_version_rocm_lemonade: None,
1021            llama_cpp_version_cuda: None,
1022            api_endpoint_enabled: false,
1023            api_endpoint_port: 49222,
1024            api_endpoint_key: None,
1025            web_search_engine: "searxng".to_string(),
1026            web_search_engine_url: String::new(),
1027            web_search_enabled: false,
1028            web_search_api_key: None,
1029            spec_type: String::new(),
1030            draft_tokens: 0,
1031            tags: Vec::new(),
1032        }
1033    }
1034}
1035
1036impl Default for Config {
1037    fn default() -> Self {
1038        Self {
1039            models_dirs: vec![
1040                dirs::data_dir()
1041                    .unwrap_or_default()
1042                    .join("llm-manager")
1043                    .join("models"),
1044            ],
1045            llama_server: "llama-server".into(),
1046            default: DefaultParams::default(),
1047            model_overrides: ModelConfigStore::new(),
1048            profiles: Default::default(),
1049            system_prompt_presets: Default::default(),
1050            rpc_workers: Vec::new(),
1051            search_limit: default_search_limit(),
1052            active_panel: ActivePanel::Models,
1053            left_pct: 55,
1054            server_settings_height: 9,
1055            language: default_language(),
1056            onboarding_complete: false,
1057        }
1058    }
1059}
1060
1061impl Config {
1062    pub fn config_path() -> PathBuf {
1063        config_base_dir().join("llm-manager").join("config.yaml")
1064    }
1065
1066    /// Known top-level config keys.
1067    fn config_keys() -> &'static [&'static str] {
1068        &[
1069            "models_dirs",
1070            "llama_server",
1071            "default",
1072            "model_overrides",
1073            "profiles",
1074            "system_prompt_presets",
1075            "rpc_workers",
1076            "search_limit",
1077            "active_panel",
1078            "left_pct",
1079            "server_settings_height",
1080            "language",
1081            "onboarding_complete",
1082        ]
1083    }
1084
1085    /// Known DefaultParams keys.
1086    fn default_params_keys() -> &'static [&'static str] {
1087        &[
1088            "context_length",
1089            "threads",
1090            "threads_batch",
1091            "batch_size",
1092            "ubatch_size",
1093            "cache_type_k",
1094            "cache_type_v",
1095            "keep",
1096            "swa_full",
1097            "mlock",
1098            "mmap",
1099            "numa",
1100            "uniform_cache",
1101            "kv_cache_offload",
1102            "parallel",
1103            "max_concurrent_predictions",
1104            "system_prompt",
1105            "system_prompt_preset_name",
1106            "gpu_layers",
1107            "gpu_layers_mode",
1108            "split_mode",
1109            "tensor_split",
1110            "main_gpu",
1111            "fit",
1112            "lora",
1113            "lora_scaled",
1114            "rpc",
1115            "embedding",
1116            "flash_attn",
1117            "jinja",
1118            "chat_template",
1119            "chat_template_kwargs",
1120            "expert_count",
1121            "seed",
1122            "temperature",
1123            "top_k",
1124            "top_p",
1125            "min_p",
1126            "typical_p",
1127            "mirostat",
1128            "mirostat_lr",
1129            "mirostat_ent",
1130            "ignore_eos",
1131            "samplers",
1132            "repeat_penalty",
1133            "repeat_last_n",
1134            "presence_penalty",
1135            "frequency_penalty",
1136            "dry_multiplier",
1137            "dry_base",
1138            "dry_allowed_length",
1139            "dry_penalty_last_n",
1140            "rope_scaling",
1141            "rope_scale",
1142            "rope_freq_base",
1143            "rope_freq_scale",
1144            "rope_yarn_enabled",
1145            "host",
1146            "port",
1147            "timeout",
1148            "cache_prompt",
1149            "cache_reuse",
1150            "webui",
1151            "ws_server_enabled",
1152            "ws_server_port",
1153            "server_tls_enabled",
1154            "server_tls_cert",
1155            "server_tls_key",
1156            "router_max_models",
1157            "server_mode",
1158            "log_level",
1159            "max_tokens",
1160            "cache_type",
1161            "backend",
1162            "platform",
1163            "llama_cpp_version_cpu",
1164            "llama_cpp_version_vulkan",
1165            "llama_cpp_version_rocm",
1166            "llama_cpp_version_rocm_lemonade",
1167            "llama_cpp_version_cuda",
1168            "api_endpoint_enabled",
1169            "api_endpoint_port",
1170            "api_endpoint_key",
1171            "web_search_engine",
1172            "web_search_engine_url",
1173            "web_search_enabled",
1174            "web_search_api_key",
1175            "spec_type",
1176            "draft_tokens",
1177            "tags",
1178            "auto_chat_template",
1179        ]
1180    }
1181
1182    /// Known ModelOverride keys.
1183    fn model_override_keys() -> &'static [&'static str] {
1184        &[
1185            "context_length",
1186            "batch_size",
1187            "ubatch_size",
1188            "cache_type_k",
1189            "cache_type_v",
1190            "keep",
1191            "swa_full",
1192            "mlock",
1193            "mmap",
1194            "numa",
1195            "uniform_cache",
1196            "system_prompt",
1197            "system_prompt_preset_name",
1198            "max_concurrent_predictions",
1199            "threads",
1200            "threads_batch",
1201            "parallel",
1202            "gpu_layers",
1203            "split_mode",
1204            "tensor_split",
1205            "main_gpu",
1206            "fit",
1207            "lora",
1208            "lora_scaled",
1209            "rpc",
1210            "embedding",
1211            "kv_cache_offload",
1212            "flash_attn",
1213            "jinja",
1214            "auto_chat_template",
1215            "chat_template",
1216            "chat_template_kwargs",
1217            "expert_count",
1218            "gpu_layers_mode",
1219            "seed",
1220            "temperature",
1221            "top_k",
1222            "top_p",
1223            "min_p",
1224            "typical_p",
1225            "mirostat",
1226            "mirostat_lr",
1227            "mirostat_ent",
1228            "ignore_eos",
1229            "samplers",
1230            "repeat_penalty",
1231            "repeat_last_n",
1232            "presence_penalty",
1233            "frequency_penalty",
1234            "dry_multiplier",
1235            "dry_base",
1236            "dry_allowed_length",
1237            "dry_penalty_last_n",
1238            "rope_scaling",
1239            "rope_scale",
1240            "rope_freq_base",
1241            "rope_freq_scale",
1242            "rope_yarn_enabled",
1243            "cache_prompt",
1244            "cache_reuse",
1245            "webui",
1246            "max_tokens",
1247            "cache_type",
1248            "llama_cpp_version_cpu",
1249            "llama_cpp_version_vulkan",
1250            "llama_cpp_version_rocm",
1251            "llama_cpp_version_rocm_lemonade",
1252            "llama_cpp_version_cuda",
1253            "spec_type",
1254            "draft_tokens",
1255            "tags",
1256        ]
1257    }
1258
1259    /// Known RpcWorker keys.
1260    fn rpc_worker_keys() -> &'static [&'static str] {
1261        &["selected", "name", "ip", "port"]
1262    }
1263
1264    /// Validate unknown YAML keys against known field lists.
1265    pub fn validate_unknown_fields(value: &serde_yml::Value) -> Vec<ValidationWarning> {
1266        let mut warnings = Vec::new();
1267        Self::check_unknown_fields(value, "", Self::config_keys(), &mut warnings);
1268        warnings
1269    }
1270
1271    fn check_unknown_fields(
1272        value: &serde_yml::Value,
1273        prefix: &str,
1274        known_keys: &'static [&'static str],
1275        warnings: &mut Vec<ValidationWarning>,
1276    ) {
1277        if let serde_yml::Value::Mapping(map) = value {
1278            for key in map.keys() {
1279                let key_str = key.as_str();
1280                if !known_keys.contains(&key_str) {
1281                    let field = if prefix.is_empty() {
1282                        key_str.to_string()
1283                    } else {
1284                        format!("{}.{}", prefix, key_str)
1285                    };
1286                    warnings.push(ValidationWarning {
1287                        field,
1288                        message: format!("Unknown config field: {}", key_str),
1289                        severity: ValidationSeverity::Warning,
1290                    });
1291                }
1292            }
1293
1294            // Recurse into nested structures
1295            for (key, val) in map {
1296                let key_str = key.as_str();
1297                let new_prefix = if prefix.is_empty() {
1298                    key_str.to_string()
1299                } else {
1300                    format!("{}.{}", prefix, key_str)
1301                };
1302
1303                match key_str {
1304                    "default" => {
1305                        Self::check_unknown_fields(
1306                            val,
1307                            &new_prefix,
1308                            Self::default_params_keys(),
1309                            warnings,
1310                        );
1311                    }
1312                    "model_overrides" => {
1313                        if let serde_yml::Value::Mapping(overrides) = val {
1314                            for (override_key, override_val) in overrides {
1315                                let k = override_key.as_str();
1316                                let override_prefix = format!("{}.{}", new_prefix, k);
1317                                Self::check_unknown_fields(
1318                                    override_val,
1319                                    &override_prefix,
1320                                    Self::model_override_keys(),
1321                                    warnings,
1322                                );
1323                            }
1324                        }
1325                    }
1326                    "rpc_workers" => {
1327                        if let serde_yml::Value::Sequence(items) = val {
1328                            for item in items {
1329                                Self::check_unknown_fields(
1330                                    item,
1331                                    &new_prefix,
1332                                    Self::rpc_worker_keys(),
1333                                    warnings,
1334                                );
1335                            }
1336                        }
1337                    }
1338                    _ => {
1339                        Self::check_unknown_fields(val, &new_prefix, known_keys, warnings);
1340                    }
1341                }
1342            }
1343        }
1344    }
1345
1346    /// Validate config values and return a list of warnings for invalid entries.
1347    pub fn validate(&self) -> Vec<ValidationWarning> {
1348        let mut warnings = Vec::new();
1349        let default = &self.default;
1350
1351        // Numeric range checks
1352        if default.context_length < 512 || default.context_length > 1048576 {
1353            warnings.push(ValidationWarning {
1354                field: "default.context_length".to_string(),
1355                message: format!(
1356                    "context_length {} is outside recommended range 512-1048576",
1357                    default.context_length
1358                ),
1359                severity: ValidationSeverity::Warning,
1360            });
1361        }
1362        if default.threads == 0 {
1363            warnings.push(ValidationWarning {
1364                field: "default.threads".to_string(),
1365                message: "threads must be greater than 0".to_string(),
1366                severity: ValidationSeverity::Warning,
1367            });
1368        }
1369        if default.temperature < 0.0 || default.temperature > 2.0 {
1370            warnings.push(ValidationWarning {
1371                field: "default.temperature".to_string(),
1372                message: format!(
1373                    "temperature {} is outside recommended range 0.0-2.0",
1374                    default.temperature
1375                ),
1376                severity: ValidationSeverity::Warning,
1377            });
1378        }
1379        if default.top_k < 0 {
1380            warnings.push(ValidationWarning {
1381                field: "default.top_k".to_string(),
1382                message: "top_k must be >= 0".to_string(),
1383                severity: ValidationSeverity::Warning,
1384            });
1385        }
1386        if default.top_p < 0.0 || default.top_p > 1.0 {
1387            warnings.push(ValidationWarning {
1388                field: "default.top_p".to_string(),
1389                message: format!(
1390                    "top_p {} is outside recommended range 0.0-1.0",
1391                    default.top_p
1392                ),
1393                severity: ValidationSeverity::Warning,
1394            });
1395        }
1396        if default.min_p < 0.0 || default.min_p > 1.0 {
1397            warnings.push(ValidationWarning {
1398                field: "default.min_p".to_string(),
1399                message: format!(
1400                    "min_p {} is outside recommended range 0.0-1.0",
1401                    default.min_p
1402                ),
1403                severity: ValidationSeverity::Warning,
1404            });
1405        }
1406        if default.typical_p < 0.0 || default.typical_p > 1.0 {
1407            warnings.push(ValidationWarning {
1408                field: "default.typical_p".to_string(),
1409                message: format!(
1410                    "typical_p {} is outside recommended range 0.0-1.0",
1411                    default.typical_p
1412                ),
1413                severity: ValidationSeverity::Warning,
1414            });
1415        }
1416        if default.repeat_penalty < 0.0 || default.repeat_penalty > 3.0 {
1417            warnings.push(ValidationWarning {
1418                field: "default.repeat_penalty".to_string(),
1419                message: format!(
1420                    "repeat_penalty {} is outside recommended range 0.0-3.0",
1421                    default.repeat_penalty
1422                ),
1423                severity: ValidationSeverity::Warning,
1424            });
1425        }
1426        if default.mirostat_lr < 0.0 || default.mirostat_lr > 1.0 {
1427            warnings.push(ValidationWarning {
1428                field: "default.mirostat_lr".to_string(),
1429                message: format!(
1430                    "mirostat_lr {} is outside recommended range 0.0-1.0",
1431                    default.mirostat_lr
1432                ),
1433                severity: ValidationSeverity::Warning,
1434            });
1435        }
1436        if default.mirostat_ent < 0.0 || default.mirostat_ent > 10.0 {
1437            warnings.push(ValidationWarning {
1438                field: "default.mirostat_ent".to_string(),
1439                message: format!(
1440                    "mirostat_ent {} is outside recommended range 0.0-10.0",
1441                    default.mirostat_ent
1442                ),
1443                severity: ValidationSeverity::Warning,
1444            });
1445        }
1446        if default.dry_multiplier < 0.0 {
1447            warnings.push(ValidationWarning {
1448                field: "default.dry_multiplier".to_string(),
1449                message: "dry_multiplier must be >= 0".to_string(),
1450                severity: ValidationSeverity::Warning,
1451            });
1452        }
1453        if default.dry_base <= 0.0 {
1454            warnings.push(ValidationWarning {
1455                field: "default.dry_base".to_string(),
1456                message: "dry_base must be > 0".to_string(),
1457                severity: ValidationSeverity::Warning,
1458            });
1459        }
1460        if default.rope_scale <= 0.0 {
1461            warnings.push(ValidationWarning {
1462                field: "default.rope_scale".to_string(),
1463                message: "rope_scale must be > 0".to_string(),
1464                severity: ValidationSeverity::Warning,
1465            });
1466        }
1467        if default.rope_freq_scale <= 0.0 {
1468            warnings.push(ValidationWarning {
1469                field: "default.rope_freq_scale".to_string(),
1470                message: "rope_freq_scale must be > 0".to_string(),
1471                severity: ValidationSeverity::Warning,
1472            });
1473        }
1474        if default.port == 0 {
1475            warnings.push(ValidationWarning {
1476                field: "default.port".to_string(),
1477                message: "port must be between 1 and 65535".to_string(),
1478                severity: ValidationSeverity::Error,
1479            });
1480        }
1481        if default.port < 1024 {
1482            warnings.push(ValidationWarning {
1483                field: "default.port".to_string(),
1484                message: format!("port {} is a privileged port (< 1024)", default.port),
1485                severity: ValidationSeverity::Warning,
1486            });
1487        }
1488        if default.api_endpoint_port == 0 {
1489            warnings.push(ValidationWarning {
1490                field: "default.api_endpoint_port".to_string(),
1491                message: "api_endpoint_port must be between 1 and 65535".to_string(),
1492                severity: ValidationSeverity::Error,
1493            });
1494        }
1495        if default.ws_server_port == 0 {
1496            warnings.push(ValidationWarning {
1497                field: "default.ws_server_port".to_string(),
1498                message: "ws_server_port must be between 1 and 65535".to_string(),
1499                severity: ValidationSeverity::Error,
1500            });
1501        }
1502        if default.timeout < 1 {
1503            warnings.push(ValidationWarning {
1504                field: "default.timeout".to_string(),
1505                message: "timeout must be at least 1 second".to_string(),
1506                severity: ValidationSeverity::Warning,
1507            });
1508        }
1509        if default.router_max_models == 0 {
1510            warnings.push(ValidationWarning {
1511                field: "default.router_max_models".to_string(),
1512                message: "router_max_models must be >= 1".to_string(),
1513                severity: ValidationSeverity::Warning,
1514            });
1515        }
1516        if let Some(max_tokens) = default.max_tokens {
1517            if max_tokens == 0 {
1518                warnings.push(ValidationWarning {
1519                    field: "default.max_tokens".to_string(),
1520                    message: "max_tokens must be > 0".to_string(),
1521                    severity: ValidationSeverity::Warning,
1522                });
1523            } else if max_tokens > 65536 {
1524                warnings.push(ValidationWarning {
1525                    field: "default.max_tokens".to_string(),
1526                    message: format!("max_tokens {} is very large (> 65536)", max_tokens),
1527                    severity: ValidationSeverity::Warning,
1528                });
1529            }
1530        }
1531        if default.context_length > 262144 {
1532            warnings.push(ValidationWarning {
1533                field: "default.context_length".to_string(),
1534                message: format!(
1535                    "context_length {} may cause high memory usage",
1536                    default.context_length
1537                ),
1538                severity: ValidationSeverity::Warning,
1539            });
1540        }
1541        if default.threads_batch > default.batch_size && default.batch_size > 0 {
1542            warnings.push(ValidationWarning {
1543                field: "default.threads_batch".to_string(),
1544                message: "threads_batch should not exceed batch_size".to_string(),
1545                severity: ValidationSeverity::Warning,
1546            });
1547        }
1548
1549        // Path validation
1550        if let Some(lora) = &default.lora
1551            && !lora.exists()
1552        {
1553            warnings.push(ValidationWarning {
1554                field: "default.lora".to_string(),
1555                message: format!("lora path does not exist: {}", lora.display()),
1556                severity: ValidationSeverity::Warning,
1557            });
1558        }
1559        if let Some((lora, _)) = &default.lora_scaled
1560            && !lora.exists()
1561        {
1562            warnings.push(ValidationWarning {
1563                field: "default.lora_scaled".to_string(),
1564                message: format!("lora path does not exist: {}", lora.display()),
1565                severity: ValidationSeverity::Warning,
1566            });
1567        }
1568        if self.llama_server.is_absolute() && !self.llama_server.exists() {
1569            warnings.push(ValidationWarning {
1570                field: "llama_server".to_string(),
1571                message: format!(
1572                    "llama_server binary not found: {}",
1573                    self.llama_server.display()
1574                ),
1575                severity: ValidationSeverity::Warning,
1576            });
1577        }
1578
1579        // Model override validation
1580        for model_name in self.model_overrides.keys() {
1581            if let Some(override_settings) = self.model_overrides.get(model_name.as_str()) {
1582                if let Some(lora) = &override_settings.lora
1583                    && !lora.exists()
1584                {
1585                    warnings.push(ValidationWarning {
1586                        field: format!("model_overrides.{}.lora", model_name),
1587                        message: format!("lora path does not exist: {}", lora.display()),
1588                        severity: ValidationSeverity::Warning,
1589                    });
1590                }
1591                if let Some((lora, _)) = &override_settings.lora_scaled
1592                    && !lora.exists()
1593                {
1594                    warnings.push(ValidationWarning {
1595                        field: format!("model_overrides.{}.lora_scaled", model_name),
1596                        message: format!("lora path does not exist: {}", lora.display()),
1597                        severity: ValidationSeverity::Warning,
1598                    });
1599                }
1600            }
1601        }
1602
1603        // Cross-field validation
1604        if default.port == default.api_endpoint_port {
1605            warnings.push(ValidationWarning {
1606                field: "default.port".to_string(),
1607                message: format!(
1608                    "port conflict: server port and API endpoint port both set to {}",
1609                    default.port
1610                ),
1611                severity: ValidationSeverity::Warning,
1612            });
1613        }
1614        if default.port == default.ws_server_port {
1615            warnings.push(ValidationWarning {
1616                field: "default.port".to_string(),
1617                message: format!(
1618                    "port conflict: server port and WS server port both set to {}",
1619                    default.port
1620                ),
1621                severity: ValidationSeverity::Warning,
1622            });
1623        }
1624        if default.api_endpoint_port == default.ws_server_port {
1625            warnings.push(ValidationWarning {
1626                field: "default.api_endpoint_port".to_string(),
1627                message: format!(
1628                    "port conflict: API endpoint port and WS server port both set to {}",
1629                    default.api_endpoint_port
1630                ),
1631                severity: ValidationSeverity::Warning,
1632            });
1633        }
1634        if matches!(default.server_mode, crate::models::ServerMode::Router)
1635            && default.router_max_models < 2
1636        {
1637            warnings.push(ValidationWarning {
1638                field: "default.router_max_models".to_string(),
1639                message: "router_max_models should be >= 2 for Router mode".to_string(),
1640                severity: ValidationSeverity::Warning,
1641            });
1642        }
1643        if !default.spec_type.is_empty() && default.draft_tokens == 0 {
1644            warnings.push(ValidationWarning {
1645                field: "default.spec_type".to_string(),
1646                message: "spec_type is set but draft_tokens is 0".to_string(),
1647                severity: ValidationSeverity::Warning,
1648            });
1649        }
1650        if default.server_tls_enabled {
1651            if let Some(cert) = &default.server_tls_cert
1652                && !cert.is_empty()
1653                && !std::path::Path::new(cert).exists()
1654            {
1655                warnings.push(ValidationWarning {
1656                    field: "default.server_tls_cert".to_string(),
1657                    message: format!("TLS cert path does not exist: {}", cert),
1658                    severity: ValidationSeverity::Warning,
1659                });
1660            }
1661            if let Some(key) = &default.server_tls_key
1662                && !key.is_empty()
1663                && !std::path::Path::new(key).exists()
1664            {
1665                warnings.push(ValidationWarning {
1666                    field: "default.server_tls_key".to_string(),
1667                    message: format!("TLS key path does not exist: {}", key),
1668                    severity: ValidationSeverity::Warning,
1669                });
1670            }
1671        }
1672
1673        warnings
1674    }
1675
1676    /// Resolve settings for a specific model and profile.
1677    pub fn resolve_settings(
1678        &self,
1679        model_name: Option<&str>,
1680        profile_name: Option<&str>,
1681    ) -> crate::models::ModelSettings {
1682        let mut settings = crate::models::ModelSettings::from_config(self);
1683
1684        // Apply model-specific override
1685        if let Some(name) = model_name
1686            && let Some(override_settings) = self.model_overrides.get(name)
1687        {
1688            override_settings.apply(&mut settings);
1689        }
1690
1691        // Apply profile override if specified
1692        if let Some(p_name) = profile_name {
1693            if let Some(profile) = self.profiles.get(p_name) {
1694                profile.settings.apply(&mut settings);
1695            } else if let Some(profile) = builtin_profiles().iter().find(|p| p.name == p_name) {
1696                profile.settings.apply(&mut settings);
1697            }
1698        }
1699
1700        // Resolve system_prompt from preset name (after all overrides)
1701        if let Some(preset) = self
1702            .system_prompt_presets
1703            .get(&settings.system_prompt_preset_name)
1704        {
1705            settings.system_prompt = preset.content.clone();
1706        }
1707
1708        settings
1709    }
1710
1711    /// Get a system prompt preset content by name.
1712    pub fn get_preset_content(&self, name: &str) -> Option<String> {
1713        self.system_prompt_presets
1714            .get(name)
1715            .map(|p| p.content.clone())
1716    }
1717
1718    fn normalize_config(mut config: Config) -> Config {
1719        // normalize models_dirs
1720        for path in &mut config.models_dirs {
1721            let path_str = path.to_string_lossy();
1722            if let Some(stripped) = path_str.strip_prefix("~/") {
1723                let home = dirs::home_dir().unwrap_or_default();
1724                *path = home.join(stripped);
1725            } else if !path.is_absolute() {
1726                let home = dirs::home_dir().unwrap_or_default();
1727                *path = home.join(path_str.as_ref());
1728            }
1729        }
1730
1731        // Merge built-in profiles into in-memory cache (do not persist to disk)
1732        for p in builtin_profiles() {
1733            if config.profiles.get(&p.name).is_none() {
1734                config.profiles.insert_builtin(p);
1735            }
1736        }
1737
1738        // Merge built-in system prompt presets into in-memory cache (do not persist to disk)
1739        for p in builtin_system_prompt_presets() {
1740            if config.system_prompt_presets.get(&p.name).is_none() {
1741                config.system_prompt_presets.insert_builtin(p);
1742            }
1743        }
1744        config
1745    }
1746
1747    fn load_impl(path: &PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
1748        let content = std::fs::read_to_string(path)?;
1749
1750        // Phase 1: Parse as Value to check for unknown fields
1751        let parsed: serde_yml::Value = serde_yml::from_str(&content)
1752            .map_err(|e| format!("Failed to parse config file {}: {}", path.display(), e))?;
1753        let mut warnings = Self::validate_unknown_fields(&parsed);
1754
1755        // Phase 2: Deserialize normally (serde_yaml ignores unknown fields)
1756        let config: Config = serde_yml::from_str(&content)
1757            .map_err(|e| format!("Failed to parse config file {}: {}", path.display(), e))?;
1758        let config = Self::normalize_config(config);
1759        let config = config.auto_detect_platform();
1760
1761        // Phase 3: Value validation
1762        warnings.extend(config.validate());
1763
1764        // Log warnings
1765        if !warnings.is_empty() {
1766            eprintln!("Config validation warnings:");
1767            for warning in &warnings {
1768                eprintln!(
1769                    "  [{}] {}: {}",
1770                    match warning.severity {
1771                        ValidationSeverity::Warning => "WARN",
1772                        ValidationSeverity::Error => "ERROR",
1773                    },
1774                    warning.field,
1775                    warning.message
1776                );
1777            }
1778        }
1779        Ok(config)
1780    }
1781
1782    pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
1783        let path = Self::config_path();
1784        if path.exists() {
1785            Self::load_impl(&path)
1786        } else {
1787            let mut config = Config::default();
1788            config.save()?;
1789            Ok(config)
1790        }
1791    }
1792
1793    pub fn load_from(path: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
1794        if path.exists() {
1795            Self::load_impl(&path)
1796        } else {
1797            Err(format!("Config file not found: {}", path.display()).into())
1798        }
1799    }
1800
1801    /// Auto-detect the platform if not explicitly set in config.
1802    fn auto_detect_platform(mut self) -> Self {
1803        if self.default.platform.is_none() {
1804            self.default.platform =
1805                Some(
1806                    crate::backend::hardware::platform_name(
1807                        crate::backend::hardware::detect_platform(),
1808                    )
1809                    .to_string(),
1810                );
1811        }
1812        self
1813    }
1814
1815    pub fn save(&mut self) -> Result<(), Box<dyn std::error::Error>> {
1816        let path = Self::config_path();
1817        if let Some(parent) = path.parent() {
1818            std::fs::create_dir_all(parent)?;
1819        }
1820        let content = serde_yml::to_string(self)?;
1821        std::fs::write(&path, content)?;
1822        #[cfg(unix)]
1823        {
1824            use std::os::unix::fs::PermissionsExt;
1825            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
1826        }
1827        // Persist model configs to individual YAML files
1828        let entries: Vec<(String, ModelOverride)> = self
1829            .model_overrides
1830            .keys()
1831            .iter()
1832            .filter_map(|k| self.model_overrides.get(k).map(|v| (k.clone(), v.clone())))
1833            .collect();
1834        for (name, cfg) in entries {
1835            self.model_overrides.save(&name, &cfg);
1836        }
1837        // Persist user profiles to individual YAML files (skip built-ins)
1838        for profile in self.profiles.user_profiles() {
1839            self.profiles.save(&profile);
1840        }
1841        // Persist user presets to individual YAML files (skip built-ins)
1842        for preset in self.system_prompt_presets.user_presets() {
1843            self.system_prompt_presets.save(&preset);
1844        }
1845        Ok(())
1846    }
1847
1848    pub fn merged_profiles(&self) -> Vec<Profile> {
1849        self.profiles.all()
1850    }
1851
1852    pub fn merged_presets(&self) -> Vec<SystemPromptPreset> {
1853        self.system_prompt_presets.all()
1854    }
1855}
1856
1857#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1858pub enum LogLevel {
1859    Info,
1860    Warning,
1861    Error,
1862}
1863
1864#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1865pub enum ValidationSeverity {
1866    Warning,
1867    Error,
1868}
1869
1870#[derive(Debug, Clone)]
1871pub struct ValidationWarning {
1872    pub field: String,
1873    pub message: String,
1874    pub severity: ValidationSeverity,
1875}
1876
1877impl LogLevel {
1878    pub fn label(&self) -> &'static str {
1879        match self {
1880            LogLevel::Info => "INFO",
1881            LogLevel::Warning => "WARNING",
1882            LogLevel::Error => "ERROR",
1883        }
1884    }
1885}
1886
1887#[derive(Debug, Clone)]
1888pub struct LogEntry {
1889    pub timestamp: String,
1890    pub level: LogLevel,
1891    pub message: String,
1892}
1893
1894impl LogEntry {
1895    pub fn new(message: impl Into<String>, level: LogLevel) -> Self {
1896        let timestamp = Local::now().format("%H:%M:%S").to_string();
1897        let message = sanitize_log(&message.into());
1898        Self {
1899            timestamp,
1900            level,
1901            message,
1902        }
1903    }
1904}
1905
1906/// Sanitize log messages to prevent TUI layout breakages.
1907/// Strips non-printable characters and control sequences, and limits length.
1908fn sanitize_log(input: &str) -> String {
1909    // Limit length to avoid layout/perf issues with massive lines
1910    let max_len = 2000;
1911    let chars: Vec<char> = input.chars().collect();
1912    let truncated = chars.len() > max_len;
1913    let chars = if truncated {
1914        chars[..max_len].to_vec()
1915    } else {
1916        chars
1917    };
1918
1919    let mut output = String::with_capacity(chars.len());
1920    for c in chars {
1921        // Strip ALL control characters except newline and tab.
1922        // Critically: strip \r (carriage return) as it breaks TUI rendering.
1923        if c.is_control() && c != '\n' && c != '\t' {
1924            continue;
1925        }
1926        output.push(c);
1927    }
1928
1929    // Replace tabs with spaces for consistent rendering
1930    let output = output.replace('\t', "    ");
1931
1932    // Final trim to remove trailing junk
1933    let mut result = output.trim_end().to_string();
1934    if truncated {
1935        result.push_str("... (truncated)");
1936    }
1937    result
1938}