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