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 from_str(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
803// ── ModelSettings ─────────────────────────────────────────────
804//
805// WHEN ADDING A NEW PARAMETER to ModelSettings, update ALL of these locations:
806//   1.  src/models.rs:668       — ModelSettings struct field + doc comment
807//   2.  src/models.rs:179       — From<DefaultParams> for ModelSettings (field mapping)
808//   3.  src/config.rs:564       — DefaultParams struct field + serde attribute
809//   4.  src/config.rs:785       — DefaultParams Default impl (default value)
810//   5.  src/config.rs:175       — ModelOverride struct field (Option<T>)
811//   6.  src/config.rs:299       — ModelOverride::from_settings() (Some(s.field))
812//   7.  src/config.rs:385       — ModelOverride::apply() (one of the 3 macro calls)
813//   8.  src/tui/settings.rs:312 — all_fields() SettingField entry (id, name, section, etc.)
814//   9.  src/tui/settings.rs:1149— profile_settings_parts() diff macro call
815//  10.  src/tui/app/profiles.rs:80 — settings_fingerprint() hash call
816//  11.  src/tui/event/helpers.rs:125 — sync_global_settings() (if global-scoped)
817//  12.  src/tui/event/panel/settings.rs — key handlers for numeric/toggle edit
818//
819// The derived PartialEq on ModelSettings and DefaultParams guarantees
820// is_dirty() compares ALL fields — no field can be missed.
821// The build.rs script checks field counts at compile time.
822
823/// Settings for loading a model via llama.cpp server.
824#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
825pub struct ModelSettings {
826    // ── Loading ──────────────────────────────────────────────
827    /// Size of the prompt context.
828    pub context_length: u32,
829    /// Number of CPU threads for generation.
830    pub threads: u32,
831    /// Number of CPU threads for batch processing.
832    pub threads_batch: u32,
833    /// Logical maximum batch size.
834    pub batch_size: u32,
835    /// Physical maximum batch size (micro-batch).
836    pub ubatch_size: u32,
837    /// Max concurrent predictions (sequences).
838    pub parallel: u32,
839    /// Max concurrent predictions (requests in flight). None means no --parallel argument.
840    pub max_concurrent_predictions: Option<u32>,
841    /// Use uniform (unified) KV cache across all sequences.
842    pub uniform_cache: bool,
843    /// Offload KV cache to system RAM.
844    pub kv_cache_offload: bool,
845    /// KV cache data type for K.
846    pub cache_type_k: Option<CacheTypeK>,
847    /// KV cache data type for V.
848    pub cache_type_v: Option<CacheTypeV>,
849    /// Keep N tokens from the initial prompt.
850    pub keep: i32,
851    /// Use full-size SWA cache.
852    pub swa_full: bool,
853    /// Force system to keep model in RAM.
854    pub mlock: bool,
855    /// Memory-map the model.
856    pub mmap: bool,
857    /// NUMA optimization.
858    pub numa: NumMode,
859    /// System prompt.
860    pub system_prompt: String,
861    /// Name of the system prompt preset currently selected.
862    pub system_prompt_preset_name: String,
863    /// Web search engine name (e.g. "searxng").
864    pub web_search_engine: String,
865    /// Web search engine URL.
866    pub web_search_engine_url: String,
867
868    // ── GPU ──────────────────────────────────────────────────
869    /// GPU layer offloading mode.
870    pub gpu_layers_mode: GpuLayersMode,
871    /// Split mode across multiple GPUs.
872    pub split_mode: SplitMode,
873    /// Fraction of model offloaded to each GPU (comma-separated).
874    pub tensor_split: String,
875    /// Main GPU index.
876    pub main_gpu: i32,
877    /// Whether to adjust arguments to fit device memory.
878    pub fit: bool,
879    /// Path to LoRA adapter.
880    pub lora: Option<PathBuf>,
881    /// Path to LoRA adapter with scale.
882    pub lora_scaled: Option<(PathBuf, f32)>,
883    /// RPC servers.
884    pub rpc: String,
885    /// Restrict to embedding use case.
886    pub embedding: bool,
887    /// Enable Flash Attention.
888    pub flash_attn: bool,
889    /// Active experts per token (MoE models, -1 = model default).
890    pub expert_count: i32,
891    /// Use Jinja template engine for chat.
892    pub jinja: bool,
893    /// Auto-select chat template from GGUF architecture.
894    pub auto_chat_template: bool,
895    /// Custom chat template string.
896    pub chat_template: Option<String>,
897    /// JSON string for --chat-template-kwargs (e.g. {"enable_thinking": false}).
898    pub chat_template_kwargs: Option<String>,
899
900    // ── Sampling ─────────────────────────────────────────────
901    /// RNG seed (-1 = random).
902    pub seed: i32,
903    /// Temperature.
904    pub temperature: f32,
905    /// Top-k sampling (0 = disabled).
906    pub top_k: i32,
907    /// Top-p sampling (1.0 = disabled).
908    pub top_p: f32,
909    /// Minimum probability for a token.
910    pub min_p: f32,
911    /// Locally typical sampling parameter p.
912    pub typical_p: f32,
913    /// Mirostat version (0=off, 1=Mirostat, 2=Mirostat2).
914    pub mirostat: Mirostat,
915    /// Mirostat learning rate (eta).
916    pub mirostat_lr: f32,
917    /// Mirostat target entropy (tau).
918    pub mirostat_ent: f32,
919    /// Ignore end-of-stream token.
920    pub ignore_eos: bool,
921    /// Sampler order string.
922    pub samplers: Samplers,
923
924    // ── Repetition Control ───────────────────────────────────
925    /// Penalize repeat sequence of tokens.
926    pub repeat_penalty: f32,
927    /// Last N tokens to consider for repeat penalty.
928    pub repeat_last_n: i32,
929    /// Repeat alpha presence penalty.
930    pub presence_penalty: Option<f32>,
931    /// Repeat alpha frequency penalty.
932    pub frequency_penalty: Option<f32>,
933    /// DRY sampling multiplier.
934    pub dry_multiplier: f32,
935    /// DRY sampling base value.
936    pub dry_base: f32,
937    /// DRY allowed length.
938    pub dry_allowed_length: i32,
939    /// DRY penalty last N.
940    pub dry_penalty_last_n: i32,
941
942    // ── RoPE ─────────────────────────────────────────────────
943    /// RoPE frequency scaling method.
944    pub rope_scaling: RopeScaling,
945    /// RoPE context scaling factor.
946    pub rope_scale: f32,
947    /// RoPE base frequency.
948    pub rope_freq_base: f32,
949    /// RoPE frequency scaling factor.
950    pub rope_freq_scale: f32,
951    /// Enable Yarn RoPE scaling mode.
952    pub rope_yarn_enabled: bool,
953
954    // ── Server ───────────────────────────────────────────────
955    /// Host address.
956    pub host: String,
957    /// Port.
958    pub port: u16,
959    /// Server timeout in seconds.
960    pub timeout: u32,
961    /// Whether to enable prompt caching.
962    pub cache_prompt: bool,
963    /// Min chunk size for cache reuse.
964    pub cache_reuse: u32,
965    /// Whether to enable WebUI.
966    pub webui: bool,
967
968    // ── Other ────────────────────────────────────────────────
969    /// Max tokens to predict.
970    pub max_tokens: Option<u32>,
971    /// Cache type (legacy, kept for compatibility).
972    pub cache_type: CacheType,
973    /// Backend (cpu/vulkan).
974    pub backend: Backend,
975    /// llama.cpp release tag for CPU backend (e.g. "b1234" or None for latest).
976    pub llama_cpp_version_cpu: Option<String>,
977    /// llama.cpp release tag for Vulkan backend (e.g. "b1234" or None for latest).
978    pub llama_cpp_version_vulkan: Option<String>,
979    /// llama.cpp release tag for ROCm backend (e.g. "b1234" or None for latest).
980    pub llama_cpp_version_rocm: Option<String>,
981    /// Lemonade llama.cpp release tag for ROCm backend.
982    pub llama_cpp_version_rocm_lemonade: Option<String>,
983    /// llama.cpp release tag for CUDA backend.
984    pub llama_cpp_version_cuda: Option<String>,
985    /// Whether to enable the API proxy server.
986    pub api_endpoint_enabled: bool,
987    /// Port for the API proxy server.
988    pub api_endpoint_port: u16,
989    /// API key for the proxy server (Bearer token).
990    pub api_endpoint_key: Option<String>,
991    /// Speculative decoding type (e.g., "draft-mtp", "ngram-simple", "" for off).
992    pub spec_type: String,
993    /// Number of draft tokens for MTP.
994    pub draft_tokens: u32,
995    /// Tags for the model.
996    pub tags: Vec<String>,
997}
998
999impl Default for ModelSettings {
1000    fn default() -> Self {
1001        let mut s: Self = crate::config::DefaultParams::default().into();
1002        // Override fields that differ from DefaultParams defaults
1003        s.uniform_cache = false;
1004        s.cache_type_k = Some(CacheTypeK::F16);
1005        s.cache_type_v = Some(CacheTypeV::F16);
1006        s.cache_type = CacheType::default();
1007        s.backend = Backend::Cpu;
1008        s.presence_penalty = Some(0.0);
1009        s.frequency_penalty = Some(0.0);
1010        s
1011    }
1012}
1013
1014impl ModelSettings {
1015    /// Create ModelSettings from config defaults, applying model-specific overrides.
1016    pub fn from_config(config: &crate::config::Config) -> Self {
1017        config.default.clone().into()
1018    }
1019}
1020
1021/// Default benchmark prompt used when starting a tuning session.
1022pub const BENCHMARK_PROMPT: &str = "Create Mona Lisa image in ascii art using text, number, symbol, everything possible. this should be the perfect painting.";
1023
1024/// A discovered model file.
1025#[derive(Debug, Clone)]
1026pub struct DiscoveredModel {
1027    pub path: PathBuf,
1028    pub name: String,
1029    pub file_size: u64,
1030    pub display_name: String, // path relative to model_dir for display
1031    pub pipeline_tag: Option<String>,
1032    pub capabilities: Vec<String>,
1033}
1034
1035/// Parsed GGUF metadata for a model, cached to avoid re-parsing the file.
1036#[derive(Debug, Clone, Default)]
1037pub struct GgufMetadata {
1038    pub layers: u32,
1039    pub hidden_size: u32,
1040    pub n_ctx_train: u32,
1041    pub n_head: u32,
1042    pub n_kv_head: u32,
1043    pub arch: String,
1044    pub file_type: String,
1045    pub quantization: String,
1046    pub model_parameters: String,
1047    pub domain: String,
1048    pub capabilities: Vec<String>,
1049    pub tokenizer: String,
1050    pub draft_tokens: u32,
1051    pub quality_rank: u8,
1052}
1053
1054impl GgufMetadata {
1055    pub fn from_path(path: &std::path::Path) -> anyhow::Result<Self> {
1056        let path_str = path.to_string_lossy();
1057        let mut container = gguf_rs::get_gguf_container(&path_str)
1058            .map_err(|e| anyhow::anyhow!("Failed to get GGUF container: {}", e))?;
1059        let model_data = container
1060            .decode()
1061            .map_err(|e| anyhow::anyhow!("Failed to decode GGUF: {}", e))?;
1062
1063        let mut meta = Self::default();
1064
1065        let extract_str = |key: &str| -> String {
1066            model_data
1067                .metadata()
1068                .get(key)
1069                .and_then(|v| v.as_str().map(|s| s.to_string()))
1070                .unwrap_or_default()
1071        };
1072
1073        let extract_num = |key: &str| -> Option<u64> {
1074            model_data.metadata().get(key).and_then(|v| {
1075                v.as_u64()
1076                    .or_else(|| v.as_i64().map(|x| x as u64))
1077                    .or_else(|| v.as_f64().map(|x| x as u64))
1078            })
1079        };
1080
1081        meta.arch = extract_str("general.architecture");
1082        let prefix = if meta.arch.is_empty() {
1083            "llama"
1084        } else {
1085            &meta.arch
1086        };
1087
1088        let get_num_with_fallback = |suffix: &str| -> u32 {
1089            extract_num(&format!("{}.{}", prefix, suffix))
1090                .or_else(|| {
1091                    if prefix != "llama" {
1092                        extract_num(&format!("llama.{}", suffix))
1093                    } else {
1094                        None
1095                    }
1096                })
1097                .unwrap_or(0) as u32
1098        };
1099
1100        meta.layers = get_num_with_fallback("block_count");
1101        meta.hidden_size = get_num_with_fallback("embedding_length");
1102        meta.n_ctx_train = get_num_with_fallback("context_length");
1103        meta.n_head = get_num_with_fallback("attention.head_count");
1104        meta.n_kv_head = get_num_with_fallback("attention.head_count_kv");
1105
1106        if meta.arch == "mtp" {
1107            meta.draft_tokens = extract_num("mtp.draft_tokens").unwrap_or(0) as u32;
1108        }
1109
1110        if let Some(v) = extract_num("general.file_type") {
1111            meta.file_type = match v {
1112                0 => "F32".to_string(),
1113                1 => "F16".to_string(),
1114                2 => "Q4_0".to_string(),
1115                3 => "Q4_1".to_string(),
1116                4 => "Q4_1 (F16)".to_string(),
1117                5 => "Q4_1 (F16)".to_string(),
1118                6 => "Q5_0".to_string(),
1119                7 => "Q8_0".to_string(),
1120                8 => "Q5_0".to_string(),
1121                9 => "Q8_1".to_string(),
1122                10 => "Q2_K".to_string(),
1123                11 => "Q3_K_S".to_string(),
1124                12 => "Q3_K_M".to_string(),
1125                13 => "Q3_K_L".to_string(),
1126                14 => "Q4_K_S".to_string(),
1127                15 => "Q4_K_M".to_string(),
1128                16 => "Q5_K_S".to_string(),
1129                17 => "Q5_K_M".to_string(),
1130                18 => "Q6_K".to_string(),
1131                19 => "IQ2_XXS".to_string(),
1132                20 => "IQ2_XS".to_string(),
1133                21 => "IQ3_XXS".to_string(),
1134                22 => "IQ1_S".to_string(),
1135                23 => "IQ4_NL".to_string(),
1136                24 => "IQ3_S".to_string(),
1137                25 => "IQ2_S".to_string(),
1138                26 => "IQ4_XS".to_string(),
1139                27 => "F64".to_string(),
1140                29 => "IQ1_M".to_string(),
1141                30 => "BF16".to_string(),
1142                31 => "IQ2_L".to_string(),
1143                32 => "IQ3_L".to_string(),
1144                33 => "IQ4_M".to_string(),
1145                34 => "TQ1_0".to_string(),
1146                35 => "TQ2_0".to_string(),
1147                39 => "MXFP4".to_string(),
1148                _ => format!("Unknown ({})", v),
1149            };
1150            meta.quality_rank = match v {
1151                0 | 1 | 7 | 9 | 30 | 27 => 4,
1152                6 | 8 | 16 | 17 | 18 => 3,
1153                2 | 3 | 4 | 5 | 14 | 15 | 23 | 26 | 33 | 32 | 24 | 31 => 2,
1154                11 | 12 | 13 | 21 | 10 | 19 | 20 | 22 | 25 | 29 => 1,
1155                _ => 0,
1156            };
1157        }
1158
1159        if let Some(value) = model_data.metadata().get("general.capabilities")
1160            && let Some(arr) = value.as_array()
1161        {
1162            for v in arr {
1163                if let Some(s) = v.as_str() {
1164                    meta.capabilities.push(s.to_string());
1165                }
1166            }
1167        }
1168
1169        if model_data
1170            .metadata()
1171            .contains_key("tokenizer.chat_template")
1172        {
1173            meta.capabilities.push("chat".to_string());
1174        }
1175
1176        meta.tokenizer = extract_str("tokenizer.ggml.model");
1177        meta.domain = extract_str("general.domain");
1178        meta.model_parameters = model_data.model_parameters();
1179
1180        Ok(meta)
1181    }
1182}
1183
1184/// Map a GGUF architecture string to a HuggingFace pipeline tag.
1185/// Uses exact GGUF arch names as reported in general.architecture metadata.
1186/// Returns None for architectures with no well-known mapping.
1187pub fn arch_to_pipeline_tag(arch: &str) -> Option<&'static str> {
1188    match arch {
1189        // Text generation
1190        "llama" | "llama-moe" | "qwen" | "qwen2" | "qwen2moe" | "qwen3" | "qwen3moe"
1191        | "qwen3next" | "qwen35" | "qwen35moe" | "qwen2vl" | "qwen3vl" | "qwen3vlmoe"
1192        | "mistral" | "mistral3" | "mistral4" | "gemma" | "gemma2" | "gemma3" | "gemma3n"
1193        | "gemma4" | "gemma4-assistant" | "gemma-embedding" | "cohere" | "cohere2" | "deepseek"
1194        | "deepseek2" | "deepseek2-ocr" | "deepseek32" | "phi2" | "phi3" | "phimoe" | "phi4"
1195        | "jais" | "jais2" | "stablelm" | "llama4" | "olmo" | "olmo2" | "olmoe" | "sonar"
1196        | "mamba" | "mamba2" | "minicpm" | "minicpm3" | "minicpmo" | "nemo" | "nemotron"
1197        | "nemotron_h" | "nemotron_h_moe" | "chameleon" | "internlm2" | "glm4" | "glm4moe"
1198        | "chatglm" | "exaone" | "exaone4" | "exaone-moe" | "dbrx" | "starcoder" | "starcoder2"
1199        | "baichuan" | "falcon" | "falcon-h1" | "falcon3" | "megrez" | "yandex" | "bailing"
1200        | "bailingmoe" | "bailingmoe2" | "bailing2" | "bailing-think" | "granite"
1201        | "granitehybrid" | "granitemoe" | "hunyuan-dense" | "hunyuan-moe" | "hunyuan_vl"
1202        | "kimi-k2" | "seed_oss" | "grok" | "solar-open" | "gpt-oss" | "smollm3"
1203        | "pangu-embedded" | "rwkv6" | "rwkv6qwen2" | "rwkv7" | "arwkv7" | "mpt" | "gpt-neox"
1204        | "gptj" | "plamo" | "plamo2" | "plamo3" | "orion" | "openchat" | "vicuna" | "zephyr"
1205        | "monarch" | "step35" | "ernie4_5" | "ernie4_5-moe" | "mini-max-m2" | "talkie"
1206        | "apertus" | "arcee" | "arctic" | "jamba" | "lfm2" | "lfm2moe" | "llada" | "llada-moe"
1207        | "maincoder" | "mellum" | "mimo2" | "refact" | "rnd1" | "smallthinker" | "xverse"
1208        | "gpt2" | "codeshell" | "cogvlm" | "deci" | "dots1" | "dream" | "app" | "mamba_ssm" => {
1209            Some("text-generation")
1210        }
1211        // Feature extraction
1212        "bert" | "nomic-bert" | "nomic-bert-moe" | "roformer" | "deberta-v2" | "deberta"
1213        | "modern-bert" | "neo-bert" | "jina-bert-v2" | "jina-bert-v3" | "eurobert" | "t5"
1214        | "t5encoder" => Some("feature-extraction"),
1215        // Image classification
1216        "resnet" | "vit" | "segformer" | "clip" => Some("image-classification"),
1217        // Audio
1218        "whisper" => Some("automatic-speech-recognition"),
1219        "bark" | "wavtokenizer-dec" => Some("text-to-audio"),
1220        // Text-to-speech
1221        "speech-to-text" => Some("speech-to-text"),
1222        "text-to-speech" => Some("text-to-speech"),
1223        // Text-to-image
1224        "stable-diffusion" => Some("text-to-image"),
1225        _ => None,
1226    }
1227}
1228
1229/// Map a GGUF architecture string to a llama.cpp built-in chat template name.
1230/// Uses exact GGUF arch names as reported in general.architecture metadata.
1231/// Returns None for architectures with no well-known mapping.
1232/// User-specified chat_template in settings always takes priority over this.
1233pub fn arch_to_chat_template(arch: &str) -> Option<&'static str> {
1234    match arch {
1235        // Llama family
1236        "llama" | "llama-moe" => Some("llama3"),
1237        "llama4" => Some("llama4"),
1238        // Mistral family
1239        "mistral" | "mistral3" | "mistral4" => Some("mistral-v1"),
1240        // Qwen family
1241        "qwen" | "qwen2" | "qwen2moe" | "qwen3" | "qwen3moe" | "qwen3next" | "qwen35"
1242        | "qwen35moe" | "qwen2vl" | "qwen3vl" | "qwen3vlmoe" => Some("chatml"),
1243        // Gemma family
1244        "gemma" | "gemma2" | "gemma3" | "gemma3n" | "gemma4" | "gemma4-assistant" => Some("gemma"),
1245        // Phi family
1246        "phi2" | "phi3" | "phimoe" => Some("phi3"),
1247        "phi4" => Some("phi4"),
1248        // Cohere
1249        "cohere" | "cohere2" => Some("command-r"),
1250        // DeepSeek
1251        "deepseek" | "deepseek2" | "deepseek2-ocr" => Some("deepseek"),
1252        "deepseek32" => Some("deepseek3"),
1253        // ChatGLM family
1254        "internlm2" | "glm4" | "glm4moe" | "chatglm" => Some("chatglm4"),
1255        // Exaone family
1256        "exaone" => Some("exaone3"),
1257        "exaone4" => Some("exaone4"),
1258        "exaone-moe" => Some("exaone-moe"),
1259        // MiniCPM family
1260        "minicpm" | "minicpm3" | "minicpmo" => Some("minicpm"),
1261        // Falcon family
1262        "falcon" | "falcon-h1" | "falcon3" => Some("chatml"),
1263        // RWKV family
1264        "rwkv6" | "rwkv6qwen2" | "rwkv7" | "arwkv7" => Some("rwkv-world"),
1265        // Granite family
1266        "granite" | "granitehybrid" | "granitemoe" => Some("granite"),
1267        // Hunyuan family
1268        "hunyuan-dense" | "hunyuan-moe" | "hunyuan_vl" => Some("hunyuan-dense"),
1269        // Other text generation models -> chatml
1270        "olmo" | "olmo2" | "olmoe" | "sonar" | "mamba" | "mamba2" | "mamba_ssm" | "dbrx"
1271        | "starcoder" | "starcoder2" | "baichuan" | "gpt-neox" | "gptj" | "mpt" | "jais"
1272        | "jais2" | "stablelm" | "chameleon" | "nemo" | "nemotron" | "nemotron_h"
1273        | "nemotron_h_moe" | "plamo" | "plamo2" | "plamo3" | "ernie4_5" | "ernie4_5-moe"
1274        | "mini-max-m2" | "talkie" | "apertus" | "arcee" | "arctic" | "jamba" | "lfm2"
1275        | "lfm2moe" | "llada" | "llada-moe" | "maincoder" | "mellum" | "mimo2" | "refact"
1276        | "rnd1" | "smallthinker" | "xverse" | "gpt2" | "codeshell" | "cogvlm" | "deci"
1277        | "dots1" | "dream" | "app" | "step35" | "smollm3" => Some("chatml"),
1278        // Specialized templates
1279        "megrez" => Some("megrez"),
1280        "yandex" => Some("yandex"),
1281        "bailing" | "bailingmoe" | "bailingmoe2" | "bailing2" | "bailing-think" => Some("bailing"),
1282        "kimi-k2" => Some("kimi-k2"),
1283        "seed_oss" => Some("seed_oss"),
1284        "grok" => Some("grok-2"),
1285        "solar-open" => Some("solar-open"),
1286        "gpt-oss" => Some("gpt-oss"),
1287        "pangu-embedded" => Some("pangu-embedded"),
1288        "gigachat" => Some("gigachat"),
1289        // These templates are available but not auto-mapped (need manual config)
1290        // "llama2", "llama2-sys", "llama2-sys-bos", "llama2-sys-strip"
1291        // "vicuna", "vicuna-orca", "zephyr", "monarch", "orion", "openchat"
1292        // "chatglm3", "glmedge"
1293        _ => None,
1294    }
1295}
1296
1297/// Return all available chat templates for the picker.
1298/// First entry is "Auto (detect)", last entry is "None", second-to-last is "Custom...".
1299/// Middle entries are the auto-mapped template names from `arch_to_chat_template`.
1300pub fn get_available_chat_templates() -> Vec<String> {
1301    let mut templates = vec!["Auto (detect)".to_string()];
1302    let mut seen = std::collections::HashSet::new();
1303    // Collect all unique template names from arch_to_chat_template
1304    let archs = vec![
1305        "llama",
1306        "llama-moe",
1307        "llama4",
1308        "mistral",
1309        "mistral3",
1310        "mistral4",
1311        "qwen",
1312        "qwen2",
1313        "qwen2moe",
1314        "qwen3",
1315        "qwen3moe",
1316        "qwen3next",
1317        "qwen35",
1318        "qwen35moe",
1319        "qwen2vl",
1320        "qwen3vl",
1321        "qwen3vlmoe",
1322        "gemma",
1323        "gemma2",
1324        "gemma3",
1325        "gemma3n",
1326        "gemma4",
1327        "gemma4-assistant",
1328        "phi2",
1329        "phi3",
1330        "phimoe",
1331        "phi4",
1332        "cohere",
1333        "cohere2",
1334        "deepseek",
1335        "deepseek2",
1336        "deepseek2-ocr",
1337        "deepseek32",
1338        "internlm2",
1339        "glm4",
1340        "glm4moe",
1341        "chatglm",
1342        "exaone",
1343        "exaone4",
1344        "exaone-moe",
1345        "minicpm",
1346        "minicpm3",
1347        "minicpmo",
1348        "falcon",
1349        "falcon-h1",
1350        "falcon3",
1351        "rwkv6",
1352        "rwkv6qwen2",
1353        "rwkv7",
1354        "arwkv7",
1355        "granite",
1356        "granitehybrid",
1357        "granitemoe",
1358        "hunyuan-dense",
1359        "hunyuan-moe",
1360        "hunyuan_vl",
1361        "olmo",
1362        "olmo2",
1363        "olmoe",
1364        "sonar",
1365        "mamba",
1366        "mamba2",
1367        "mamba_ssm",
1368        "dbrx",
1369        "starcoder",
1370        "starcoder2",
1371        "baichuan",
1372        "gpt-neox",
1373        "gptj",
1374        "mpt",
1375        "jais",
1376        "jais2",
1377        "stablelm",
1378        "chameleon",
1379        "nemo",
1380        "nemotron",
1381        "nemotron_h",
1382        "nemotron_h_moe",
1383        "plamo",
1384        "plamo2",
1385        "plamo3",
1386        "ernie4_5",
1387        "ernie4_5-moe",
1388        "mini-max-m2",
1389        "talkie",
1390        "apertus",
1391        "arcee",
1392        "arctic",
1393        "jamba",
1394        "lfm2",
1395        "lfm2moe",
1396        "llada",
1397        "llada-moe",
1398        "maincoder",
1399        "mellum",
1400        "mimo2",
1401        "refact",
1402        "rnd1",
1403        "smallthinker",
1404        "xverse",
1405        "gpt2",
1406        "codeshell",
1407        "cogvlm",
1408        "deci",
1409        "dots1",
1410        "dream",
1411        "app",
1412        "step35",
1413        "smollm3",
1414        "megrez",
1415        "yandex",
1416        "bailing",
1417        "bailingmoe",
1418        "bailingmoe2",
1419        "bailing2",
1420        "bailing-think",
1421        "kimi-k2",
1422        "seed_oss",
1423        "grok",
1424        "solar-open",
1425        "gpt-oss",
1426        "pangu-embedded",
1427        "gigachat",
1428        "stable-diffusion",
1429    ];
1430    for arch in &archs {
1431        if let Some(template) = arch_to_chat_template(arch) {
1432            if seen.insert(template.to_string()) {
1433                templates.push(template.to_string());
1434            }
1435        }
1436    }
1437    templates.push("Select a Template file...".to_string());
1438    templates.push("None".to_string());
1439    templates
1440}
1441
1442/// Metrics reported by the llama.cpp server.
1443#[derive(Debug, Clone)]
1444pub struct ServerMetrics {
1445    pub loaded: bool,
1446    pub tps: f64,
1447    pub prompt_tps: f64,
1448    pub cpu_usage: f64,
1449    pub gpu_mem_used: u64,
1450    pub gpu_mem_total: u64,
1451    pub ram_used: u64,
1452    pub ctx_used: u32,
1453    pub ctx_max: u32,
1454    /// Sum of gpu_mem_used across all loaded models (for Total VRAM display).
1455    pub total_vram_used: u64,
1456    /// Number of decoded tokens from print_timing logs.
1457    pub decoded_tokens: u64,
1458    /// Generation tokens per second parsed from llama.cpp log output (e.g., "tg = 64.45 t/s").
1459    pub gen_tps: f64,
1460    /// Estimated latency per generated token in milliseconds.
1461    pub latency_per_token_ms: f64,
1462    /// Estimated prompt processing latency in milliseconds (1000 / prompt_tps).
1463    pub prompt_latency_ms: f64,
1464    /// Number of tokens in the prompt being evaluated.
1465    pub prompt_tokens: u64,
1466    /// Progress of prompt evaluation (0.0-1.0).
1467    pub prompt_progress: f64,
1468    /// Elapsed prompt evaluation time in milliseconds.
1469    pub prompt_elapsed_ms: f64,
1470    /// Prompt evaluation throughput in tokens per second.
1471    pub prompt_tps_eval: f64,
1472}
1473
1474/// GPU device buffer reported by llama-server during model loading.
1475#[derive(Debug, Clone)]
1476pub struct GPUBuffer {
1477    pub device: String,
1478    pub buffer_size_mib: f64,
1479}
1480
1481/// Progress information during model loading, parsed from llama-server log output.
1482#[derive(Debug, Clone, Default)]
1483pub struct LoadProgress {
1484    /// Total number of layers in the model.
1485    pub layers_total: Option<u32>,
1486    /// Number of layers already offloaded to GPU.
1487    pub layers_loaded: Option<u32>,
1488    /// Total number of tensors in the model (from "Loading tensor X of Y" log).
1489    pub tensors_total: Option<u32>,
1490    /// Number of tensors loaded (counted from dot-lines in log).
1491    pub tensors_loaded: u32,
1492    /// GPU device buffers with their sizes.
1493    pub buffers: Vec<GPUBuffer>,
1494}
1495
1496impl Default for ServerMetrics {
1497    fn default() -> Self {
1498        Self {
1499            loaded: false,
1500            tps: 0.0,
1501            prompt_tps: 0.0,
1502            cpu_usage: 0.0,
1503            gpu_mem_used: 0,
1504            gpu_mem_total: 0,
1505            ram_used: 0,
1506            ctx_used: 0,
1507            ctx_max: 0,
1508            total_vram_used: 0,
1509            decoded_tokens: 0,
1510            gen_tps: 0.0,
1511            latency_per_token_ms: 0.0,
1512            prompt_latency_ms: 0.0,
1513            prompt_tokens: 0,
1514            prompt_progress: 0.0,
1515            prompt_elapsed_ms: 0.0,
1516            prompt_tps_eval: 0.0,
1517        }
1518    }
1519}
1520
1521/// WebSocket-friendly metrics snapshot (serializable, no internal state).
1522#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1523pub struct WsMetrics {
1524    pub model_name: String,
1525    pub loaded: bool,
1526    pub state: String,
1527    pub tps: f64,
1528    pub prompt_tps: f64,
1529    pub ctx_used: u32,
1530    pub ctx_max: u32,
1531    pub cpu_usage: f64,
1532    pub gpu_mem_used: u64,
1533    pub gpu_mem_total: u64,
1534    pub ram_used: u64,
1535    pub latency_per_token_ms: f64,
1536    pub decoded_tokens: u64,
1537    pub gen_tps: f64,
1538    pub prompt_tokens: u64,
1539    pub prompt_progress: f64,
1540    pub prompt_elapsed_ms: f64,
1541    pub prompt_tps_eval: f64,
1542    pub timestamp: u64,
1543    // Server command
1544    pub cmd_display: Option<String>,
1545    // LLM settings
1546    pub threads: u32,
1547    pub threads_batch: u32,
1548    pub context_length: u32,
1549    pub ubatch_size: u32,
1550    pub batch_size: u32,
1551    pub temperature: f32,
1552    pub top_k: u32,
1553    pub top_p: f32,
1554    pub min_p: f32,
1555    pub typical_p: f32,
1556    pub seed: i32,
1557    pub repeat_penalty: f32,
1558    pub repeat_last_n: i32,
1559    pub presence_penalty: Option<f32>,
1560    pub frequency_penalty: Option<f32>,
1561    pub mirostat: Option<u32>,
1562    pub mirostat_lr: Option<f32>,
1563    pub mirostat_ent: Option<f32>,
1564    pub max_tokens: Option<u32>,
1565    pub flash_attn: bool,
1566    pub kv_cache_offload: bool,
1567    pub cache_type_k: Option<String>,
1568    pub cache_type_v: Option<String>,
1569    pub uniform_cache: bool,
1570    pub mlock: bool,
1571    pub mmap: bool,
1572    pub embedding: bool,
1573    pub jinja: bool,
1574    pub ignore_eos: bool,
1575    pub samplers: String,
1576    pub expert_count: i32,
1577    pub gpu_layers: String,
1578    pub backend: String,
1579    pub llama_cpp_version: String,
1580    pub spec_type: String,
1581    pub draft_tokens: u32,
1582}
1583
1584impl WsMetrics {
1585    pub fn from_metrics(
1586        metrics: &ServerMetrics,
1587        model_name: &str,
1588        state: &str,
1589        settings: &crate::models::ModelSettings,
1590        cmd_display: Option<&str>,
1591    ) -> Self {
1592        use std::time::{SystemTime, UNIX_EPOCH};
1593        let timestamp = SystemTime::now()
1594            .duration_since(UNIX_EPOCH)
1595            .map(|d| d.as_secs())
1596            .unwrap_or(0);
1597        let gpu_layers = match settings.gpu_layers_mode {
1598            crate::models::GpuLayersMode::Auto => "Auto".to_string(),
1599            crate::models::GpuLayersMode::Specific(n) => n.to_string(),
1600            crate::models::GpuLayersMode::All => "All".to_string(),
1601        };
1602        Self {
1603            model_name: model_name.to_string(),
1604            loaded: metrics.loaded,
1605            state: state.to_string(),
1606            tps: metrics.tps,
1607            prompt_tps: metrics.prompt_tps,
1608            ctx_used: metrics.ctx_used,
1609            ctx_max: metrics.ctx_max,
1610            cpu_usage: metrics.cpu_usage,
1611            gpu_mem_used: metrics.gpu_mem_used,
1612            gpu_mem_total: metrics.gpu_mem_total,
1613            ram_used: metrics.ram_used,
1614            latency_per_token_ms: metrics.latency_per_token_ms,
1615           decoded_tokens: metrics.decoded_tokens,
1616            gen_tps: metrics.gen_tps,
1617            prompt_tokens: metrics.prompt_tokens,
1618            prompt_progress: metrics.prompt_progress,
1619            prompt_elapsed_ms: metrics.prompt_elapsed_ms,
1620            prompt_tps_eval: metrics.prompt_tps_eval,
1621            timestamp,
1622            cmd_display: cmd_display.map(String::from),
1623            threads: settings.threads,
1624            threads_batch: settings.threads_batch,
1625            context_length: settings.context_length,
1626            ubatch_size: settings.ubatch_size,
1627            batch_size: settings.batch_size,
1628            temperature: settings.temperature,
1629            top_k: settings.top_k as u32,
1630            top_p: settings.top_p,
1631            min_p: settings.min_p,
1632            typical_p: settings.typical_p,
1633            seed: settings.seed,
1634            repeat_penalty: settings.repeat_penalty,
1635            repeat_last_n: settings.repeat_last_n,
1636            presence_penalty: settings.presence_penalty,
1637            frequency_penalty: settings.frequency_penalty,
1638            mirostat: Some(match settings.mirostat {
1639                crate::models::Mirostat::Off => 0,
1640                crate::models::Mirostat::V1 => 1,
1641                crate::models::Mirostat::Mirostat2 => 2,
1642            }),
1643            mirostat_lr: Some(settings.mirostat_lr),
1644            mirostat_ent: Some(settings.mirostat_ent),
1645            max_tokens: settings.max_tokens,
1646            flash_attn: settings.flash_attn,
1647            kv_cache_offload: settings.kv_cache_offload,
1648            cache_type_k: settings.cache_type_k.map(|k| k.to_string()),
1649            cache_type_v: settings.cache_type_v.map(|k| k.to_string()),
1650            uniform_cache: settings.uniform_cache,
1651            mlock: settings.mlock,
1652            mmap: settings.mmap,
1653            embedding: settings.embedding,
1654            jinja: settings.jinja,
1655            ignore_eos: settings.ignore_eos,
1656            samplers: settings.samplers.to_string(),
1657            expert_count: settings.expert_count,
1658            gpu_layers,
1659            backend: settings.backend.to_string(),
1660            llama_cpp_version: settings.get_active_backend_version_display().to_string(),
1661            spec_type: settings.spec_type.clone(),
1662            draft_tokens: settings.draft_tokens,
1663        }
1664    }
1665}
1666
1667/// Estimate VRAM usage (in MiB) for a model with the given settings.
1668///
1669/// Model file size is the size of the GGUF file in MiB. The model itself
1670/// takes 1x its size in VRAM (loaded as-is). KV cache is the dominant
1671/// variable cost — it scales with context_length, batch_size, and layers.
1672///
1673/// The KV cache formula accounts for:
1674/// - Actual GQA ratio from model metadata (n_kv_head / n_head)
1675/// - FlashAttention: reduces KV cache storage by ~2x
1676/// - Unified KV cache: shares KV across sequences, dividing by parallel count
1677/// - KV cache quantization (q4_0, q5_0, q8_0, etc.)
1678pub fn estimate_vram_mib(
1679    model_mib: u64,
1680    settings: &ModelSettings,
1681    total_layers: u32,
1682    hidden_size_opt: Option<u32>,
1683    n_head_opt: Option<u32>,
1684    n_kv_head_opt: Option<u32>,
1685    gpu_mem_total_mib: u64,
1686) -> u64 {
1687    let model_mib_f = model_mib as f64;
1688
1689    // Compute how much of the model is loaded into VRAM based on GPU layers.
1690    let gpu_layers = match settings.gpu_layers_mode {
1691        GpuLayersMode::Auto => {
1692            // Heuristic: ~60% of layers when Auto (llama.cpp will decide at runtime)
1693            if total_layers > 0 {
1694                (total_layers as f64 * 0.6) as u32
1695            } else {
1696                20
1697            }
1698        }
1699        GpuLayersMode::Specific(n) => {
1700            if total_layers > 0 {
1701                n.min(total_layers)
1702            } else {
1703                n
1704            }
1705        }
1706        GpuLayersMode::All => {
1707            if total_layers > 0 {
1708                total_layers
1709            } else {
1710                32
1711            }
1712        }
1713    };
1714
1715    // Model weights loaded into VRAM: proportional to GPU layers.
1716    let model_vram = if total_layers > 0 && gpu_layers > 0 {
1717        model_mib_f * (gpu_layers as f64 / total_layers as f64).min(1.0)
1718    } else if gpu_layers > 0 {
1719        model_mib_f
1720    } else {
1721        0.0
1722    };
1723
1724    if matches!(settings.gpu_layers_mode, GpuLayersMode::Specific(0)) {
1725        return 0; // CPU only
1726    }
1727
1728    // Heuristic for hidden_size if not provided:
1729    // A 7B model (4-bit) is ~4000 MiB and has hidden=4096.
1730    let hidden_size = match hidden_size_opt {
1731        Some(h) => h as f64,
1732        None => {
1733            let params_est = model_mib_f / 550.0;
1734            (1024.0 * params_est.sqrt().max(1.0) * 1.5).max(512.0)
1735        }
1736    };
1737
1738    // ── KV cache estimation ─────────────────────────────────────
1739
1740    // GQA ratio: real KV heads vs query heads.
1741    // If n_kv_head == n_head, ratio = 1.0 (no reduction).
1742    // If n_kv_head < n_head, ratio < 1.0 (KV cache is smaller).
1743    let gqa_ratio = match (n_head_opt, n_kv_head_opt) {
1744        (Some(n_head), Some(n_kv_head)) if n_head > 0 => n_kv_head as f64 / n_head as f64,
1745        _ => 1.0, // fallback: assume no GQA
1746    };
1747
1748    // FlashAttention reduces KV cache storage by ~2x because it doesn't
1749    // need to keep the full attention matrix in memory.
1750    let flash_attn_factor = if settings.flash_attn { 0.5 } else { 1.0 };
1751
1752    // Unified KV cache shares a single KV buffer across all sequences.
1753    let uniform_cache_factor = if settings.uniform_cache {
1754        1.0 / settings.parallel as f64
1755    } else {
1756        1.0
1757    };
1758
1759    // Effective context length: YaRN RoPE scale extends the usable context.
1760    let effective_ctx = settings.context_length as f64 * settings.rope_scale as f64;
1761
1762    // KV cache in MiB:
1763    // Formula: 2 * n_layer * n_ctx * n_embd_kv * sizeof(type)
1764    // n_embd_kv = hidden_size * gqa_ratio
1765    // The KV cache is allocated for the total number of model layers,
1766    // not just the number layers loaded into the GPU (gpu_layers).
1767    // However only gpu_layers * sizeof(type) contributes to the VRAM cost.
1768    let kv_mib = (2.0
1769        * hidden_size
1770        * effective_ctx
1771        * total_layers as f64
1772        * gqa_ratio
1773        * gpu_layers as f64
1774        / total_layers as f64  // VRAM cost: only GPU-loaded portion of KV cache
1775        * flash_attn_factor
1776        * uniform_cache_factor
1777        * kv_quant_bytes(
1778            settings.cache_type_k.unwrap_or(CacheTypeK::F16),
1779            settings.cache_type_v.unwrap_or(CacheTypeV::F16)
1780        ))
1781        / (1024.0 * 1024.0);
1782
1783    // Activation overhead during inference (proportional to batch * hidden).
1784    // Increased multiplier to 8.0 (from 2.0) to be more pessimistic about scratch buffers.
1785    let activation_mib = (settings.batch_size as f64 * hidden_size * 8.0) / (1024.0 * 1024.0);
1786
1787    // Fixed overhead for driver, fragmentation, and small meta buffers.
1788    // Use 3.8% of max VRAM, falling back to 500MiB if unknown.
1789    let fixed_overhead = if gpu_mem_total_mib > 0 {
1790        gpu_mem_total_mib as f64 * 0.038
1791    } else {
1792        500.0
1793    };
1794
1795    let total_mib = model_vram + kv_mib + activation_mib + fixed_overhead + 550.0;
1796
1797    total_mib.ceil() as u64
1798}
1799
1800/// Return the average KV cache element size in bytes for the given K/V types.
1801///
1802/// KV cache stores K and V separately, potentially at different precisions.
1803/// We average the two to get a single per-element size.
1804fn kv_quant_bytes(k_type: CacheQuantType, v_type: CacheQuantType) -> f64 {
1805    let get_bytes = |t: CacheQuantType| match t {
1806        CacheQuantType::F32 => 4.0,
1807        CacheQuantType::F16 | CacheQuantType::BF16 => 2.0,
1808        CacheQuantType::Q8_0 => 1.0,
1809        CacheQuantType::Q5_0 | CacheQuantType::Q5_1 => 0.625, // 5 bits
1810        CacheQuantType::Q4_0 | CacheQuantType::Q4_1 | CacheQuantType::Iq4Nl => 0.5, // 4 bits
1811    };
1812    (get_bytes(k_type) + get_bytes(v_type)) / 2.0
1813}
1814
1815impl ModelSettings {
1816    /// Check if this settings differs from `other` in any field.
1817    /// Uses derived PartialEq which compares all fields — compiler-enforced.
1818    pub fn is_dirty(&self, other: &Self) -> bool {
1819        self != other
1820    }
1821}
1822
1823// Benchmark Tuning types
1824#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1825pub struct BenchTuneConfig {
1826    pub model_path: PathBuf,
1827    pub num_iterations: u32,
1828    pub prompt: String,
1829    pub params_to_test: Vec<BenchTuneParam>,
1830    pub test_duration: Duration,
1831    pub bench_mode: BenchTuneMode,
1832    pub n_predict: u32,
1833    pub chat_template_kwargs: Option<String>,
1834    pub test_timeout: Duration,
1835}
1836
1837#[derive(Debug, Clone, Serialize, Deserialize)]
1838pub struct BenchTuneParam {
1839    pub name: String,
1840    pub min: f64,
1841    pub max: f64,
1842    pub step: f64,
1843    pub enabled: bool,
1844    pub variants: Vec<String>,
1845}
1846
1847impl PartialEq for BenchTuneParam {
1848    fn eq(&self, other: &Self) -> bool {
1849        self.name == other.name
1850            && self.min.to_bits() == other.min.to_bits()
1851            && self.max.to_bits() == other.max.to_bits()
1852            && self.step.to_bits() == other.step.to_bits()
1853            && self.enabled == other.enabled
1854            && self.variants == other.variants
1855    }
1856}
1857impl Eq for BenchTuneParam {}
1858
1859#[derive(Debug, Clone, Serialize, Deserialize)]
1860pub struct BenchTuneParamValue {
1861    pub temperature: Option<f64>,
1862    pub top_p: Option<f64>,
1863    pub top_k: Option<i64>,
1864    pub repeat_penalty: Option<f64>,
1865    pub context_length: Option<u32>,
1866    pub batch_size: Option<u32>,
1867    pub flash_attn: Option<bool>,
1868    pub threads: Option<u32>,
1869    pub expert_count: Option<i32>,
1870    pub spec_type: Option<String>,
1871    pub draft_tokens: Option<u32>,
1872}
1873
1874impl PartialEq for BenchTuneParamValue {
1875    fn eq(&self, other: &Self) -> bool {
1876        self.temperature.map(|v| v.to_bits()) == other.temperature.map(|v| v.to_bits())
1877            && self.top_p.map(|v| v.to_bits()) == other.top_p.map(|v| v.to_bits())
1878            && self.top_k == other.top_k
1879            && self.repeat_penalty.map(|v| v.to_bits()) == other.repeat_penalty.map(|v| v.to_bits())
1880            && self.context_length == other.context_length
1881            && self.batch_size == other.batch_size
1882            && self.flash_attn == other.flash_attn
1883            && self.threads == other.threads
1884            && self.expert_count == other.expert_count
1885            && self.spec_type == other.spec_type
1886            && self.draft_tokens == other.draft_tokens
1887    }
1888}
1889impl Eq for BenchTuneParamValue {}
1890
1891#[derive(Debug, Clone, Serialize, Deserialize)]
1892pub struct BenchTuneResult {
1893    pub params: BenchTuneParamValue,
1894    pub metrics: BenchTuneMetrics,
1895    pub outputs: Vec<String>,
1896    pub per_iteration_metrics: Vec<BenchTuneMetrics>,
1897    pub base_settings: Option<ModelSettings>,
1898    pub server_command: Option<String>,
1899}
1900
1901#[derive(Debug, Clone, Serialize, Deserialize)]
1902pub struct BenchTuneMetrics {
1903    pub prompt_tps: f64,
1904    pub generation_tps: f64,
1905    pub combined_tps: f64,
1906    pub latency_per_token: f64,
1907    pub prompt_processing_time: f64,
1908}
1909
1910#[derive(Debug, Clone, Serialize, Deserialize)]
1911pub enum BenchTuneStatus {
1912    Running {
1913        current: usize,
1914        total: usize,
1915        progress: f32,
1916        current_params: BenchTuneParamValue,
1917    },
1918    Completed {
1919        total_tests: usize,
1920        successful_tests: usize,
1921        elapsed: Duration,
1922    },
1923    PartiallyCompleted {
1924        total_tests: usize,
1925        successful_tests: usize,
1926        failed_tests: usize,
1927        elapsed: Duration,
1928    },
1929    Cancelled {
1930        total_tests: usize,
1931        successful_tests: usize,
1932        failed_tests: usize,
1933        elapsed: Duration,
1934    },
1935    Error {
1936        error: String,
1937    },
1938}
1939
1940#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1941pub enum BenchTuneMode {
1942    /// Runtime-only mode: sends all params in /completion request body, no server restarts
1943    RuntimeOnly,
1944    /// Full mode: spawns a new server for each parameter combination (tests server-level params)
1945    #[default]
1946    Full,
1947}
1948
1949/// Progress status for benchmark tuning
1950#[derive(Debug, Clone)]
1951pub enum BenchTuneProgress {
1952    /// Tuning is running.
1953    Running {
1954        current: usize,
1955        total: usize,
1956        progress: f32,
1957        current_params: BenchTuneParamValue,
1958    },
1959    /// Tuning is complete.
1960    Completed {
1961        total_tests: usize,
1962        successful_tests: usize,
1963        elapsed: Duration,
1964    },
1965    /// Tuning completed with some failures.
1966    PartiallyCompleted {
1967        total_tests: usize,
1968        successful_tests: usize,
1969        failed_tests: usize,
1970        elapsed: Duration,
1971    },
1972    /// Tuning was cancelled by the user.
1973    Cancelled {
1974        total_tests: usize,
1975        successful_tests: usize,
1976        failed_tests: usize,
1977        elapsed: Duration,
1978    },
1979    /// Tuning failed.
1980    Error { error: String },
1981}
1982
1983impl BenchTuneProgress {
1984    pub fn from_status(status: &BenchTuneStatus) -> Option<Self> {
1985        match status {
1986            BenchTuneStatus::Running {
1987                current,
1988                total,
1989                progress,
1990                current_params,
1991            } => Some(BenchTuneProgress::Running {
1992                current: *current,
1993                total: *total,
1994                progress: *progress,
1995                current_params: current_params.clone(),
1996            }),
1997            BenchTuneStatus::Completed {
1998                total_tests,
1999                successful_tests,
2000                elapsed,
2001            } => Some(BenchTuneProgress::Completed {
2002                total_tests: *total_tests,
2003                successful_tests: *successful_tests,
2004                elapsed: *elapsed,
2005            }),
2006            BenchTuneStatus::PartiallyCompleted {
2007                total_tests,
2008                successful_tests,
2009                failed_tests,
2010                elapsed,
2011            } => Some(BenchTuneProgress::PartiallyCompleted {
2012                total_tests: *total_tests,
2013                successful_tests: *successful_tests,
2014                failed_tests: *failed_tests,
2015                elapsed: *elapsed,
2016            }),
2017            BenchTuneStatus::Cancelled {
2018                total_tests,
2019                successful_tests,
2020                failed_tests,
2021                elapsed,
2022            } => Some(BenchTuneProgress::Cancelled {
2023                total_tests: *total_tests,
2024                successful_tests: *successful_tests,
2025                failed_tests: *failed_tests,
2026                elapsed: *elapsed,
2027            }),
2028            BenchTuneStatus::Error { error } => Some(BenchTuneProgress::Error {
2029                error: error.clone(),
2030            }),
2031        }
2032    }
2033}
2034
2035impl BenchTuneConfig {
2036    pub fn new(model_path: PathBuf, num_iterations: u32, prompt: String) -> Self {
2037        Self {
2038            model_path,
2039            num_iterations,
2040            prompt,
2041            params_to_test: vec![
2042                BenchTuneParam {
2043                    name: "temperature".to_string(),
2044                    min: 0.4,
2045                    max: 1.0,
2046                    step: 0.1,
2047                    enabled: false,
2048                    variants: vec![],
2049                },
2050                BenchTuneParam {
2051                    name: "top_p".to_string(),
2052                    min: 0.8,
2053                    max: 1.0,
2054                    step: 0.1,
2055                    enabled: false,
2056                    variants: vec![],
2057                },
2058                BenchTuneParam {
2059                    name: "top_k".to_string(),
2060                    min: 10.0,
2061                    max: 40.0,
2062                    step: 5.0,
2063                    enabled: false,
2064                    variants: vec![],
2065                },
2066                BenchTuneParam {
2067                    name: "repeat_penalty".to_string(),
2068                    min: 1.0,
2069                    max: 1.5,
2070                    step: 0.1,
2071                    enabled: false,
2072                    variants: vec![],
2073                },
2074                BenchTuneParam {
2075                    name: "flash_attn".to_string(),
2076                    min: 0.0,
2077                    max: 1.0,
2078                    step: 1.0,
2079                    enabled: false,
2080                    variants: vec![],
2081                },
2082                BenchTuneParam {
2083                    name: "threads".to_string(),
2084                    min: 4.0,
2085                    max: 16.0,
2086                    step: 4.0,
2087                    enabled: false,
2088                    variants: vec![],
2089                },
2090                BenchTuneParam {
2091                    name: "batch_size".to_string(),
2092                    min: 512.0,
2093                    max: 2048.0,
2094                    step: 512.0,
2095                    enabled: false,
2096                    variants: vec![],
2097                },
2098                BenchTuneParam {
2099                    name: "expert_count".to_string(),
2100                    min: -1.0,
2101                    max: 4.0,
2102                    step: 1.0,
2103                    enabled: false,
2104                    variants: vec![],
2105                },
2106                BenchTuneParam {
2107                    name: "spec_type".to_string(),
2108                    min: 0.0,
2109                    max: 8.0,
2110                    step: 1.0,
2111                    enabled: false,
2112                    variants: vec![
2113                        "Off".to_string(),
2114                        "draft-mtp".to_string(),
2115                        "draft-simple".to_string(),
2116                        "draft-eagle3".to_string(),
2117                        "ngram-simple".to_string(),
2118                        "ngram-map-k".to_string(),
2119                        "ngram-map-k4v".to_string(),
2120                        "ngram-mod".to_string(),
2121                        "ngram-cache".to_string(),
2122                    ],
2123                },
2124                BenchTuneParam {
2125                    name: "draft_tokens".to_string(),
2126                    min: 0.0,
2127                    max: 8.0,
2128                    step: 1.0,
2129                    enabled: false,
2130                    variants: vec![],
2131                },
2132            ],
2133            test_duration: Duration::from_secs(30),
2134            bench_mode: BenchTuneMode::default(),
2135            n_predict: 512,
2136            chat_template_kwargs: Some(r#"{"enable_thinking": false}"#.to_string()),
2137            test_timeout: Duration::from_secs(60),
2138        }
2139    }
2140
2141    /// Generate all parameter combinations based on the config
2142    pub fn generate_combinations(&self) -> Vec<BenchTuneParamValue> {
2143        let mut temp_values = vec![None];
2144        let mut top_p_values = vec![None];
2145        let mut top_k_values = vec![None];
2146        let mut repeat_penalty_values = vec![None];
2147        let mut flash_attn_values = vec![None];
2148        let mut threads_values = vec![None];
2149        let mut batch_size_values = vec![None];
2150        let mut expert_count_values = vec![None];
2151        let mut selected_spec_type = None;
2152        if let Some(p) = self.params_to_test.iter().find(|p| p.name == "spec_type") {
2153            let base_idx = (p.min as usize).min(p.variants.len().saturating_sub(1));
2154            let val = &p.variants[base_idx];
2155            if val != "Off" {
2156                selected_spec_type = Some(val.clone());
2157            }
2158        }
2159        let mut spec_type_values = vec![selected_spec_type];
2160        let mut draft_tokens_values = vec![None];
2161
2162        let spec_type_options = vec![
2163            "Off".to_string(),
2164            "draft-mtp".to_string(),
2165            "draft-simple".to_string(),
2166            "draft-eagle3".to_string(),
2167            "ngram-simple".to_string(),
2168            "ngram-map-k".to_string(),
2169            "ngram-map-k4v".to_string(),
2170            "ngram-mod".to_string(),
2171            "ngram-cache".to_string(),
2172        ];
2173
2174        for p in &self.params_to_test {
2175            if !p.enabled {
2176                continue;
2177            }
2178
2179            let vals: Vec<f64> = {
2180                let step_count = ((p.max - p.min) / p.step).ceil() as usize;
2181                (0..=step_count)
2182                    .map(|i| (p.min + (i as f64 * p.step)).min(p.max))
2183                    .collect()
2184            };
2185
2186            match p.name.as_str() {
2187                "temperature" => temp_values = vals.into_iter().map(Some).collect(),
2188                "top_p" => top_p_values = vals.into_iter().map(Some).collect(),
2189                "top_k" => top_k_values = vals.into_iter().map(|v| Some(v as i64)).collect(),
2190                "repeat_penalty" => repeat_penalty_values = vals.into_iter().map(Some).collect(),
2191                "flash_attn" => {
2192                    flash_attn_values = vals.into_iter().map(|v| Some(v >= 0.5)).collect()
2193                }
2194                "threads" => threads_values = vals.into_iter().map(|v| Some(v as u32)).collect(),
2195                "batch_size" => {
2196                    batch_size_values = vals.into_iter().map(|v| Some(v as u32)).collect()
2197                }
2198                "expert_count" => {
2199                    expert_count_values = vals.into_iter().map(|v| Some(v as i32)).collect()
2200                }
2201                "spec_type" => {
2202                    if !p.variants.is_empty() {
2203                        spec_type_values = p.variants.clone().into_iter().map(Some).collect();
2204                    } else {
2205                        let step_count = ((p.max - p.min) / p.step).ceil() as usize;
2206                        spec_type_values = (0..=step_count)
2207                            .map(|i| {
2208                                let idx = i.min(spec_type_options.len() - 1);
2209                                Some(spec_type_options[idx].clone())
2210                            })
2211                            .collect()
2212                    }
2213                }
2214                "draft_tokens" => {
2215                    draft_tokens_values = vals.into_iter().map(|v| Some(v as u32)).collect()
2216                }
2217                _ => {}
2218            }
2219        }
2220
2221        let mut combinations = Vec::new();
2222        for &temp in &temp_values {
2223            for &top_p in &top_p_values {
2224                for &top_k in &top_k_values {
2225                    for &rp in &repeat_penalty_values {
2226                        for &fa in &flash_attn_values {
2227                            for &th in &threads_values {
2228                                for &bs in &batch_size_values {
2229                                    for &ec in &expert_count_values {
2230                                        for st in &spec_type_values {
2231                                            for &dt in &draft_tokens_values {
2232                                                combinations.push(BenchTuneParamValue {
2233                                                    temperature: temp,
2234                                                    top_p,
2235                                                    top_k,
2236                                                    repeat_penalty: rp,
2237                                                    context_length: None,
2238                                                    batch_size: bs,
2239                                                    flash_attn: fa,
2240                                                    threads: th,
2241                                                    expert_count: ec,
2242                                                    spec_type: st.clone(),
2243                                                    draft_tokens: dt,
2244                                                });
2245                                            }
2246                                        }
2247                                    }
2248                                }
2249                            }
2250                        }
2251                    }
2252                }
2253            }
2254        }
2255
2256        combinations
2257    }
2258
2259    /// Get total number of tests to run
2260    pub fn get_total_tests_count(&self) -> usize {
2261        self.generate_combinations().len()
2262    }
2263
2264    /// Get number of parameter combinations (fast, without generating them)
2265    pub fn get_num_combinations(&self) -> usize {
2266        let mut count: u64 = 1;
2267        for p in &self.params_to_test {
2268            if !p.enabled {
2269                continue;
2270            }
2271            let vals = if p.name == "flash_attn" {
2272                2 // On/Off
2273            } else if !p.variants.is_empty() {
2274                p.variants.len()
2275            } else if p.name == "spec_type" {
2276                let step_count = ((p.max - p.min) / p.step).ceil() as usize;
2277                (step_count + 1).min(9)
2278            } else {
2279                let step_count = ((p.max - p.min) / p.step).ceil() as usize;
2280                step_count + 1
2281            };
2282            count *= vals as u64;
2283        }
2284        count as usize
2285    }
2286}
2287
2288// ── Parameter struct field count tests ──────────────────────────
2289
2290#[cfg(test)]
2291mod field_count_tests {
2292    use super::*;
2293
2294    /// Verify ModelSettings has the expected number of fields.
2295    /// If this test fails, a field was added/removed — update the checklist
2296    /// in src/models.rs:665 and all locations listed there.
2297    #[test]
2298    fn test_model_settings_field_count() {
2299        // This test uses reflection-like field access to verify the count.
2300        // If a field is added/removed, the tuple size changes and the
2301        // expected count assertion fails.
2302        let s = ModelSettings::default();
2303        let field_count = count_model_settings_fields(&s);
2304        assert_eq!(
2305            field_count, 75,
2306            "ModelSettings has {} fields (expected 75). \
2307        Update the checklist at src/models.rs:665 and all locations listed there.",
2308            field_count
2309        );
2310    }
2311
2312    /// Count fields in ModelSettings by forcing reference to each one.
2313    /// This is a compile-time guarantee: if a field is removed, the
2314    /// function won't compile. If a field is added, the count changes.
2315    #[allow(clippy::too_many_lines)]
2316    fn count_model_settings_fields(s: &ModelSettings) -> usize {
2317        let _ = (
2318            &s.context_length,
2319            &s.threads,
2320            &s.threads_batch,
2321            &s.batch_size,
2322            &s.ubatch_size,
2323            &s.parallel,
2324            &s.max_concurrent_predictions,
2325            &s.uniform_cache,
2326            &s.kv_cache_offload,
2327            &s.cache_type_k,
2328            &s.cache_type_v,
2329            &s.keep,
2330            &s.swa_full,
2331            &s.mlock,
2332            &s.mmap,
2333            &s.numa,
2334            &s.system_prompt,
2335            &s.system_prompt_preset_name,
2336            &s.gpu_layers_mode,
2337            &s.split_mode,
2338            &s.tensor_split,
2339            &s.main_gpu,
2340            &s.fit,
2341            &s.lora,
2342            &s.lora_scaled,
2343            &s.rpc,
2344            &s.embedding,
2345            &s.flash_attn,
2346            &s.expert_count,
2347            &s.jinja,
2348            &s.chat_template,
2349            &s.chat_template_kwargs,
2350            &s.seed,
2351            &s.temperature,
2352            &s.top_k,
2353            &s.top_p,
2354            &s.min_p,
2355            &s.typical_p,
2356            &s.mirostat,
2357            &s.mirostat_lr,
2358            &s.mirostat_ent,
2359            &s.ignore_eos,
2360            &s.samplers,
2361            &s.repeat_penalty,
2362            &s.repeat_last_n,
2363            &s.presence_penalty,
2364            &s.frequency_penalty,
2365            &s.dry_multiplier,
2366            &s.dry_base,
2367            &s.dry_allowed_length,
2368            &s.dry_penalty_last_n,
2369            &s.rope_scaling,
2370            &s.rope_scale,
2371            &s.rope_freq_base,
2372            &s.rope_freq_scale,
2373            &s.rope_yarn_enabled,
2374            &s.host,
2375            &s.port,
2376            &s.timeout,
2377            &s.cache_prompt,
2378            &s.cache_reuse,
2379            &s.webui,
2380            &s.max_tokens,
2381            &s.cache_type,
2382            &s.backend,
2383            &s.llama_cpp_version_cpu,
2384            &s.llama_cpp_version_vulkan,
2385            &s.llama_cpp_version_rocm,
2386            &s.llama_cpp_version_rocm_lemonade,
2387            &s.llama_cpp_version_cuda,
2388            &s.api_endpoint_enabled,
2389            &s.api_endpoint_port,
2390            &s.spec_type,
2391            &s.draft_tokens,
2392            &s.tags,
2393        );
2394        75
2395    }
2396
2397    /// Verify that is_dirty() uses derived PartialEq (compiler-enforced).
2398    /// This test confirms that two identical settings are not dirty,
2399    /// and two different settings are dirty.
2400    #[test]
2401    fn test_is_dirty_uses_derived_eq() {
2402        let s1 = ModelSettings::default();
2403        let s2 = ModelSettings::default();
2404        let s3 = s1.clone();
2405
2406        // Identical settings should not be dirty
2407        assert!(!s1.is_dirty(&s2));
2408        assert!(!s1.is_dirty(&s3));
2409
2410        // Derived PartialEq must match is_dirty
2411        assert_eq!(s1 != s2, s1.is_dirty(&s2));
2412        assert_eq!(s1 != s3, s1.is_dirty(&s3));
2413    }
2414
2415    /// Verify DefaultParams and ModelSettings share the same field set
2416    /// via the From<DefaultParams> for ModelSettings implementation.
2417    #[test]
2418    fn test_from_default_params_completeness() {
2419        let dp = crate::config::DefaultParams::default();
2420        let ms: ModelSettings = dp.clone().into();
2421
2422        // Verify the From impl produces a valid ModelSettings
2423        // The derived PartialEq ensures all fields were mapped
2424        assert_eq!(ms.context_length, 131072);
2425        assert_eq!(ms.threads, dp.threads);
2426        assert_eq!(ms.temperature, 0.8);
2427        // backend is hardware-dependent in DefaultParams::default(),
2428        // so we just verify it was mapped correctly
2429        assert_eq!(ms.backend, dp.backend);
2430    }
2431}