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