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