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