Skip to main content

llm_manager/
models.rs

1use std::path::PathBuf;
2use std::time::Duration;
3
4use serde::{Deserialize, Serialize};
5
6/// The state of a model in the manager.
7#[derive(Debug, Clone, PartialEq)]
8pub enum ModelState {
9    Available,
10    Loading,
11    Benchmarking,
12    Loaded { port: u16, pid: u32 },
13    Failed { error: String },
14}
15
16/// Sort order for search results.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SearchSort {
19    Relevance,
20    Downloads,
21    Likes,
22    Trending,
23    CreatedAt,
24}
25
26impl SearchSort {
27    pub fn next(self) -> Self {
28        match self {
29            SearchSort::Relevance => SearchSort::Downloads,
30            SearchSort::Downloads => SearchSort::Likes,
31            SearchSort::Likes => SearchSort::Trending,
32            SearchSort::Trending => SearchSort::CreatedAt,
33            SearchSort::CreatedAt => SearchSort::Relevance,
34        }
35    }
36
37    pub fn label(self) -> String {
38        match self {
39            SearchSort::Relevance => crate::t!("models.sort.relevance").to_string(),
40            SearchSort::Downloads => crate::t!("models.sort.downloads").to_string(),
41            SearchSort::Likes => crate::t!("models.sort.likes").to_string(),
42            SearchSort::Trending => crate::t!("models.sort.trending").to_string(),
43            SearchSort::CreatedAt => crate::t!("models.sort.created").to_string(),
44        }
45    }
46}
47
48/// Sort order for the local models list.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ListSort {
51    Name,
52    Status,
53    Params,
54    Qual,
55    Context,
56}
57
58impl ListSort {
59    pub fn next(self) -> Self {
60        match self {
61            ListSort::Name => ListSort::Status,
62            ListSort::Status => ListSort::Params,
63            ListSort::Params => ListSort::Qual,
64            ListSort::Qual => ListSort::Context,
65            ListSort::Context => ListSort::Name,
66        }
67    }
68
69    pub fn label(self) -> String {
70        match self {
71            ListSort::Name => crate::t!("models.list_sort.name").to_string(),
72            ListSort::Status => crate::t!("models.list_sort.status").to_string(),
73            ListSort::Params => crate::t!("models.list_sort.params").to_string(),
74            ListSort::Qual => crate::t!("models.list_sort.qual").to_string(),
75            ListSort::Context => crate::t!("models.list_sort.context").to_string(),
76        }
77    }
78
79    pub fn is_ascending(self) -> bool {
80        matches!(self, ListSort::Name)
81    }
82}
83
84/// A model found via HuggingFace search.
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86pub struct SearchResult {
87    pub model_id: String,
88    pub model_name: String,
89    pub tags: Vec<String>,
90    pub downloads: u64,
91    pub likes: u64,
92    pub pipeline_tag: Option<String>,
93    pub size: Option<u64>,
94    pub parameters: Option<String>,
95    pub capabilities: Vec<String>,
96    pub context_length: Option<u32>,
97    pub readme: Option<String>,
98    /// Quantization type extracted from GGUF metadata (e.g. "Q4_K_M", "Q8_0").
99    pub quantization: Option<String>,
100    /// License extracted from tags (e.g. "apache-2.0", "llama3.1").
101    pub license: Option<String>,
102    /// HuggingFace trending score.
103    pub trending_score: i64,
104    /// Creation timestamp string.
105    pub created_at: Option<String>,
106    /// Whether a matching GGUF file is already downloaded locally.
107    #[serde(default)]
108    pub downloaded: bool,
109}
110
111/// Download progress information.
112#[derive(Debug, Clone)]
113pub struct DownloadState {
114    pub model_id: String,
115    pub filename: String,
116    pub total_bytes: u64,
117    pub downloaded_bytes: u64,
118    pub status: DownloadStatus,
119    pub cancelled: bool,
120    pub cancel_token: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
121    /// Download control: 1=downloading, 2=paused, 3=cancelled
122    pub download_state: u8,
123    /// Shared atomic state for pausing/resuming the download loop
124    pub download_state_arc: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
125    pub start_time: std::time::Instant,
126    pub bytes_per_second: f64,
127    /// Filesystem path where the download is being saved.
128    pub dest: Option<std::path::PathBuf>,
129}
130
131impl DownloadState {
132    pub fn new(model_id: String, filename: String, total_bytes: u64) -> Self {
133        Self {
134            model_id,
135            filename,
136            total_bytes,
137            downloaded_bytes: 0,
138            status: DownloadStatus::Downloading,
139            cancelled: false,
140            cancel_token: None,
141            download_state: 1,
142            download_state_arc: None,
143            start_time: std::time::Instant::now(),
144            bytes_per_second: 0.0,
145            dest: None,
146        }
147    }
148}
149
150impl std::hash::Hash for ModelSettings {
151    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
152        // ── Loading ──
153        self.context_length.hash(state);
154        self.threads.hash(state);
155        self.threads_batch.hash(state);
156        self.batch_size.hash(state);
157        self.ubatch_size.hash(state);
158        self.parallel.hash(state);
159        self.max_concurrent_predictions.hash(state);
160        self.uniform_cache.hash(state);
161        self.kv_cache_offload.hash(state);
162        self.cache_type_k.hash(state);
163        self.cache_type_v.hash(state);
164        self.keep.hash(state);
165        self.swa_full.hash(state);
166        self.mlock.hash(state);
167        self.mmap.hash(state);
168        self.numa.hash(state);
169        self.system_prompt.hash(state);
170        self.system_prompt_preset_name.hash(state);
171        // ── GPU ──
172        self.gpu_layers_mode.hash(state);
173        self.split_mode.hash(state);
174        self.tensor_split.hash(state);
175        self.main_gpu.hash(state);
176        self.fit.hash(state);
177        self.lora.hash(state);
178        if let Some((path, scale)) = &self.lora_scaled {
179            path.hash(state);
180            scale.to_bits().hash(state);
181        }
182        self.rpc.hash(state);
183        self.embedding.hash(state);
184        self.flash_attn.hash(state);
185        self.expert_count.hash(state);
186        self.jinja.hash(state);
187        self.auto_chat_template.hash(state);
188        self.chat_template.hash(state);
189        self.chat_template_kwargs.hash(state);
190        // ── Sampling ──
191        self.seed.hash(state);
192        self.temperature.to_bits().hash(state);
193        self.top_k.hash(state);
194        self.top_p.to_bits().hash(state);
195        self.min_p.to_bits().hash(state);
196        self.typical_p.to_bits().hash(state);
197        self.mirostat.hash(state);
198        self.mirostat_lr.to_bits().hash(state);
199        self.mirostat_ent.to_bits().hash(state);
200        self.ignore_eos.hash(state);
201        self.samplers.hash(state);
202        // ── Repetition Control ──
203        self.repeat_penalty.to_bits().hash(state);
204        self.repeat_last_n.hash(state);
205        self.presence_penalty.map(|v| v.to_bits()).hash(state);
206        self.frequency_penalty.map(|v| v.to_bits()).hash(state);
207        self.dry_multiplier.to_bits().hash(state);
208        self.dry_base.to_bits().hash(state);
209        self.dry_allowed_length.hash(state);
210        self.dry_penalty_last_n.hash(state);
211        // ── RoPE ──
212        self.rope_scaling.hash(state);
213        self.rope_scale.to_bits().hash(state);
214        self.rope_freq_base.to_bits().hash(state);
215        self.rope_freq_scale.to_bits().hash(state);
216        self.rope_yarn_enabled.hash(state);
217        // ── Server ──
218        self.host.hash(state);
219        self.port.hash(state);
220        self.timeout.hash(state);
221        self.cache_prompt.hash(state);
222        self.cache_reuse.hash(state);
223        self.webui.hash(state);
224        // ── Other ──
225        self.max_tokens.hash(state);
226        self.cache_type.hash(state);
227        self.backend.hash(state);
228        self.llama_cpp_version_cpu.hash(state);
229        self.llama_cpp_version_vulkan.hash(state);
230        self.llama_cpp_version_rocm.hash(state);
231        self.llama_cpp_version_rocm_lemonade.hash(state);
232        self.llama_cpp_version_cuda.hash(state);
233        self.api_endpoint_enabled.hash(state);
234        self.api_endpoint_port.hash(state);
235        self.spec_type.hash(state);
236        self.draft_tokens.hash(state);
237        self.tags.hash(state);
238        self.web_search_engine.hash(state);
239        self.web_search_engine_url.hash(state);
240    }
241}
242
243impl ModelSettings {
244    /// Get the version string for the currently active backend.
245    pub fn get_active_backend_version(&self) -> Option<&String> {
246        match self.backend {
247            Backend::Cpu => self.llama_cpp_version_cpu.as_ref(),
248            Backend::Vulkan => self.llama_cpp_version_vulkan.as_ref(),
249            Backend::Rocm => self.llama_cpp_version_rocm.as_ref(),
250            Backend::RocmLemonade => self.llama_cpp_version_rocm_lemonade.as_ref(),
251            Backend::Cuda => self.llama_cpp_version_cuda.as_ref(),
252            _ => None,
253        }
254    }
255
256    /// Get the display version string for the currently active backend (defaults to "latest").
257    pub fn get_active_backend_version_display(&self) -> &str {
258        self.get_active_backend_version()
259            .map(|s| s.as_str())
260            .unwrap_or("latest")
261    }
262
263    /// Set the version string for the currently active backend.
264    pub fn set_active_backend_version(&mut self, tag: Option<String>) {
265        match self.backend {
266            Backend::Cpu => self.llama_cpp_version_cpu = tag,
267            Backend::Vulkan => self.llama_cpp_version_vulkan = tag,
268            Backend::Rocm => self.llama_cpp_version_rocm = tag,
269            Backend::RocmLemonade => self.llama_cpp_version_rocm_lemonade = tag,
270            Backend::Cuda => self.llama_cpp_version_cuda = tag,
271            _ => {}
272        }
273    }
274}
275
276/// Strip the .gguf extension from a model name.
277pub fn strip_gguf(name: &str) -> &str {
278    name.strip_suffix(".gguf")
279        .or_else(|| name.strip_suffix(".GGUF"))
280        .unwrap_or(name)
281}
282
283/// Extract just the filename from a path, stripping the .gguf extension.
284/// e.g., "repo/model/file.gguf" → "file"
285pub fn model_filename(name: &str) -> String {
286    let filename = std::path::Path::new(name)
287        .file_name()
288        .map(|f| f.to_string_lossy().to_string())
289        .unwrap_or_else(|| name.to_string());
290    strip_gguf(&filename).to_string()
291}
292
293/// Ensure host string is valid for URL construction and CLI arguments.
294/// Handles empty strings (defaults to 127.0.0.1), strips display suffixes,
295/// and wraps IPv6 addresses in brackets.
296pub fn clean_host(host: &str) -> String {
297    let host = host.trim();
298    if host.is_empty() {
299        return "127.0.0.1".to_string();
300    }
301    // Remove (xxx) suffixes often used in display, e.g. "localhost (127.0.0.1)"
302    let host = host.split_whitespace().next().unwrap_or(host);
303    if host.contains(':') && !host.starts_with('[') {
304        format!("[{}]", host)
305    } else {
306        host.to_string()
307    }
308}
309
310/// Format a host string for display (e.g. "" or "127.0.0.1" -> "localhost (127.0.0.1)").
311pub fn format_host(host: &str) -> String {
312    match host {
313        "" | "127.0.0.1" => crate::t!("server.host_label").to_string(),
314        _ => host.to_string(),
315    }
316}
317
318impl From<crate::config::DefaultParams> for ModelSettings {
319    fn from(dp: crate::config::DefaultParams) -> Self {
320        Self {
321            context_length: dp.context_length,
322            threads: dp.threads,
323            threads_batch: dp.threads_batch,
324            batch_size: dp.batch_size,
325            ubatch_size: dp.ubatch_size,
326            parallel: dp.parallel,
327            max_concurrent_predictions: dp.max_concurrent_predictions,
328            uniform_cache: dp.uniform_cache,
329            kv_cache_offload: dp.kv_cache_offload,
330            cache_type_k: dp.cache_type_k,
331            cache_type_v: dp.cache_type_v,
332            keep: dp.keep,
333            swa_full: dp.swa_full,
334            mlock: dp.mlock,
335            mmap: dp.mmap,
336            numa: dp.numa,
337            system_prompt: dp.system_prompt,
338            system_prompt_preset_name: dp.system_prompt_preset_name,
339            web_search_engine: dp.web_search_engine.clone(),
340            web_search_engine_url: dp.web_search_engine_url.clone(),
341
342            gpu_layers_mode: match dp.gpu_layers {
343                n if n < 0 => GpuLayersMode::All,
344                _ => dp.gpu_layers_mode,
345            },
346            split_mode: dp.split_mode,
347            tensor_split: dp.tensor_split,
348            main_gpu: dp.main_gpu,
349            fit: dp.fit,
350            lora: dp.lora,
351            lora_scaled: dp.lora_scaled,
352            rpc: dp.rpc,
353            embedding: dp.embedding,
354            flash_attn: dp.flash_attn,
355            expert_count: dp.expert_count,
356            jinja: dp.jinja,
357            auto_chat_template: dp.auto_chat_template,
358            chat_template: dp.chat_template,
359            chat_template_kwargs: dp.chat_template_kwargs,
360            seed: dp.seed,
361            temperature: dp.temperature,
362            top_k: dp.top_k,
363            top_p: dp.top_p,
364            min_p: dp.min_p,
365            typical_p: dp.typical_p,
366            mirostat: dp.mirostat,
367            mirostat_lr: dp.mirostat_lr,
368            mirostat_ent: dp.mirostat_ent,
369            ignore_eos: dp.ignore_eos,
370            samplers: dp.samplers,
371            repeat_penalty: dp.repeat_penalty,
372            repeat_last_n: dp.repeat_last_n,
373            presence_penalty: dp.presence_penalty,
374            frequency_penalty: dp.frequency_penalty,
375            dry_multiplier: dp.dry_multiplier,
376            dry_base: dp.dry_base,
377            dry_allowed_length: dp.dry_allowed_length,
378            dry_penalty_last_n: dp.dry_penalty_last_n,
379            rope_scaling: dp.rope_scaling,
380            rope_scale: dp.rope_scale,
381            rope_freq_base: dp.rope_freq_base,
382            rope_freq_scale: dp.rope_freq_scale,
383            rope_yarn_enabled: dp.rope_yarn_enabled,
384            host: dp.host,
385            port: dp.port,
386            timeout: dp.timeout,
387            cache_prompt: dp.cache_prompt,
388            cache_reuse: dp.cache_reuse,
389            webui: dp.webui,
390            max_tokens: dp.max_tokens,
391            cache_type: dp.cache_type,
392            backend: dp.backend,
393            llama_cpp_version_cpu: dp.llama_cpp_version_cpu,
394            llama_cpp_version_vulkan: dp.llama_cpp_version_vulkan,
395            llama_cpp_version_rocm: dp.llama_cpp_version_rocm,
396            llama_cpp_version_rocm_lemonade: dp.llama_cpp_version_rocm_lemonade,
397            llama_cpp_version_cuda: dp.llama_cpp_version_cuda,
398            api_endpoint_enabled: dp.api_endpoint_enabled,
399            api_endpoint_port: dp.api_endpoint_port,
400            api_endpoint_key: dp.api_endpoint_key,
401
402            spec_type: dp.spec_type,
403            draft_tokens: dp.draft_tokens,
404            tags: dp.tags,
405        }
406    }
407}
408
409/// How to handle GPU layer offloading.
410#[derive(
411    Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, Default,
412)]
413pub enum GpuLayersMode {
414    #[default]
415    Auto,
416    Specific(u32),
417    All,
418}
419
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub enum DownloadStatus {
422    Downloading,
423    Pausing,
424    Paused,
425    Complete,
426    Error(String),
427    Cancelled,
428}
429
430// ── Cache type enums ──────────────────────────────────────────
431
432/// Main KV cache data type.
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
434pub enum CacheType {
435    #[serde(rename = "f16")]
436    #[default]
437    F16,
438    #[serde(rename = "bf16")]
439    BF16,
440    #[serde(rename = "fq8_0")]
441    Fq8_0,
442    #[serde(rename = "fq4_1")]
443    Fq4_1,
444}
445
446impl std::fmt::Display for CacheType {
447    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448        match self {
449            CacheType::F16 => write!(f, "f16"),
450            CacheType::BF16 => write!(f, "bf16"),
451            CacheType::Fq8_0 => write!(f, "fq8_0"),
452            CacheType::Fq4_1 => write!(f, "fq4_1"),
453        }
454    }
455}
456
457/// KV cache quantization type.
458#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default, Hash)]
459pub enum CacheQuantType {
460    #[serde(rename = "f32")]
461    F32,
462    #[serde(rename = "f16")]
463    #[default]
464    F16,
465    #[serde(rename = "bf16")]
466    BF16,
467    #[serde(rename = "q8_0")]
468    Q8_0,
469    #[serde(rename = "q4_0")]
470    Q4_0,
471    #[serde(rename = "q4_1")]
472    Q4_1,
473    #[serde(rename = "iq4_nl")]
474    Iq4Nl,
475    #[serde(rename = "q5_0")]
476    Q5_0,
477    #[serde(rename = "q5_1")]
478    Q5_1,
479}
480
481pub type CacheTypeK = CacheQuantType;
482pub type CacheTypeV = CacheQuantType;
483
484impl CacheQuantType {
485    pub fn from_u8(n: u8) -> Self {
486        match n {
487            0 => Self::F32,
488            1 => Self::F16,
489            2 => Self::BF16,
490            3 => Self::Q8_0,
491            4 => Self::Q5_1,
492            5 => Self::Q5_0,
493            6 => Self::Q4_1,
494            7 => Self::Q4_0,
495            8 => Self::Iq4Nl,
496            _ => Self::F16,
497        }
498    }
499    pub fn next(&self) -> Self {
500        match self {
501            Self::F32 => Self::F16,
502            Self::F16 => Self::BF16,
503            Self::BF16 => Self::Q8_0,
504            Self::Q8_0 => Self::Q5_1,
505            Self::Q5_1 => Self::Q5_0,
506            Self::Q5_0 => Self::Q4_1,
507            Self::Q4_1 => Self::Q4_0,
508            Self::Q4_0 => Self::Iq4Nl,
509            Self::Iq4Nl => Self::F32,
510        }
511    }
512    pub fn prev(&self) -> Self {
513        match self {
514            Self::F32 => Self::Iq4Nl,
515            Self::F16 => Self::F32,
516            Self::BF16 => Self::F16,
517            Self::Q8_0 => Self::BF16,
518            Self::Q5_1 => Self::Q8_0,
519            Self::Q5_0 => Self::Q5_1,
520            Self::Q4_1 => Self::Q5_0,
521            Self::Q4_0 => Self::Q4_1,
522            Self::Iq4Nl => Self::Q4_0,
523        }
524    }
525}
526
527impl std::fmt::Display for CacheQuantType {
528    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
529        match self {
530            Self::F32 => write!(f, "f32"),
531            Self::F16 => write!(f, "f16"),
532            Self::BF16 => write!(f, "bf16"),
533            Self::Q8_0 => write!(f, "q8_0"),
534            Self::Q4_0 => write!(f, "q4_0"),
535            Self::Q4_1 => write!(f, "q4_1"),
536            Self::Iq4Nl => write!(f, "iq4_nl"),
537            Self::Q5_0 => write!(f, "q5_0"),
538            Self::Q5_1 => write!(f, "q5_1"),
539        }
540    }
541}
542
543impl From<&str> for CacheQuantType {
544    fn from(s: &str) -> Self {
545        match s {
546            "F32" => Self::F32,
547            "F16" => Self::F16,
548            "BF16" => Self::BF16,
549            "Q8_0" => Self::Q8_0,
550            "Q4_0" => Self::Q4_0,
551            "Q4_1" => Self::Q4_1,
552            "Iq4Nl" => Self::Iq4Nl,
553            "Q5_0" => Self::Q5_0,
554            "Q5_1" => Self::Q5_1,
555            _ => Self::F16, // Default or error handling
556        }
557    }
558}
559
560/// Split mode for multi-GPU.
561#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash, Default)]
562pub enum SplitMode {
563    #[serde(rename = "none")]
564    None,
565    #[serde(rename = "layer")]
566    #[default]
567    Layer,
568    #[serde(rename = "row")]
569    Row,
570    #[serde(rename = "tensor")]
571    Tensor,
572}
573
574impl std::fmt::Display for SplitMode {
575    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
576        match self {
577            SplitMode::None => write!(f, "none"),
578            SplitMode::Layer => write!(f, "layer"),
579            SplitMode::Row => write!(f, "row"),
580            SplitMode::Tensor => write!(f, "tensor"),
581        }
582    }
583}
584
585/// NUMA optimization mode.
586#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash, Default)]
587pub enum NumMode {
588    #[serde(rename = "none")]
589    #[default]
590    None,
591    #[serde(rename = "distribute")]
592    Distribute,
593    #[serde(rename = "isolate")]
594    Isolate,
595    #[serde(rename = "numactl")]
596    Numactl,
597}
598
599impl std::fmt::Display for NumMode {
600    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
601        match self {
602            NumMode::None => write!(f, "none"),
603            NumMode::Distribute => write!(f, "distribute"),
604            NumMode::Isolate => write!(f, "isolate"),
605            NumMode::Numactl => write!(f, "numactl"),
606        }
607    }
608}
609
610/// RoPE frequency scaling method.
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
612pub enum RopeScaling {
613    #[serde(rename = "none")]
614    #[default]
615    None,
616    #[serde(rename = "linear")]
617    Linear,
618    #[serde(rename = "yarn")]
619    Yarn,
620}
621
622impl std::fmt::Display for RopeScaling {
623    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
624        match self {
625            RopeScaling::None => write!(f, "none"),
626            RopeScaling::Linear => write!(f, "linear"),
627            RopeScaling::Yarn => write!(f, "yarn"),
628        }
629    }
630}
631
632/// Mirostat version.
633#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
634pub enum Mirostat {
635    #[serde(rename = "0")]
636    #[default]
637    Off,
638    #[serde(rename = "1")]
639    V1,
640    #[serde(rename = "2")]
641    Mirostat2,
642}
643
644impl std::fmt::Display for Mirostat {
645    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
646        match self {
647            Mirostat::Off => write!(f, "off"),
648            Mirostat::V1 => write!(f, "1"),
649            Mirostat::Mirostat2 => write!(f, "2"),
650        }
651    }
652}
653
654/// Sampler order string (semicolon-separated).
655/// Common types: penalties, dry, top_n_sigma, top_k, typ_p, top_p, min_p, xtc, temperature
656#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
657pub struct Samplers(pub String);
658
659impl Default for Samplers {
660    fn default() -> Self {
661        Self("top_k;top_p;temperature".to_string())
662    }
663}
664
665impl std::fmt::Display for Samplers {
666    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
667        write!(f, "{}", self.0)
668    }
669}
670
671/// Backend used to run the llama.cpp server.
672#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
673pub enum Backend {
674    #[serde(rename = "cpu")]
675    #[default]
676    Cpu,
677    #[serde(rename = "vulkan")]
678    Vulkan,
679    #[serde(rename = "rocm")]
680    Rocm,
681    #[serde(rename = "rocm_lemonade")]
682    RocmLemonade,
683    #[serde(rename = "cuda")]
684    Cuda,
685    #[serde(rename = "cpu_arm64")]
686    CpuArm64,
687    #[serde(rename = "win_cpu")]
688    CpuWindows,
689    #[serde(rename = "win_vulkan")]
690    VulkanWindows,
691    #[serde(rename = "win_cuda_12_4")]
692    CudaWindows12_4,
693    #[serde(rename = "win_cuda_13_1")]
694    CudaWindows13_1,
695    #[serde(rename = "win_hip")]
696    HipWindows,
697    #[serde(rename = "macos_arm64")]
698    CpuMacosArm64,
699    #[serde(rename = "macos_x64")]
700    CpuMacosX64,
701}
702
703impl Backend {
704    /// Get the identifier used for directory names and asset prefixes.
705    pub fn slug(&self) -> &'static str {
706        match self {
707            Backend::Cpu => "cpu",
708            Backend::Vulkan => "vulkan",
709            Backend::Rocm => "rocm",
710            Backend::RocmLemonade => "rocm-lemonade",
711            Backend::Cuda => "cuda",
712            Backend::CpuArm64 => "cpu-arm64",
713            Backend::CpuWindows => "win-cpu",
714            Backend::VulkanWindows => "win-vulkan",
715            Backend::CudaWindows12_4 => "win-cuda-12.4",
716            Backend::CudaWindows13_1 => "win-cuda-13.1",
717            Backend::HipWindows => "win-hip",
718            Backend::CpuMacosArm64 => "macos-arm64",
719            Backend::CpuMacosX64 => "macos-x64",
720        }
721    }
722
723    /// Returns true if this backend is for Linux.
724    pub fn is_linux(self) -> bool {
725        matches!(
726            self,
727            Backend::Cpu
728                | Backend::Vulkan
729                | Backend::Rocm
730                | Backend::RocmLemonade
731                | Backend::Cuda
732                | Backend::CpuArm64
733        )
734    }
735
736    /// Returns true if this backend is for Windows.
737    pub fn is_windows(self) -> bool {
738        matches!(
739            self,
740            Backend::CpuWindows
741                | Backend::VulkanWindows
742                | Backend::CudaWindows12_4
743                | Backend::CudaWindows13_1
744                | Backend::HipWindows
745        )
746    }
747
748    /// Returns true if this backend is for macOS.
749    pub fn is_macos(self) -> bool {
750        matches!(self, Backend::CpuMacosArm64 | Backend::CpuMacosX64)
751    }
752
753    /// Parse backend from string representation.
754    pub fn parse_backend(s: &str) -> Self {
755        let s = s.to_lowercase();
756        if s.starts_with("vulkan") || s.starts_with("vk") {
757            Backend::Vulkan
758        } else if s.starts_with("rocm") || s.starts_with("ro") {
759            if s.contains("lemonade") {
760                Backend::RocmLemonade
761            } else {
762                Backend::Rocm
763            }
764        } else if s.starts_with("cuda") || s.starts_with("cu") {
765            Backend::Cuda
766        } else {
767            Backend::Cpu // Default
768        }
769    }
770}
771
772impl std::fmt::Display for Backend {
773    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
774        write!(f, "{}", self.slug())
775    }
776}
777
778/// Server mode: normal (single model) or router (multiple models).
779#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
780pub enum ServerMode {
781    #[serde(rename = "normal")]
782    #[default]
783    Normal,
784    #[serde(rename = "router")]
785    Router,
786    #[serde(rename = "bench_gpu", alias = "bench")]
787    Bench,
788    #[serde(rename = "bench_tune")]
789    BenchTune,
790}
791
792impl std::fmt::Display for ServerMode {
793    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794        match self {
795            ServerMode::Normal => write!(f, "Normal"),
796            ServerMode::Router => write!(f, "Router (XP!)"),
797            ServerMode::Bench => write!(f, "Bench GPU"),
798            ServerMode::BenchTune => write!(f, "BenchTune"),
799        }
800    }
801}
802
803impl ServerMode {
804    pub fn all() -> &'static [Self] {
805        &[Self::Normal, Self::Router, Self::Bench, Self::BenchTune]
806    }
807}
808
809// ── ModelSettings ─────────────────────────────────────────────
810//
811// WHEN ADDING A NEW PARAMETER to ModelSettings, update ALL of these locations:
812//   1.  src/models.rs:668       — ModelSettings struct field + doc comment
813//   2.  src/models.rs:179       — From<DefaultParams> for ModelSettings (field mapping)
814//   3.  src/config.rs:564       — DefaultParams struct field + serde attribute
815//   4.  src/config.rs:785       — DefaultParams Default impl (default value)
816//   5.  src/config.rs:175       — ModelOverride struct field (Option<T>)
817//   6.  src/config.rs:299       — ModelOverride::from_settings() (Some(s.field))
818//   7.  src/config.rs:385       — ModelOverride::apply() (one of the 3 macro calls)
819//   8.  src/tui/settings.rs:312 — all_fields() SettingField entry (id, name, section, etc.)
820//   9.  src/tui/settings.rs:1149— profile_settings_parts() diff macro call
821//  10.  src/tui/app/profiles.rs:80 — settings_fingerprint() hash call
822//  11.  src/tui/event/helpers.rs:125 — sync_global_settings() (if global-scoped)
823//  12.  src/tui/event/panel/settings.rs — key handlers for numeric/toggle edit
824//
825// The derived PartialEq on ModelSettings and DefaultParams guarantees
826// is_dirty() compares ALL fields — no field can be missed.
827// The build.rs script checks field counts at compile time.
828
829/// Settings for loading a model via llama.cpp server.
830#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
831pub struct ModelSettings {
832    // ── Loading ──────────────────────────────────────────────
833    /// Size of the prompt context.
834    pub context_length: u32,
835    /// Number of CPU threads for generation.
836    pub threads: u32,
837    /// Number of CPU threads for batch processing.
838    pub threads_batch: u32,
839    /// Logical maximum batch size.
840    pub batch_size: u32,
841    /// Physical maximum batch size (micro-batch).
842    pub ubatch_size: u32,
843    /// Max concurrent predictions (sequences).
844    pub parallel: u32,
845    /// Max concurrent predictions (requests in flight). None means no --parallel argument.
846    pub max_concurrent_predictions: Option<u32>,
847    /// Use uniform (unified) KV cache across all sequences.
848    pub uniform_cache: bool,
849    /// Offload KV cache to system RAM.
850    pub kv_cache_offload: bool,
851    /// KV cache data type for K.
852    pub cache_type_k: Option<CacheTypeK>,
853    /// KV cache data type for V.
854    pub cache_type_v: Option<CacheTypeV>,
855    /// Keep N tokens from the initial prompt.
856    pub keep: i32,
857    /// Use full-size SWA cache.
858    pub swa_full: bool,
859    /// Force system to keep model in RAM.
860    pub mlock: bool,
861    /// Memory-map the model.
862    pub mmap: bool,
863    /// NUMA optimization.
864    pub numa: NumMode,
865    /// System prompt.
866    pub system_prompt: String,
867    /// Name of the system prompt preset currently selected.
868    pub system_prompt_preset_name: String,
869    /// Web search engine name (e.g. "searxng").
870    pub web_search_engine: String,
871    /// Web search engine URL.
872    pub web_search_engine_url: String,
873
874    // ── GPU ──────────────────────────────────────────────────
875    /// GPU layer offloading mode.
876    pub gpu_layers_mode: GpuLayersMode,
877    /// Split mode across multiple GPUs.
878    pub split_mode: SplitMode,
879    /// Fraction of model offloaded to each GPU (comma-separated).
880    pub tensor_split: String,
881    /// Main GPU index.
882    pub main_gpu: i32,
883    /// Whether to adjust arguments to fit device memory.
884    pub fit: bool,
885    /// Path to LoRA adapter.
886    pub lora: Option<PathBuf>,
887    /// Path to LoRA adapter with scale.
888    pub lora_scaled: Option<(PathBuf, f32)>,
889    /// RPC servers.
890    pub rpc: String,
891    /// Restrict to embedding use case.
892    pub embedding: bool,
893    /// Enable Flash Attention.
894    pub flash_attn: bool,
895    /// Active experts per token (MoE models, -1 = model default).
896    pub expert_count: i32,
897    /// Use Jinja template engine for chat.
898    pub jinja: bool,
899    /// Auto-select chat template from GGUF architecture.
900    /// See https://huggingface.co/spiritbuun/buun-Qwen3.6-chat_template for template examples.
901    pub auto_chat_template: bool,
902    /// Custom chat template string.
903    pub chat_template: Option<String>,
904    /// JSON string for --chat-template-kwargs (e.g. {"enable_thinking": false}).
905    pub chat_template_kwargs: Option<String>,
906
907    // ── Sampling ─────────────────────────────────────────────
908    /// RNG seed (-1 = random).
909    pub seed: i32,
910    /// Temperature.
911    pub temperature: f32,
912    /// Top-k sampling (0 = disabled).
913    pub top_k: i32,
914    /// Top-p sampling (1.0 = disabled).
915    pub top_p: f32,
916    /// Minimum probability for a token.
917    pub min_p: f32,
918    /// Locally typical sampling parameter p.
919    pub typical_p: f32,
920    /// Mirostat version (0=off, 1=Mirostat, 2=Mirostat2).
921    pub mirostat: Mirostat,
922    /// Mirostat learning rate (eta).
923    pub mirostat_lr: f32,
924    /// Mirostat target entropy (tau).
925    pub mirostat_ent: f32,
926    /// Ignore end-of-stream token.
927    pub ignore_eos: bool,
928    /// Sampler order string.
929    pub samplers: Samplers,
930
931    // ── Repetition Control ───────────────────────────────────
932    /// Penalize repeat sequence of tokens.
933    pub repeat_penalty: f32,
934    /// Last N tokens to consider for repeat penalty.
935    pub repeat_last_n: i32,
936    /// Repeat alpha presence penalty.
937    pub presence_penalty: Option<f32>,
938    /// Repeat alpha frequency penalty.
939    pub frequency_penalty: Option<f32>,
940    /// DRY sampling multiplier.
941    pub dry_multiplier: f32,
942    /// DRY sampling base value.
943    pub dry_base: f32,
944    /// DRY allowed length.
945    pub dry_allowed_length: i32,
946    /// DRY penalty last N.
947    pub dry_penalty_last_n: i32,
948
949    // ── RoPE ─────────────────────────────────────────────────
950    /// RoPE frequency scaling method.
951    pub rope_scaling: RopeScaling,
952    /// RoPE context scaling factor.
953    pub rope_scale: f32,
954    /// RoPE base frequency.
955    pub rope_freq_base: f32,
956    /// RoPE frequency scaling factor.
957    pub rope_freq_scale: f32,
958    /// Enable Yarn RoPE scaling mode.
959    pub rope_yarn_enabled: bool,
960
961    // ── Server ───────────────────────────────────────────────
962    /// Host address.
963    pub host: String,
964    /// Port.
965    pub port: u16,
966    /// Server timeout in seconds.
967    pub timeout: u32,
968    /// Whether to enable prompt caching.
969    pub cache_prompt: bool,
970    /// Min chunk size for cache reuse.
971    pub cache_reuse: u32,
972    /// Whether to enable WebUI.
973    pub webui: bool,
974
975    // ── Other ────────────────────────────────────────────────
976    /// Max tokens to predict.
977    pub max_tokens: Option<u32>,
978    /// Cache type (legacy, kept for compatibility).
979    pub cache_type: CacheType,
980    /// Backend (cpu/vulkan).
981    pub backend: Backend,
982    /// llama.cpp release tag for CPU backend (e.g. "b1234" or None for latest).
983    pub llama_cpp_version_cpu: Option<String>,
984    /// llama.cpp release tag for Vulkan backend (e.g. "b1234" or None for latest).
985    pub llama_cpp_version_vulkan: Option<String>,
986    /// llama.cpp release tag for ROCm backend (e.g. "b1234" or None for latest).
987    pub llama_cpp_version_rocm: Option<String>,
988    /// Lemonade llama.cpp release tag for ROCm backend.
989    pub llama_cpp_version_rocm_lemonade: Option<String>,
990    /// llama.cpp release tag for CUDA backend.
991    pub llama_cpp_version_cuda: Option<String>,
992    /// Whether to enable the API proxy server.
993    pub api_endpoint_enabled: bool,
994    /// Port for the API proxy server.
995    pub api_endpoint_port: u16,
996    /// API key for the proxy server (Bearer token).
997    pub api_endpoint_key: Option<String>,
998    /// Speculative decoding type (e.g., "draft-mtp", "ngram-simple", "" for off).
999    pub spec_type: String,
1000    /// Number of draft tokens for MTP.
1001    pub draft_tokens: u32,
1002    /// Tags for the model.
1003    pub tags: Vec<String>,
1004}
1005
1006impl Default for ModelSettings {
1007    fn default() -> Self {
1008        let mut s: Self = crate::config::DefaultParams::default().into();
1009        // Override fields that differ from DefaultParams defaults
1010        s.uniform_cache = false;
1011        s.cache_type_k = Some(CacheTypeK::F16);
1012        s.cache_type_v = Some(CacheTypeV::F16);
1013        s.cache_type = CacheType::default();
1014        s.backend = Backend::Cpu;
1015        s.presence_penalty = Some(0.0);
1016        s.frequency_penalty = Some(0.0);
1017        s
1018    }
1019}
1020
1021impl ModelSettings {
1022    /// Create ModelSettings from config defaults, applying model-specific overrides.
1023    pub fn from_config(config: &crate::config::Config) -> Self {
1024        config.default.clone().into()
1025    }
1026}
1027
1028/// Default benchmark prompt used when starting a tuning session.
1029pub const BENCHMARK_PROMPT: &str = "Create Mona Lisa image in ascii art using text, number, symbol, everything possible. this should be the perfect painting.";
1030
1031/// A discovered model file.
1032#[derive(Debug, Clone)]
1033pub struct DiscoveredModel {
1034    pub path: PathBuf,
1035    pub name: String,
1036    pub file_size: u64,
1037    pub display_name: String, // path relative to model_dir for display
1038    pub pipeline_tag: Option<String>,
1039    pub capabilities: Vec<String>,
1040}
1041
1042/// Parsed GGUF metadata for a model, cached to avoid re-parsing the file.
1043#[derive(Debug, Clone, Default)]
1044pub struct GgufMetadata {
1045    pub layers: u32,
1046    pub hidden_size: u32,
1047    pub n_ctx_train: u32,
1048    pub n_head: u32,
1049    pub n_kv_head: u32,
1050    pub arch: String,
1051    pub file_type: String,
1052    pub quantization: String,
1053    pub model_parameters: String,
1054    pub domain: String,
1055    pub capabilities: Vec<String>,
1056    pub tokenizer: String,
1057    pub draft_tokens: u32,
1058    pub quality_rank: u8,
1059}
1060
1061impl GgufMetadata {
1062    pub fn from_path(path: &std::path::Path) -> anyhow::Result<Self> {
1063        let path_str = path.to_string_lossy();
1064
1065        let model_data_res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1066            gguf_rs::get_gguf_container(&path_str).map(|mut container| container.decode())
1067        }));
1068
1069        let model_data = match model_data_res {
1070            Ok(Ok(Ok(data))) => data,
1071            Ok(Ok(Err(e))) => return Err(anyhow::anyhow!("Failed to decode GGUF: {}", e)),
1072            Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to get GGUF container: {}", e)),
1073            Err(_) => {
1074                return Err(anyhow::anyhow!(
1075                    "GGUF library panicked during parsing/decoding (likely due to unsupported tensor/GGML type)"
1076                ));
1077            }
1078        };
1079
1080        let mut meta = Self::default();
1081
1082        let extract_str = |key: &str| -> String {
1083            model_data
1084                .metadata()
1085                .get(key)
1086                .and_then(|v| v.as_str().map(|s| s.to_string()))
1087                .unwrap_or_default()
1088        };
1089
1090        let extract_num = |key: &str| -> Option<u64> {
1091            model_data.metadata().get(key).and_then(|v| {
1092                v.as_u64()
1093                    .or_else(|| v.as_i64().map(|x| x as u64))
1094                    .or_else(|| v.as_f64().map(|x| x as u64))
1095            })
1096        };
1097
1098        meta.arch = extract_str("general.architecture");
1099        let prefix = if meta.arch.is_empty() {
1100            "llama"
1101        } else {
1102            &meta.arch
1103        };
1104
1105        let get_num_with_fallback = |suffix: &str| -> u32 {
1106            extract_num(&format!("{}.{}", prefix, suffix))
1107                .or_else(|| {
1108                    if prefix != "llama" {
1109                        extract_num(&format!("llama.{}", suffix))
1110                    } else {
1111                        None
1112                    }
1113                })
1114                .unwrap_or(0) as u32
1115        };
1116
1117        meta.layers = get_num_with_fallback("block_count");
1118        meta.hidden_size = get_num_with_fallback("embedding_length");
1119        meta.n_ctx_train = get_num_with_fallback("context_length");
1120        meta.n_head = get_num_with_fallback("attention.head_count");
1121        meta.n_kv_head = get_num_with_fallback("attention.head_count_kv");
1122
1123        if meta.arch == "mtp" {
1124            meta.draft_tokens = extract_num("mtp.draft_tokens").unwrap_or(0) as u32;
1125        }
1126
1127        if let Some(v) = extract_num("general.file_type") {
1128            meta.file_type = match v {
1129                0 => "F32".to_string(),
1130                1 => "F16".to_string(),
1131                2 => "Q4_0".to_string(),
1132                3 => "Q4_1".to_string(),
1133                4 => "Q4_1 (F16)".to_string(),
1134                5 => "Q4_1 (F16)".to_string(),
1135                6 => "Q5_0".to_string(),
1136                7 => "Q8_0".to_string(),
1137                8 => "Q5_0".to_string(),
1138                9 => "Q8_1".to_string(),
1139                10 => "Q2_K".to_string(),
1140                11 => "Q3_K_S".to_string(),
1141                12 => "Q3_K_M".to_string(),
1142                13 => "Q3_K_L".to_string(),
1143                14 => "Q4_K_S".to_string(),
1144                15 => "Q4_K_M".to_string(),
1145                16 => "Q5_K_S".to_string(),
1146                17 => "Q5_K_M".to_string(),
1147                18 => "Q6_K".to_string(),
1148                19 => "IQ2_XXS".to_string(),
1149                20 => "IQ2_XS".to_string(),
1150                21 => "IQ3_XXS".to_string(),
1151                22 => "IQ1_S".to_string(),
1152                23 => "IQ4_NL".to_string(),
1153                24 => "IQ3_S".to_string(),
1154                25 => "IQ2_S".to_string(),
1155                26 => "IQ4_XS".to_string(),
1156                27 => "F64".to_string(),
1157                29 => "IQ1_M".to_string(),
1158                30 => "BF16".to_string(),
1159                31 => "IQ2_L".to_string(),
1160                32 => "IQ3_L".to_string(),
1161                33 => "IQ4_M".to_string(),
1162                34 => "TQ1_0".to_string(),
1163                35 => "TQ2_0".to_string(),
1164                39 => "MXFP4".to_string(),
1165                _ => format!("Unknown ({})", v),
1166            };
1167            meta.quality_rank = match v {
1168                0 | 1 | 7 | 9 | 30 | 27 => 4,
1169                6 | 8 | 16 | 17 | 18 => 3,
1170                2 | 3 | 4 | 5 | 14 | 15 | 23 | 26 | 33 | 32 | 24 | 31 => 2,
1171                11 | 12 | 13 | 21 | 10 | 19 | 20 | 22 | 25 | 29 => 1,
1172                _ => 0,
1173            };
1174        }
1175
1176        if let Some(value) = model_data.metadata().get("general.capabilities")
1177            && let Some(arr) = value.as_array()
1178        {
1179            for v in arr {
1180                if let Some(s) = v.as_str() {
1181                    meta.capabilities.push(s.to_string());
1182                }
1183            }
1184        }
1185
1186        if model_data
1187            .metadata()
1188            .contains_key("tokenizer.chat_template")
1189        {
1190            meta.capabilities.push("chat".to_string());
1191        }
1192
1193        meta.tokenizer = extract_str("tokenizer.ggml.model");
1194        meta.domain = extract_str("general.domain");
1195        meta.model_parameters = model_data.model_parameters();
1196
1197        Ok(meta)
1198    }
1199}
1200
1201/// Map a GGUF architecture string to a HuggingFace pipeline tag.
1202/// Uses exact GGUF arch names as reported in general.architecture metadata.
1203/// Returns None for architectures with no well-known mapping.
1204pub fn arch_to_pipeline_tag(arch: &str) -> Option<&'static str> {
1205    match arch {
1206        // Text generation
1207        "llama" | "llama-moe" | "qwen" | "qwen2" | "qwen2moe" | "qwen3" | "qwen3moe"
1208        | "qwen3next" | "qwen35" | "qwen35moe" | "qwen2vl" | "qwen3vl" | "qwen3vlmoe"
1209        | "mistral" | "mistral3" | "mistral4" | "gemma" | "gemma2" | "gemma3" | "gemma3n"
1210        | "gemma4" | "gemma4-assistant" | "gemma-embedding" | "cohere" | "cohere2" | "deepseek"
1211        | "deepseek2" | "deepseek2-ocr" | "deepseek32" | "phi2" | "phi3" | "phimoe" | "phi4"
1212        | "jais" | "jais2" | "stablelm" | "llama4" | "olmo" | "olmo2" | "olmoe" | "sonar"
1213        | "mamba" | "mamba2" | "minicpm" | "minicpm3" | "minicpmo" | "nemo" | "nemotron"
1214        | "nemotron_h" | "nemotron_h_moe" | "chameleon" | "internlm2" | "glm4" | "glm4moe"
1215        | "chatglm" | "exaone" | "exaone4" | "exaone-moe" | "dbrx" | "starcoder" | "starcoder2"
1216        | "baichuan" | "falcon" | "falcon-h1" | "falcon3" | "megrez" | "yandex" | "bailing"
1217        | "bailingmoe" | "bailingmoe2" | "bailing2" | "bailing-think" | "granite"
1218        | "granitehybrid" | "granitemoe" | "hunyuan-dense" | "hunyuan-moe" | "hunyuan_vl"
1219        | "kimi-k2" | "seed_oss" | "grok" | "solar-open" | "gpt-oss" | "smollm3"
1220        | "pangu-embedded" | "rwkv6" | "rwkv6qwen2" | "rwkv7" | "arwkv7" | "mpt" | "gpt-neox"
1221        | "gptj" | "plamo" | "plamo2" | "plamo3" | "orion" | "openchat" | "vicuna" | "zephyr"
1222        | "monarch" | "step35" | "ernie4_5" | "ernie4_5-moe" | "mini-max-m2" | "talkie"
1223        | "apertus" | "arcee" | "arctic" | "jamba" | "lfm2" | "lfm2moe" | "llada" | "llada-moe"
1224        | "maincoder" | "mellum" | "mimo2" | "refact" | "rnd1" | "smallthinker" | "xverse"
1225        | "gpt2" | "codeshell" | "cogvlm" | "deci" | "dots1" | "dream" | "app" | "mamba_ssm" => {
1226            Some("text-generation")
1227        }
1228        // Feature extraction
1229        "bert" | "nomic-bert" | "nomic-bert-moe" | "roformer" | "deberta-v2" | "deberta"
1230        | "modern-bert" | "neo-bert" | "jina-bert-v2" | "jina-bert-v3" | "eurobert" | "t5"
1231        | "t5encoder" => Some("feature-extraction"),
1232        // Image classification
1233        "resnet" | "vit" | "segformer" | "clip" => Some("image-classification"),
1234        // Audio
1235        "whisper" => Some("automatic-speech-recognition"),
1236        "bark" | "wavtokenizer-dec" => Some("text-to-audio"),
1237        // Text-to-speech
1238        "speech-to-text" => Some("speech-to-text"),
1239        "text-to-speech" => Some("text-to-speech"),
1240        // Text-to-image
1241        "stable-diffusion" => Some("text-to-image"),
1242        _ => None,
1243    }
1244}
1245
1246/// Map a GGUF architecture string to a llama.cpp built-in chat template name.
1247/// Uses exact GGUF arch names as reported in general.architecture metadata.
1248/// Returns None for architectures with no well-known mapping.
1249/// User-specified chat_template in settings always takes priority over this.
1250pub fn arch_to_chat_template(arch: &str) -> Option<&'static str> {
1251    match arch {
1252        // Llama family
1253        "llama" | "llama-moe" => Some("llama3"),
1254        "llama4" => Some("llama4"),
1255        // Mistral family
1256        "mistral" | "mistral3" | "mistral4" => Some("mistral-v1"),
1257        // Qwen family
1258        "qwen" | "qwen2" | "qwen2moe" | "qwen3" | "qwen3moe" | "qwen3next" | "qwen35"
1259        | "qwen35moe" | "qwen2vl" | "qwen3vl" | "qwen3vlmoe" => Some("chatml"),
1260        // Gemma family
1261        "gemma" | "gemma2" | "gemma3" | "gemma3n" | "gemma4" | "gemma4-assistant" => Some("gemma"),
1262        // Phi family
1263        "phi2" | "phi3" | "phimoe" => Some("phi3"),
1264        "phi4" => Some("phi4"),
1265        // Cohere
1266        "cohere" | "cohere2" => Some("command-r"),
1267        // DeepSeek
1268        "deepseek" | "deepseek2" | "deepseek2-ocr" => Some("deepseek"),
1269        "deepseek32" => Some("deepseek3"),
1270        // ChatGLM family
1271        "internlm2" | "glm4" | "glm4moe" | "chatglm" => Some("chatglm4"),
1272        // Exaone family
1273        "exaone" => Some("exaone3"),
1274        "exaone4" => Some("exaone4"),
1275        "exaone-moe" => Some("exaone-moe"),
1276        // MiniCPM family
1277        "minicpm" | "minicpm3" | "minicpmo" => Some("minicpm"),
1278        // Falcon family
1279        "falcon" | "falcon-h1" | "falcon3" => Some("chatml"),
1280        // RWKV family
1281        "rwkv6" | "rwkv6qwen2" | "rwkv7" | "arwkv7" => Some("rwkv-world"),
1282        // Granite family
1283        "granite" | "granitehybrid" | "granitemoe" => Some("granite"),
1284        // Hunyuan family
1285        "hunyuan-dense" | "hunyuan-moe" | "hunyuan_vl" => Some("hunyuan-dense"),
1286        // Other text generation models -> chatml
1287        "olmo" | "olmo2" | "olmoe" | "sonar" | "mamba" | "mamba2" | "mamba_ssm" | "dbrx"
1288        | "starcoder" | "starcoder2" | "baichuan" | "gpt-neox" | "gptj" | "mpt" | "jais"
1289        | "jais2" | "stablelm" | "chameleon" | "nemo" | "nemotron" | "nemotron_h"
1290        | "nemotron_h_moe" | "plamo" | "plamo2" | "plamo3" | "ernie4_5" | "ernie4_5-moe"
1291        | "mini-max-m2" | "talkie" | "apertus" | "arcee" | "arctic" | "jamba" | "lfm2"
1292        | "lfm2moe" | "llada" | "llada-moe" | "maincoder" | "mellum" | "mimo2" | "refact"
1293        | "rnd1" | "smallthinker" | "xverse" | "gpt2" | "codeshell" | "cogvlm" | "deci"
1294        | "dots1" | "dream" | "app" | "step35" | "smollm3" => Some("chatml"),
1295        // Specialized templates
1296        "megrez" => Some("megrez"),
1297        "yandex" => Some("yandex"),
1298        "bailing" | "bailingmoe" | "bailingmoe2" | "bailing2" | "bailing-think" => Some("bailing"),
1299        "kimi-k2" => Some("kimi-k2"),
1300        "seed_oss" => Some("seed_oss"),
1301        "grok" => Some("grok-2"),
1302        "solar-open" => Some("solar-open"),
1303        "gpt-oss" => Some("gpt-oss"),
1304        "pangu-embedded" => Some("pangu-embedded"),
1305        "gigachat" => Some("gigachat"),
1306        // These templates are available but not auto-mapped (need manual config)
1307        // "llama2", "llama2-sys", "llama2-sys-bos", "llama2-sys-strip"
1308        // "vicuna", "vicuna-orca", "zephyr", "monarch", "orion", "openchat"
1309        // "chatglm3", "glmedge"
1310        _ => None,
1311    }
1312}
1313
1314/// Return all available chat templates for the picker.
1315/// First entry is "Auto (detect)", last entry is "None", second-to-last is "Custom...".
1316/// Middle entries are the auto-mapped template names from `arch_to_chat_template`.
1317pub fn get_available_chat_templates() -> Vec<String> {
1318    let mut templates = vec!["Auto (detect)".to_string()];
1319    let mut seen = std::collections::HashSet::new();
1320    // Collect all unique template names from arch_to_chat_template
1321    let archs = vec![
1322        "llama",
1323        "llama-moe",
1324        "llama4",
1325        "mistral",
1326        "mistral3",
1327        "mistral4",
1328        "qwen",
1329        "qwen2",
1330        "qwen2moe",
1331        "qwen3",
1332        "qwen3moe",
1333        "qwen3next",
1334        "qwen35",
1335        "qwen35moe",
1336        "qwen2vl",
1337        "qwen3vl",
1338        "qwen3vlmoe",
1339        "gemma",
1340        "gemma2",
1341        "gemma3",
1342        "gemma3n",
1343        "gemma4",
1344        "gemma4-assistant",
1345        "phi2",
1346        "phi3",
1347        "phimoe",
1348        "phi4",
1349        "cohere",
1350        "cohere2",
1351        "deepseek",
1352        "deepseek2",
1353        "deepseek2-ocr",
1354        "deepseek32",
1355        "internlm2",
1356        "glm4",
1357        "glm4moe",
1358        "chatglm",
1359        "exaone",
1360        "exaone4",
1361        "exaone-moe",
1362        "minicpm",
1363        "minicpm3",
1364        "minicpmo",
1365        "falcon",
1366        "falcon-h1",
1367        "falcon3",
1368        "rwkv6",
1369        "rwkv6qwen2",
1370        "rwkv7",
1371        "arwkv7",
1372        "granite",
1373        "granitehybrid",
1374        "granitemoe",
1375        "hunyuan-dense",
1376        "hunyuan-moe",
1377        "hunyuan_vl",
1378        "olmo",
1379        "olmo2",
1380        "olmoe",
1381        "sonar",
1382        "mamba",
1383        "mamba2",
1384        "mamba_ssm",
1385        "dbrx",
1386        "starcoder",
1387        "starcoder2",
1388        "baichuan",
1389        "gpt-neox",
1390        "gptj",
1391        "mpt",
1392        "jais",
1393        "jais2",
1394        "stablelm",
1395        "chameleon",
1396        "nemo",
1397        "nemotron",
1398        "nemotron_h",
1399        "nemotron_h_moe",
1400        "plamo",
1401        "plamo2",
1402        "plamo3",
1403        "ernie4_5",
1404        "ernie4_5-moe",
1405        "mini-max-m2",
1406        "talkie",
1407        "apertus",
1408        "arcee",
1409        "arctic",
1410        "jamba",
1411        "lfm2",
1412        "lfm2moe",
1413        "llada",
1414        "llada-moe",
1415        "maincoder",
1416        "mellum",
1417        "mimo2",
1418        "refact",
1419        "rnd1",
1420        "smallthinker",
1421        "xverse",
1422        "gpt2",
1423        "codeshell",
1424        "cogvlm",
1425        "deci",
1426        "dots1",
1427        "dream",
1428        "app",
1429        "step35",
1430        "smollm3",
1431        "megrez",
1432        "yandex",
1433        "bailing",
1434        "bailingmoe",
1435        "bailingmoe2",
1436        "bailing2",
1437        "bailing-think",
1438        "kimi-k2",
1439        "seed_oss",
1440        "grok",
1441        "solar-open",
1442        "gpt-oss",
1443        "pangu-embedded",
1444        "gigachat",
1445        "stable-diffusion",
1446    ];
1447    for arch in &archs {
1448        if let Some(template) = arch_to_chat_template(arch)
1449            && seen.insert(template.to_string())
1450        {
1451            templates.push(template.to_string());
1452        }
1453    }
1454    templates.push("Select a Template file...".to_string());
1455    templates.push("None".to_string());
1456    templates
1457}
1458
1459/// Metrics reported by the llama.cpp server.
1460#[derive(Debug, Clone)]
1461pub struct ServerMetrics {
1462    pub loaded: bool,
1463    pub tps: f64,
1464    pub prompt_tps: f64,
1465    pub cpu_usage: f64,
1466    pub gpu_mem_used: u64,
1467    pub gpu_mem_total: u64,
1468    pub ram_used: u64,
1469    pub ctx_used: u32,
1470    pub ctx_max: u32,
1471    /// Sum of gpu_mem_used across all loaded models (for Total VRAM display).
1472    pub total_vram_used: u64,
1473    /// Number of decoded tokens from print_timing logs.
1474    pub decoded_tokens: u64,
1475    /// Generation tokens per second parsed from llama.cpp log output (e.g., "tg = 64.45 t/s").
1476    pub gen_tps: f64,
1477    /// Estimated latency per generated token in milliseconds.
1478    pub latency_per_token_ms: f64,
1479    /// Estimated prompt processing latency in milliseconds (1000 / prompt_tps).
1480    pub prompt_latency_ms: f64,
1481    /// Number of tokens in the prompt being evaluated.
1482    pub prompt_tokens: u64,
1483    /// Progress of prompt evaluation (0.0-1.0).
1484    pub prompt_progress: f64,
1485    /// Elapsed prompt evaluation time in milliseconds.
1486    pub prompt_elapsed_ms: f64,
1487    /// Prompt evaluation throughput in tokens per second.
1488    pub prompt_tps_eval: f64,
1489}
1490
1491/// GPU device buffer reported by llama-server during model loading.
1492#[derive(Debug, Clone)]
1493pub struct GPUBuffer {
1494    pub device: String,
1495    pub buffer_size_mib: f64,
1496}
1497
1498/// Progress information during model loading, parsed from llama-server log output.
1499#[derive(Debug, Clone, Default)]
1500pub struct LoadProgress {
1501    /// Total number of layers in the model.
1502    pub layers_total: Option<u32>,
1503    /// Number of layers already offloaded to GPU.
1504    pub layers_loaded: Option<u32>,
1505    /// Total number of tensors in the model (from "Loading tensor X of Y" log).
1506    pub tensors_total: Option<u32>,
1507    /// Number of tensors loaded (counted from dot-lines in log).
1508    pub tensors_loaded: u32,
1509    /// Number of models loaded (from "loading model: X of Y" log).
1510    pub model_loaded: u32,
1511    /// Total number of models to load (from "loading model: X of Y" log).
1512    pub model_total: Option<u32>,
1513    /// GPU device buffers with their sizes.
1514    pub buffers: Vec<GPUBuffer>,
1515}
1516
1517impl Default for ServerMetrics {
1518    fn default() -> Self {
1519        Self {
1520            loaded: false,
1521            tps: 0.0,
1522            prompt_tps: 0.0,
1523            cpu_usage: 0.0,
1524            gpu_mem_used: 0,
1525            gpu_mem_total: 0,
1526            ram_used: 0,
1527            ctx_used: 0,
1528            ctx_max: 0,
1529            total_vram_used: 0,
1530            decoded_tokens: 0,
1531            gen_tps: 0.0,
1532            latency_per_token_ms: 0.0,
1533            prompt_latency_ms: 0.0,
1534            prompt_tokens: 0,
1535            prompt_progress: 0.0,
1536            prompt_elapsed_ms: 0.0,
1537            prompt_tps_eval: 0.0,
1538        }
1539    }
1540}
1541
1542/// WebSocket-friendly metrics snapshot (serializable, no internal state).
1543#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1544pub struct WsMetrics {
1545    pub model_name: String,
1546    pub loaded: bool,
1547    pub state: String,
1548    pub tps: f64,
1549    pub prompt_tps: f64,
1550    pub ctx_used: u32,
1551    pub ctx_max: u32,
1552    pub cpu_usage: f64,
1553    pub gpu_mem_used: u64,
1554    pub gpu_mem_total: u64,
1555    pub ram_used: u64,
1556    pub latency_per_token_ms: f64,
1557    pub decoded_tokens: u64,
1558    pub gen_tps: f64,
1559    pub prompt_tokens: u64,
1560    pub prompt_progress: f64,
1561    pub prompt_elapsed_ms: f64,
1562    pub prompt_tps_eval: f64,
1563    pub timestamp: u64,
1564    // Server command
1565    pub cmd_display: Option<String>,
1566    // LLM settings
1567    pub threads: u32,
1568    pub threads_batch: u32,
1569    pub context_length: u32,
1570    pub ubatch_size: u32,
1571    pub batch_size: u32,
1572    pub temperature: f32,
1573    pub top_k: u32,
1574    pub top_p: f32,
1575    pub min_p: f32,
1576    pub typical_p: f32,
1577    pub seed: i32,
1578    pub repeat_penalty: f32,
1579    pub repeat_last_n: i32,
1580    pub presence_penalty: Option<f32>,
1581    pub frequency_penalty: Option<f32>,
1582    pub mirostat: Option<u32>,
1583    pub mirostat_lr: Option<f32>,
1584    pub mirostat_ent: Option<f32>,
1585    pub max_tokens: Option<u32>,
1586    pub flash_attn: bool,
1587    pub kv_cache_offload: bool,
1588    pub cache_type_k: Option<String>,
1589    pub cache_type_v: Option<String>,
1590    pub uniform_cache: bool,
1591    pub mlock: bool,
1592    pub mmap: bool,
1593    pub embedding: bool,
1594    pub jinja: bool,
1595    pub ignore_eos: bool,
1596    pub samplers: String,
1597    pub expert_count: i32,
1598    pub gpu_layers: String,
1599    pub backend: String,
1600    pub llama_cpp_version: String,
1601    pub spec_type: String,
1602    pub draft_tokens: u32,
1603}
1604
1605impl WsMetrics {
1606    pub fn from_metrics(
1607        metrics: &ServerMetrics,
1608        model_name: &str,
1609        state: &str,
1610        settings: &crate::models::ModelSettings,
1611        cmd_display: Option<&str>,
1612    ) -> Self {
1613        use std::time::{SystemTime, UNIX_EPOCH};
1614        let timestamp = SystemTime::now()
1615            .duration_since(UNIX_EPOCH)
1616            .map(|d| d.as_secs())
1617            .unwrap_or(0);
1618        let gpu_layers = match settings.gpu_layers_mode {
1619            crate::models::GpuLayersMode::Auto => "Auto".to_string(),
1620            crate::models::GpuLayersMode::Specific(n) => n.to_string(),
1621            crate::models::GpuLayersMode::All => "All".to_string(),
1622        };
1623        Self {
1624            model_name: model_name.to_string(),
1625            loaded: metrics.loaded,
1626            state: state.to_string(),
1627            tps: metrics.tps,
1628            prompt_tps: metrics.prompt_tps,
1629            ctx_used: metrics.ctx_used,
1630            ctx_max: metrics.ctx_max,
1631            cpu_usage: metrics.cpu_usage,
1632            gpu_mem_used: metrics.gpu_mem_used,
1633            gpu_mem_total: metrics.gpu_mem_total,
1634            ram_used: metrics.ram_used,
1635            latency_per_token_ms: metrics.latency_per_token_ms,
1636            decoded_tokens: metrics.decoded_tokens,
1637            gen_tps: metrics.gen_tps,
1638            prompt_tokens: metrics.prompt_tokens,
1639            prompt_progress: metrics.prompt_progress,
1640            prompt_elapsed_ms: metrics.prompt_elapsed_ms,
1641            prompt_tps_eval: metrics.prompt_tps_eval,
1642            timestamp,
1643            cmd_display: cmd_display.map(String::from),
1644            threads: settings.threads,
1645            threads_batch: settings.threads_batch,
1646            context_length: settings.context_length,
1647            ubatch_size: settings.ubatch_size,
1648            batch_size: settings.batch_size,
1649            temperature: settings.temperature,
1650            top_k: (settings.top_k.max(0)) as u32,
1651            top_p: settings.top_p,
1652            min_p: settings.min_p,
1653            typical_p: settings.typical_p,
1654            seed: settings.seed,
1655            repeat_penalty: settings.repeat_penalty,
1656            repeat_last_n: settings.repeat_last_n,
1657            presence_penalty: settings.presence_penalty,
1658            frequency_penalty: settings.frequency_penalty,
1659            mirostat: Some(match settings.mirostat {
1660                crate::models::Mirostat::Off => 0,
1661                crate::models::Mirostat::V1 => 1,
1662                crate::models::Mirostat::Mirostat2 => 2,
1663            }),
1664            mirostat_lr: Some(settings.mirostat_lr),
1665            mirostat_ent: Some(settings.mirostat_ent),
1666            max_tokens: settings.max_tokens,
1667            flash_attn: settings.flash_attn,
1668            kv_cache_offload: settings.kv_cache_offload,
1669            cache_type_k: settings.cache_type_k.map(|k| k.to_string()),
1670            cache_type_v: settings.cache_type_v.map(|k| k.to_string()),
1671            uniform_cache: settings.uniform_cache,
1672            mlock: settings.mlock,
1673            mmap: settings.mmap,
1674            embedding: settings.embedding,
1675            jinja: settings.jinja,
1676            ignore_eos: settings.ignore_eos,
1677            samplers: settings.samplers.to_string(),
1678            expert_count: settings.expert_count,
1679            gpu_layers,
1680            backend: settings.backend.to_string(),
1681            llama_cpp_version: settings.get_active_backend_version_display().to_string(),
1682            spec_type: settings.spec_type.clone(),
1683            draft_tokens: settings.draft_tokens,
1684        }
1685    }
1686}
1687
1688/// Estimate VRAM usage (in MiB) for a model with the given settings.
1689///
1690/// Model file size is the size of the GGUF file in MiB. The model itself
1691/// takes 1x its size in VRAM (loaded as-is). KV cache is the dominant
1692/// variable cost — it scales with context_length, batch_size, and layers.
1693///
1694/// The KV cache formula accounts for:
1695/// - Actual GQA ratio from model metadata (n_kv_head / n_head)
1696/// - FlashAttention: reduces KV cache storage by ~2x
1697/// - Unified KV cache: shares KV across sequences, dividing by parallel count
1698/// - KV cache quantization (q4_0, q5_0, q8_0, etc.)
1699pub fn estimate_vram_mib(
1700    model_mib: u64,
1701    settings: &ModelSettings,
1702    total_layers: u32,
1703    hidden_size_opt: Option<u32>,
1704    n_head_opt: Option<u32>,
1705    n_kv_head_opt: Option<u32>,
1706    gpu_mem_total_mib: u64,
1707) -> u64 {
1708    let model_mib_f = model_mib as f64;
1709
1710    // Compute how much of the model is loaded into VRAM based on GPU layers.
1711    let gpu_layers = match settings.gpu_layers_mode {
1712        GpuLayersMode::Auto => {
1713            // Heuristic: ~60% of layers when Auto (llama.cpp will decide at runtime)
1714            if total_layers > 0 {
1715                (total_layers as f64 * 0.6) as u32
1716            } else {
1717                20
1718            }
1719        }
1720        GpuLayersMode::Specific(n) => {
1721            if total_layers > 0 {
1722                n.min(total_layers)
1723            } else {
1724                n
1725            }
1726        }
1727        GpuLayersMode::All => {
1728            if total_layers > 0 {
1729                total_layers
1730            } else {
1731                32
1732            }
1733        }
1734    };
1735
1736    // Model weights loaded into VRAM: proportional to GPU layers.
1737    let model_vram = if total_layers > 0 && gpu_layers > 0 {
1738        model_mib_f * (gpu_layers as f64 / total_layers as f64).min(1.0)
1739    } else if gpu_layers > 0 {
1740        model_mib_f
1741    } else {
1742        0.0
1743    };
1744
1745    if matches!(settings.gpu_layers_mode, GpuLayersMode::Specific(0)) {
1746        return 0; // CPU only
1747    }
1748
1749    // Heuristic for hidden_size if not provided:
1750    // A 7B model (4-bit) is ~4000 MiB and has hidden=4096.
1751    let hidden_size = match hidden_size_opt {
1752        Some(h) => h as f64,
1753        None => {
1754            let params_est = model_mib_f / 550.0;
1755            (1024.0 * params_est.sqrt().max(1.0) * 1.5).max(512.0)
1756        }
1757    };
1758
1759    // ── KV cache estimation ─────────────────────────────────────
1760
1761    // GQA ratio: real KV heads vs query heads.
1762    // If n_kv_head == n_head, ratio = 1.0 (no reduction).
1763    // If n_kv_head < n_head, ratio < 1.0 (KV cache is smaller).
1764    let gqa_ratio = match (n_head_opt, n_kv_head_opt) {
1765        (Some(n_head), Some(n_kv_head)) if n_head > 0 => n_kv_head as f64 / n_head as f64,
1766        _ => 1.0, // fallback: assume no GQA
1767    };
1768
1769    // FlashAttention reduces KV cache storage by ~2x because it doesn't
1770    // need to keep the full attention matrix in memory.
1771    let flash_attn_factor = if settings.flash_attn { 0.5 } else { 1.0 };
1772
1773    // Unified KV cache shares a single KV buffer across all sequences.
1774    let uniform_cache_factor = if settings.uniform_cache {
1775        1.0 / settings.parallel as f64
1776    } else {
1777        1.0
1778    };
1779
1780    // Effective context length: YaRN RoPE scale extends the usable context.
1781    let effective_ctx = settings.context_length as f64 * settings.rope_scale as f64;
1782
1783    // KV cache in MiB:
1784    // Formula: 2 * n_layer * n_ctx * n_embd_kv * sizeof(type)
1785    // n_embd_kv = hidden_size * gqa_ratio
1786    // The KV cache is allocated for the total number of model layers,
1787    // not just the number layers loaded into the GPU (gpu_layers).
1788    // However only gpu_layers * sizeof(type) contributes to the VRAM cost.
1789    let kv_mib = (2.0
1790        * hidden_size
1791        * effective_ctx
1792        * total_layers as f64
1793        * gqa_ratio
1794        * gpu_layers as f64
1795        / total_layers as f64  // VRAM cost: only GPU-loaded portion of KV cache
1796        * flash_attn_factor
1797        * uniform_cache_factor
1798        * kv_quant_bytes(
1799            settings.cache_type_k.unwrap_or(CacheTypeK::F16),
1800            settings.cache_type_v.unwrap_or(CacheTypeV::F16)
1801        ))
1802        / (1024.0 * 1024.0);
1803
1804    // Activation overhead during inference (proportional to batch * hidden).
1805    // Increased multiplier to 8.0 (from 2.0) to be more pessimistic about scratch buffers.
1806    let activation_mib = (settings.batch_size as f64 * hidden_size * 8.0) / (1024.0 * 1024.0);
1807
1808    // Fixed overhead for driver, fragmentation, and small meta buffers.
1809    // Use 3.8% of max VRAM, falling back to 500MiB if unknown.
1810    let fixed_overhead = if gpu_mem_total_mib > 0 {
1811        gpu_mem_total_mib as f64 * 0.038
1812    } else {
1813        500.0
1814    };
1815
1816    let total_mib = model_vram + kv_mib + activation_mib + fixed_overhead + 550.0;
1817
1818    total_mib.ceil() as u64
1819}
1820
1821/// Return the average KV cache element size in bytes for the given K/V types.
1822///
1823/// KV cache stores K and V separately, potentially at different precisions.
1824/// We average the two to get a single per-element size.
1825fn kv_quant_bytes(k_type: CacheQuantType, v_type: CacheQuantType) -> f64 {
1826    let get_bytes = |t: CacheQuantType| match t {
1827        CacheQuantType::F32 => 4.0,
1828        CacheQuantType::F16 | CacheQuantType::BF16 => 2.0,
1829        CacheQuantType::Q8_0 => 1.0,
1830        CacheQuantType::Q5_0 | CacheQuantType::Q5_1 => 0.625, // 5 bits
1831        CacheQuantType::Q4_0 | CacheQuantType::Q4_1 | CacheQuantType::Iq4Nl => 0.5, // 4 bits
1832    };
1833    (get_bytes(k_type) + get_bytes(v_type)) / 2.0
1834}
1835
1836impl ModelSettings {
1837    /// Check if this settings differs from `other` in any field.
1838    /// Uses derived PartialEq which compares all fields — compiler-enforced.
1839    pub fn is_dirty(&self, other: &Self) -> bool {
1840        self != other
1841    }
1842}
1843
1844// Benchmark Tuning types
1845#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1846pub struct BenchTuneConfig {
1847    pub model_path: PathBuf,
1848    pub num_iterations: u32,
1849    pub prompt: String,
1850    pub params_to_test: Vec<BenchTuneParam>,
1851    pub test_duration: Duration,
1852    pub bench_mode: BenchTuneMode,
1853    pub n_predict: u32,
1854    pub chat_template_kwargs: Option<String>,
1855    pub test_timeout: Duration,
1856}
1857
1858#[derive(Debug, Clone, Serialize, Deserialize)]
1859pub struct BenchTuneParam {
1860    pub name: String,
1861    pub min: f64,
1862    pub max: f64,
1863    pub step: f64,
1864    pub enabled: bool,
1865    pub variants: Vec<String>,
1866}
1867
1868impl PartialEq for BenchTuneParam {
1869    fn eq(&self, other: &Self) -> bool {
1870        self.name == other.name
1871            && self.min.to_bits() == other.min.to_bits()
1872            && self.max.to_bits() == other.max.to_bits()
1873            && self.step.to_bits() == other.step.to_bits()
1874            && self.enabled == other.enabled
1875            && self.variants == other.variants
1876    }
1877}
1878impl Eq for BenchTuneParam {}
1879
1880#[derive(Debug, Clone, Serialize, Deserialize)]
1881pub struct BenchTuneParamValue {
1882    pub temperature: Option<f64>,
1883    pub top_p: Option<f64>,
1884    pub top_k: Option<i64>,
1885    pub repeat_penalty: Option<f64>,
1886    pub context_length: Option<u32>,
1887    pub batch_size: Option<u32>,
1888    pub flash_attn: Option<bool>,
1889    pub threads: Option<u32>,
1890    pub expert_count: Option<i32>,
1891    pub spec_type: Option<String>,
1892    pub draft_tokens: Option<u32>,
1893}
1894
1895impl PartialEq for BenchTuneParamValue {
1896    fn eq(&self, other: &Self) -> bool {
1897        self.temperature.map(|v| v.to_bits()) == other.temperature.map(|v| v.to_bits())
1898            && self.top_p.map(|v| v.to_bits()) == other.top_p.map(|v| v.to_bits())
1899            && self.top_k == other.top_k
1900            && self.repeat_penalty.map(|v| v.to_bits()) == other.repeat_penalty.map(|v| v.to_bits())
1901            && self.context_length == other.context_length
1902            && self.batch_size == other.batch_size
1903            && self.flash_attn == other.flash_attn
1904            && self.threads == other.threads
1905            && self.expert_count == other.expert_count
1906            && self.spec_type == other.spec_type
1907            && self.draft_tokens == other.draft_tokens
1908    }
1909}
1910impl Eq for BenchTuneParamValue {}
1911
1912#[derive(Debug, Clone, Serialize, Deserialize)]
1913pub struct BenchTuneResult {
1914    pub params: BenchTuneParamValue,
1915    pub metrics: BenchTuneMetrics,
1916    pub outputs: Vec<String>,
1917    pub per_iteration_metrics: Vec<BenchTuneMetrics>,
1918    pub base_settings: Option<ModelSettings>,
1919    pub server_command: Option<String>,
1920}
1921
1922#[derive(Debug, Clone, Serialize, Deserialize)]
1923pub struct BenchTuneMetrics {
1924    pub prompt_tps: f64,
1925    pub generation_tps: f64,
1926    pub combined_tps: f64,
1927    pub latency_per_token: f64,
1928    pub prompt_processing_time: f64,
1929}
1930
1931#[derive(Debug, Clone, Serialize, Deserialize)]
1932pub enum BenchTuneStatus {
1933    Running {
1934        current: usize,
1935        total: usize,
1936        progress: f32,
1937        current_params: BenchTuneParamValue,
1938    },
1939    Completed {
1940        total_tests: usize,
1941        successful_tests: usize,
1942        elapsed: Duration,
1943    },
1944    PartiallyCompleted {
1945        total_tests: usize,
1946        successful_tests: usize,
1947        failed_tests: usize,
1948        elapsed: Duration,
1949    },
1950    Cancelled {
1951        total_tests: usize,
1952        successful_tests: usize,
1953        failed_tests: usize,
1954        elapsed: Duration,
1955    },
1956    Error {
1957        error: String,
1958    },
1959}
1960
1961#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1962pub enum BenchTuneMode {
1963    /// Runtime-only mode: sends all params in /completion request body, no server restarts
1964    RuntimeOnly,
1965    /// Full mode: spawns a new server for each parameter combination (tests server-level params)
1966    #[default]
1967    Full,
1968}
1969
1970/// Progress status for benchmark tuning
1971#[derive(Debug, Clone)]
1972pub enum BenchTuneProgress {
1973    /// Tuning is running.
1974    Running {
1975        current: usize,
1976        total: usize,
1977        progress: f32,
1978        current_params: BenchTuneParamValue,
1979    },
1980    /// Tuning is complete.
1981    Completed {
1982        total_tests: usize,
1983        successful_tests: usize,
1984        elapsed: Duration,
1985    },
1986    /// Tuning completed with some failures.
1987    PartiallyCompleted {
1988        total_tests: usize,
1989        successful_tests: usize,
1990        failed_tests: usize,
1991        elapsed: Duration,
1992    },
1993    /// Tuning was cancelled by the user.
1994    Cancelled {
1995        total_tests: usize,
1996        successful_tests: usize,
1997        failed_tests: usize,
1998        elapsed: Duration,
1999    },
2000    /// Tuning failed.
2001    Error { error: String },
2002}
2003
2004impl BenchTuneProgress {
2005    pub fn from_status(status: &BenchTuneStatus) -> Option<Self> {
2006        match status {
2007            BenchTuneStatus::Running {
2008                current,
2009                total,
2010                progress,
2011                current_params,
2012            } => Some(BenchTuneProgress::Running {
2013                current: *current,
2014                total: *total,
2015                progress: *progress,
2016                current_params: current_params.clone(),
2017            }),
2018            BenchTuneStatus::Completed {
2019                total_tests,
2020                successful_tests,
2021                elapsed,
2022            } => Some(BenchTuneProgress::Completed {
2023                total_tests: *total_tests,
2024                successful_tests: *successful_tests,
2025                elapsed: *elapsed,
2026            }),
2027            BenchTuneStatus::PartiallyCompleted {
2028                total_tests,
2029                successful_tests,
2030                failed_tests,
2031                elapsed,
2032            } => Some(BenchTuneProgress::PartiallyCompleted {
2033                total_tests: *total_tests,
2034                successful_tests: *successful_tests,
2035                failed_tests: *failed_tests,
2036                elapsed: *elapsed,
2037            }),
2038            BenchTuneStatus::Cancelled {
2039                total_tests,
2040                successful_tests,
2041                failed_tests,
2042                elapsed,
2043            } => Some(BenchTuneProgress::Cancelled {
2044                total_tests: *total_tests,
2045                successful_tests: *successful_tests,
2046                failed_tests: *failed_tests,
2047                elapsed: *elapsed,
2048            }),
2049            BenchTuneStatus::Error { error } => Some(BenchTuneProgress::Error {
2050                error: error.clone(),
2051            }),
2052        }
2053    }
2054}
2055
2056impl BenchTuneConfig {
2057    pub fn new(model_path: PathBuf, num_iterations: u32, prompt: String) -> Self {
2058        Self {
2059            model_path,
2060            num_iterations,
2061            prompt,
2062            params_to_test: vec![
2063                BenchTuneParam {
2064                    name: "temperature".to_string(),
2065                    min: 0.4,
2066                    max: 1.0,
2067                    step: 0.1,
2068                    enabled: false,
2069                    variants: vec![],
2070                },
2071                BenchTuneParam {
2072                    name: "top_p".to_string(),
2073                    min: 0.8,
2074                    max: 1.0,
2075                    step: 0.1,
2076                    enabled: false,
2077                    variants: vec![],
2078                },
2079                BenchTuneParam {
2080                    name: "top_k".to_string(),
2081                    min: 10.0,
2082                    max: 40.0,
2083                    step: 5.0,
2084                    enabled: false,
2085                    variants: vec![],
2086                },
2087                BenchTuneParam {
2088                    name: "repeat_penalty".to_string(),
2089                    min: 1.0,
2090                    max: 1.5,
2091                    step: 0.1,
2092                    enabled: false,
2093                    variants: vec![],
2094                },
2095                BenchTuneParam {
2096                    name: "flash_attn".to_string(),
2097                    min: 0.0,
2098                    max: 1.0,
2099                    step: 1.0,
2100                    enabled: false,
2101                    variants: vec![],
2102                },
2103                BenchTuneParam {
2104                    name: "threads".to_string(),
2105                    min: 4.0,
2106                    max: 16.0,
2107                    step: 4.0,
2108                    enabled: false,
2109                    variants: vec![],
2110                },
2111                BenchTuneParam {
2112                    name: "batch_size".to_string(),
2113                    min: 512.0,
2114                    max: 2048.0,
2115                    step: 512.0,
2116                    enabled: false,
2117                    variants: vec![],
2118                },
2119                BenchTuneParam {
2120                    name: "expert_count".to_string(),
2121                    min: -1.0,
2122                    max: 4.0,
2123                    step: 1.0,
2124                    enabled: false,
2125                    variants: vec![],
2126                },
2127                BenchTuneParam {
2128                    name: "spec_type".to_string(),
2129                    min: 0.0,
2130                    max: 8.0,
2131                    step: 1.0,
2132                    enabled: false,
2133                    variants: vec![
2134                        "Off".to_string(),
2135                        "draft-mtp".to_string(),
2136                        "draft-simple".to_string(),
2137                        "draft-eagle3".to_string(),
2138                        "ngram-simple".to_string(),
2139                        "ngram-map-k".to_string(),
2140                        "ngram-map-k4v".to_string(),
2141                        "ngram-mod".to_string(),
2142                        "ngram-cache".to_string(),
2143                    ],
2144                },
2145                BenchTuneParam {
2146                    name: "draft_tokens".to_string(),
2147                    min: 0.0,
2148                    max: 8.0,
2149                    step: 1.0,
2150                    enabled: false,
2151                    variants: vec![],
2152                },
2153            ],
2154            test_duration: Duration::from_secs(30),
2155            bench_mode: BenchTuneMode::default(),
2156            n_predict: 512,
2157            chat_template_kwargs: Some(r#"{"enable_thinking": false}"#.to_string()),
2158            test_timeout: Duration::from_secs(60),
2159        }
2160    }
2161
2162    /// Generate all parameter combinations based on the config
2163    pub fn generate_combinations(&self) -> Vec<BenchTuneParamValue> {
2164        let mut temp_values = vec![None];
2165        let mut top_p_values = vec![None];
2166        let mut top_k_values = vec![None];
2167        let mut repeat_penalty_values = vec![None];
2168        let mut flash_attn_values = vec![None];
2169        let mut threads_values = vec![None];
2170        let mut batch_size_values = vec![None];
2171        let mut expert_count_values = vec![None];
2172        let mut selected_spec_type = None;
2173        if let Some(p) = self.params_to_test.iter().find(|p| p.name == "spec_type") {
2174            let base_idx = (p.min as usize).min(p.variants.len().saturating_sub(1));
2175            let val = &p.variants[base_idx];
2176            if val != "Off" {
2177                selected_spec_type = Some(val.clone());
2178            }
2179        }
2180        let mut spec_type_values = vec![selected_spec_type];
2181        let mut draft_tokens_values = vec![None];
2182
2183        let spec_type_options = vec![
2184            "Off".to_string(),
2185            "draft-mtp".to_string(),
2186            "draft-simple".to_string(),
2187            "draft-eagle3".to_string(),
2188            "ngram-simple".to_string(),
2189            "ngram-map-k".to_string(),
2190            "ngram-map-k4v".to_string(),
2191            "ngram-mod".to_string(),
2192            "ngram-cache".to_string(),
2193        ];
2194
2195        for p in &self.params_to_test {
2196            if !p.enabled {
2197                continue;
2198            }
2199
2200            let vals: Vec<f64> = {
2201                let step_count = ((p.max - p.min) / p.step).ceil() as usize;
2202                (0..=step_count)
2203                    .map(|i| (p.min + (i as f64 * p.step)).min(p.max))
2204                    .collect()
2205            };
2206
2207            match p.name.as_str() {
2208                "temperature" => temp_values = vals.into_iter().map(Some).collect(),
2209                "top_p" => top_p_values = vals.into_iter().map(Some).collect(),
2210                "top_k" => top_k_values = vals.into_iter().map(|v| Some(v as i64)).collect(),
2211                "repeat_penalty" => repeat_penalty_values = vals.into_iter().map(Some).collect(),
2212                "flash_attn" => {
2213                    flash_attn_values = vals.into_iter().map(|v| Some(v >= 0.5)).collect()
2214                }
2215                "threads" => threads_values = vals.into_iter().map(|v| Some(v as u32)).collect(),
2216                "batch_size" => {
2217                    batch_size_values = vals.into_iter().map(|v| Some(v as u32)).collect()
2218                }
2219                "expert_count" => {
2220                    expert_count_values = vals.into_iter().map(|v| Some(v as i32)).collect()
2221                }
2222                "spec_type" => {
2223                    if !p.variants.is_empty() {
2224                        spec_type_values = p.variants.clone().into_iter().map(Some).collect();
2225                    } else {
2226                        let step_count = ((p.max - p.min) / p.step).ceil() as usize;
2227                        spec_type_values = (0..=step_count)
2228                            .map(|i| {
2229                                let idx = i.min(spec_type_options.len() - 1);
2230                                Some(spec_type_options[idx].clone())
2231                            })
2232                            .collect()
2233                    }
2234                }
2235                "draft_tokens" => {
2236                    draft_tokens_values = vals.into_iter().map(|v| Some(v as u32)).collect()
2237                }
2238                _ => {}
2239            }
2240        }
2241
2242        let mut combinations = Vec::new();
2243        for &temp in &temp_values {
2244            for &top_p in &top_p_values {
2245                for &top_k in &top_k_values {
2246                    for &rp in &repeat_penalty_values {
2247                        for &fa in &flash_attn_values {
2248                            for &th in &threads_values {
2249                                for &bs in &batch_size_values {
2250                                    for &ec in &expert_count_values {
2251                                        for st in &spec_type_values {
2252                                            for &dt in &draft_tokens_values {
2253                                                combinations.push(BenchTuneParamValue {
2254                                                    temperature: temp,
2255                                                    top_p,
2256                                                    top_k,
2257                                                    repeat_penalty: rp,
2258                                                    context_length: None,
2259                                                    batch_size: bs,
2260                                                    flash_attn: fa,
2261                                                    threads: th,
2262                                                    expert_count: ec,
2263                                                    spec_type: st.clone(),
2264                                                    draft_tokens: dt,
2265                                                });
2266                                            }
2267                                        }
2268                                    }
2269                                }
2270                            }
2271                        }
2272                    }
2273                }
2274            }
2275        }
2276
2277        combinations
2278    }
2279
2280    /// Get total number of tests to run
2281    pub fn get_total_tests_count(&self) -> usize {
2282        self.generate_combinations().len()
2283    }
2284
2285    /// Get number of parameter combinations (fast, without generating them)
2286    pub fn get_num_combinations(&self) -> usize {
2287        let mut count: u64 = 1;
2288        for p in &self.params_to_test {
2289            if !p.enabled {
2290                continue;
2291            }
2292            let vals = if p.name == "flash_attn" {
2293                2 // On/Off
2294            } else if !p.variants.is_empty() {
2295                p.variants.len()
2296            } else if p.name == "spec_type" {
2297                let step_count = ((p.max - p.min) / p.step).ceil() as usize;
2298                (step_count + 1).min(9)
2299            } else {
2300                let step_count = ((p.max - p.min) / p.step).ceil() as usize;
2301                step_count + 1
2302            };
2303            count *= vals as u64;
2304        }
2305        count as usize
2306    }
2307}
2308
2309// ── Parameter struct field count tests ──────────────────────────
2310
2311#[cfg(test)]
2312mod field_count_tests {
2313    use super::*;
2314
2315    /// Verify ModelSettings has the expected number of fields.
2316    /// If this test fails, a field was added/removed — update the checklist
2317    /// in src/models.rs:665 and all locations listed there.
2318    #[test]
2319    fn test_model_settings_field_count() {
2320        // This test uses reflection-like field access to verify the count.
2321        // If a field is added/removed, the tuple size changes and the
2322        // expected count assertion fails.
2323        let s = ModelSettings::default();
2324        let field_count = count_model_settings_fields(&s);
2325        assert_eq!(
2326            field_count, 75,
2327            "ModelSettings has {} fields (expected 75). \
2328        Update the checklist at src/models.rs:665 and all locations listed there.",
2329            field_count
2330        );
2331    }
2332
2333    /// Count fields in ModelSettings by forcing reference to each one.
2334    /// This is a compile-time guarantee: if a field is removed, the
2335    /// function won't compile. If a field is added, the count changes.
2336    #[allow(clippy::too_many_lines)]
2337    fn count_model_settings_fields(s: &ModelSettings) -> usize {
2338        let _ = (
2339            &s.context_length,
2340            &s.threads,
2341            &s.threads_batch,
2342            &s.batch_size,
2343            &s.ubatch_size,
2344            &s.parallel,
2345            &s.max_concurrent_predictions,
2346            &s.uniform_cache,
2347            &s.kv_cache_offload,
2348            &s.cache_type_k,
2349            &s.cache_type_v,
2350            &s.keep,
2351            &s.swa_full,
2352            &s.mlock,
2353            &s.mmap,
2354            &s.numa,
2355            &s.system_prompt,
2356            &s.system_prompt_preset_name,
2357            &s.gpu_layers_mode,
2358            &s.split_mode,
2359            &s.tensor_split,
2360            &s.main_gpu,
2361            &s.fit,
2362            &s.lora,
2363            &s.lora_scaled,
2364            &s.rpc,
2365            &s.embedding,
2366            &s.flash_attn,
2367            &s.expert_count,
2368            &s.jinja,
2369            &s.chat_template,
2370            &s.chat_template_kwargs,
2371            &s.seed,
2372            &s.temperature,
2373            &s.top_k,
2374            &s.top_p,
2375            &s.min_p,
2376            &s.typical_p,
2377            &s.mirostat,
2378            &s.mirostat_lr,
2379            &s.mirostat_ent,
2380            &s.ignore_eos,
2381            &s.samplers,
2382            &s.repeat_penalty,
2383            &s.repeat_last_n,
2384            &s.presence_penalty,
2385            &s.frequency_penalty,
2386            &s.dry_multiplier,
2387            &s.dry_base,
2388            &s.dry_allowed_length,
2389            &s.dry_penalty_last_n,
2390            &s.rope_scaling,
2391            &s.rope_scale,
2392            &s.rope_freq_base,
2393            &s.rope_freq_scale,
2394            &s.rope_yarn_enabled,
2395            &s.host,
2396            &s.port,
2397            &s.timeout,
2398            &s.cache_prompt,
2399            &s.cache_reuse,
2400            &s.webui,
2401            &s.max_tokens,
2402            &s.cache_type,
2403            &s.backend,
2404            &s.llama_cpp_version_cpu,
2405            &s.llama_cpp_version_vulkan,
2406            &s.llama_cpp_version_rocm,
2407            &s.llama_cpp_version_rocm_lemonade,
2408            &s.llama_cpp_version_cuda,
2409            &s.api_endpoint_enabled,
2410            &s.api_endpoint_port,
2411            &s.spec_type,
2412            &s.draft_tokens,
2413            &s.tags,
2414        );
2415        75
2416    }
2417
2418    /// Verify that is_dirty() uses derived PartialEq (compiler-enforced).
2419    /// This test confirms that two identical settings are not dirty,
2420    /// and two different settings are dirty.
2421    #[test]
2422    fn test_is_dirty_uses_derived_eq() {
2423        let s1 = ModelSettings::default();
2424        let s2 = ModelSettings::default();
2425        let s3 = s1.clone();
2426
2427        // Identical settings should not be dirty
2428        assert!(!s1.is_dirty(&s2));
2429        assert!(!s1.is_dirty(&s3));
2430
2431        // Derived PartialEq must match is_dirty
2432        assert_eq!(s1 != s2, s1.is_dirty(&s2));
2433        assert_eq!(s1 != s3, s1.is_dirty(&s3));
2434    }
2435
2436    /// Verify DefaultParams and ModelSettings share the same field set
2437    /// via the From<DefaultParams> for ModelSettings implementation.
2438    #[test]
2439    fn test_from_default_params_completeness() {
2440        let dp = crate::config::DefaultParams::default();
2441        let ms: ModelSettings = dp.clone().into();
2442
2443        // Verify the From impl produces a valid ModelSettings
2444        // The derived PartialEq ensures all fields were mapped
2445        assert_eq!(ms.context_length, 131072);
2446        assert_eq!(ms.threads, dp.threads);
2447        assert_eq!(ms.temperature, 0.8);
2448        // backend is hardware-dependent in DefaultParams::default(),
2449        // so we just verify it was mapped correctly
2450        assert_eq!(ms.backend, dp.backend);
2451    }
2452}