memvid_cli/commands/
models.rs

1//! Model management commands for all model types: embeddings, rerankers, and LLMs.
2//!
3//! Supports listing, installing, and removing models for semantic search, reranking,
4//! and local LLM inference.
5
6use std::fs;
7use std::io::{self, Write};
8use std::path::{Path, PathBuf};
9
10use anyhow::{anyhow, bail, Result};
11use clap::{Args, Subcommand, ValueEnum};
12
13use crate::config::CliConfig;
14
15// ============================================================================
16// Embedding Models
17// ============================================================================
18
19/// Local embedding models available for installation
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum EmbeddingModel {
22    /// BGE-small-en-v1.5: Fast, 384-dim, ~33 MB
23    BgeSmall,
24    /// BGE-base-en-v1.5: Balanced, 768-dim, ~110 MB
25    BgeBase,
26    /// Nomic-embed-text-v1.5: High accuracy, 768-dim, ~137 MB
27    Nomic,
28    /// GTE-large-en-v1.5: Best semantic depth, 1024-dim, ~327 MB
29    GteLarge,
30}
31
32impl EmbeddingModel {
33    fn all() -> impl Iterator<Item = EmbeddingModel> {
34        [
35            EmbeddingModel::BgeSmall,
36            EmbeddingModel::BgeBase,
37            EmbeddingModel::Nomic,
38            EmbeddingModel::GteLarge,
39        ]
40        .into_iter()
41    }
42
43    fn display_name(&self) -> &'static str {
44        match self {
45            EmbeddingModel::BgeSmall => "BGE-small-en-v1.5",
46            EmbeddingModel::BgeBase => "BGE-base-en-v1.5",
47            EmbeddingModel::Nomic => "Nomic-embed-text-v1.5",
48            EmbeddingModel::GteLarge => "GTE-large-en-v1.5",
49        }
50    }
51
52    fn cli_name(&self) -> &'static str {
53        match self {
54            EmbeddingModel::BgeSmall => "bge-small",
55            EmbeddingModel::BgeBase => "bge-base",
56            EmbeddingModel::Nomic => "nomic",
57            EmbeddingModel::GteLarge => "gte-large",
58        }
59    }
60
61    fn dimensions(&self) -> usize {
62        match self {
63            EmbeddingModel::BgeSmall => 384,
64            EmbeddingModel::BgeBase => 768,
65            EmbeddingModel::Nomic => 768,
66            EmbeddingModel::GteLarge => 1024,
67        }
68    }
69
70    fn size_mb(&self) -> usize {
71        match self {
72            EmbeddingModel::BgeSmall => 33,
73            EmbeddingModel::BgeBase => 110,
74            EmbeddingModel::Nomic => 137,
75            EmbeddingModel::GteLarge => 327,
76        }
77    }
78
79    fn hf_repo(&self) -> &'static str {
80        match self {
81            EmbeddingModel::BgeSmall => "BAAI/bge-small-en-v1.5",
82            EmbeddingModel::BgeBase => "BAAI/bge-base-en-v1.5",
83            EmbeddingModel::Nomic => "nomic-ai/nomic-embed-text-v1.5",
84            EmbeddingModel::GteLarge => "thenlper/gte-large",
85        }
86    }
87
88    fn is_default(&self) -> bool {
89        matches!(self, EmbeddingModel::BgeSmall)
90    }
91}
92
93// ============================================================================
94// Reranker Models
95// ============================================================================
96
97/// Local reranker models available for installation
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum RerankerModel {
100    /// Jina-reranker-v1-turbo-en: Fast English reranking, ~86 MB
101    JinaTurbo,
102    /// Jina-reranker-v2-base-multilingual: Multilingual, ~200 MB
103    JinaMultilingual,
104    /// BGE-reranker-base: English/Chinese, ~200 MB
105    BgeRerankerBase,
106    /// BGE-reranker-v2-m3: Best multilingual, ~400 MB
107    BgeRerankerV2M3,
108}
109
110impl RerankerModel {
111    fn all() -> impl Iterator<Item = RerankerModel> {
112        [
113            RerankerModel::JinaTurbo,
114            RerankerModel::JinaMultilingual,
115            RerankerModel::BgeRerankerBase,
116            RerankerModel::BgeRerankerV2M3,
117        ]
118        .into_iter()
119    }
120
121    fn display_name(&self) -> &'static str {
122        match self {
123            RerankerModel::JinaTurbo => "Jina-reranker-v1-turbo-en",
124            RerankerModel::JinaMultilingual => "Jina-reranker-v2-base-multilingual",
125            RerankerModel::BgeRerankerBase => "BGE-reranker-base",
126            RerankerModel::BgeRerankerV2M3 => "BGE-reranker-v2-m3",
127        }
128    }
129
130    fn cli_name(&self) -> &'static str {
131        match self {
132            RerankerModel::JinaTurbo => "jina-turbo",
133            RerankerModel::JinaMultilingual => "jina-multilingual",
134            RerankerModel::BgeRerankerBase => "bge-reranker-base",
135            RerankerModel::BgeRerankerV2M3 => "bge-reranker-v2-m3",
136        }
137    }
138
139    fn size_mb(&self) -> usize {
140        match self {
141            RerankerModel::JinaTurbo => 86,
142            RerankerModel::JinaMultilingual => 200,
143            RerankerModel::BgeRerankerBase => 200,
144            RerankerModel::BgeRerankerV2M3 => 400,
145        }
146    }
147
148    fn hf_repo(&self) -> &'static str {
149        match self {
150            RerankerModel::JinaTurbo => "jinaai/jina-reranker-v1-turbo-en",
151            RerankerModel::JinaMultilingual => "jinaai/jina-reranker-v2-base-multilingual",
152            RerankerModel::BgeRerankerBase => "BAAI/bge-reranker-base",
153            RerankerModel::BgeRerankerV2M3 => "rozgo/bge-reranker-v2-m3",
154        }
155    }
156
157    fn language(&self) -> &'static str {
158        match self {
159            RerankerModel::JinaTurbo => "English",
160            RerankerModel::JinaMultilingual => "Multilingual",
161            RerankerModel::BgeRerankerBase => "English/Chinese",
162            RerankerModel::BgeRerankerV2M3 => "Multilingual",
163        }
164    }
165
166    fn is_default(&self) -> bool {
167        matches!(self, RerankerModel::JinaTurbo)
168    }
169}
170
171// ============================================================================
172// LLM Models
173// ============================================================================
174
175/// Known LLM models available for installation
176#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
177pub enum LlmModel {
178    /// Phi-3.5 Mini Instruct (Q4_K_M quantization, ~2.4 GB)
179    #[value(name = "phi-3.5-mini")]
180    Phi35Mini,
181    /// Phi-3.5 Mini Instruct (Q8_0 quantization, ~3.9 GB)
182    #[value(name = "phi-3.5-mini-q8")]
183    Phi35MiniQ8,
184}
185
186impl LlmModel {
187    /// Returns the Hugging Face model ID
188    fn hf_repo(&self) -> &'static str {
189        match self {
190            LlmModel::Phi35Mini | LlmModel::Phi35MiniQ8 => "bartowski/Phi-3.5-mini-instruct-GGUF",
191        }
192    }
193
194    /// Returns the filename within the Hugging Face repo
195    fn hf_filename(&self) -> &'static str {
196        match self {
197            LlmModel::Phi35Mini => "Phi-3.5-mini-instruct-Q4_K_M.gguf",
198            LlmModel::Phi35MiniQ8 => "Phi-3.5-mini-instruct-Q8_0.gguf",
199        }
200    }
201
202    /// Returns the expected file size in bytes (approximate)
203    fn expected_size_bytes(&self) -> u64 {
204        match self {
205            LlmModel::Phi35Mini => 2_360_000_000,   // ~2.4 GB
206            LlmModel::Phi35MiniQ8 => 3_860_000_000, // ~3.9 GB
207        }
208    }
209
210    /// Returns the local directory name for this model
211    fn local_dir_name(&self) -> &'static str {
212        match self {
213            LlmModel::Phi35Mini => "phi-3.5-mini-q4",
214            LlmModel::Phi35MiniQ8 => "phi-3.5-mini-q8",
215        }
216    }
217
218    /// Returns a human-readable display name
219    fn display_name(&self) -> &'static str {
220        match self {
221            LlmModel::Phi35Mini => "Phi-3.5 Mini Instruct (Q4_K_M)",
222            LlmModel::Phi35MiniQ8 => "Phi-3.5 Mini Instruct (Q8_0)",
223        }
224    }
225
226    fn cli_name(&self) -> &'static str {
227        match self {
228            LlmModel::Phi35Mini => "phi-3.5-mini",
229            LlmModel::Phi35MiniQ8 => "phi-3.5-mini-q8",
230        }
231    }
232
233    /// Returns an iterator over all known models
234    fn all() -> impl Iterator<Item = LlmModel> {
235        [LlmModel::Phi35Mini, LlmModel::Phi35MiniQ8].into_iter()
236    }
237
238    fn is_default(&self) -> bool {
239        matches!(self, LlmModel::Phi35Mini)
240    }
241}
242
243// ============================================================================
244// CLIP Models (Visual Search)
245// ============================================================================
246
247/// CLIP models available for installation
248#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
249pub enum ClipModel {
250    /// MobileCLIP-S2 int8 quantized: Fast, 512-dim, ~101 MB total (default)
251    #[value(name = "mobileclip-s2")]
252    MobileClipS2,
253    /// MobileCLIP-S2 fp16: Better accuracy, 512-dim, ~199 MB total
254    #[value(name = "mobileclip-s2-fp16")]
255    MobileClipS2Fp16,
256    /// SigLIP-base quantized: Higher quality, 768-dim, ~211 MB total
257    #[value(name = "siglip-base")]
258    SigLipBase,
259}
260
261impl ClipModel {
262    fn all() -> impl Iterator<Item = ClipModel> {
263        [
264            ClipModel::MobileClipS2,
265            ClipModel::MobileClipS2Fp16,
266            ClipModel::SigLipBase,
267        ]
268        .into_iter()
269    }
270
271    fn display_name(&self) -> &'static str {
272        match self {
273            ClipModel::MobileClipS2 => "MobileCLIP-S2 (int8 quantized)",
274            ClipModel::MobileClipS2Fp16 => "MobileCLIP-S2 (fp16)",
275            ClipModel::SigLipBase => "SigLIP-base (quantized)",
276        }
277    }
278
279    fn cli_name(&self) -> &'static str {
280        match self {
281            ClipModel::MobileClipS2 => "mobileclip-s2",
282            ClipModel::MobileClipS2Fp16 => "mobileclip-s2-fp16",
283            ClipModel::SigLipBase => "siglip-base",
284        }
285    }
286
287    fn dimensions(&self) -> usize {
288        match self {
289            ClipModel::MobileClipS2 | ClipModel::MobileClipS2Fp16 => 512,
290            ClipModel::SigLipBase => 768,
291        }
292    }
293
294    fn total_size_mb(&self) -> f32 {
295        match self {
296            ClipModel::MobileClipS2 => 100.8,     // 36.7 + 64.1
297            ClipModel::MobileClipS2Fp16 => 198.7, // 71.7 + 127.0
298            ClipModel::SigLipBase => 210.5,       // 99.5 + 111.0
299        }
300    }
301
302    fn vision_url(&self) -> &'static str {
303        match self {
304            ClipModel::MobileClipS2 => "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/vision_model_int8.onnx",
305            ClipModel::MobileClipS2Fp16 => "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/vision_model_fp16.onnx",
306            ClipModel::SigLipBase => "https://huggingface.co/Xenova/siglip-base-patch16-224/resolve/main/onnx/vision_model_quantized.onnx",
307        }
308    }
309
310    fn text_url(&self) -> &'static str {
311        match self {
312            ClipModel::MobileClipS2 => "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/text_model_int8.onnx",
313            ClipModel::MobileClipS2Fp16 => "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/text_model_fp16.onnx",
314            ClipModel::SigLipBase => "https://huggingface.co/Xenova/siglip-base-patch16-224/resolve/main/onnx/text_model_quantized.onnx",
315        }
316    }
317
318    fn vision_filename(&self) -> &'static str {
319        match self {
320            ClipModel::MobileClipS2 => "mobileclip-s2_vision.onnx",
321            ClipModel::MobileClipS2Fp16 => "mobileclip-s2-fp16_vision.onnx",
322            ClipModel::SigLipBase => "siglip-base_vision.onnx",
323        }
324    }
325
326    fn text_filename(&self) -> &'static str {
327        match self {
328            ClipModel::MobileClipS2 => "mobileclip-s2_text.onnx",
329            ClipModel::MobileClipS2Fp16 => "mobileclip-s2-fp16_text.onnx",
330            ClipModel::SigLipBase => "siglip-base_text.onnx",
331        }
332    }
333
334    fn is_default(&self) -> bool {
335        matches!(self, ClipModel::MobileClipS2)
336    }
337}
338
339// ============================================================================
340// NER Models (Named Entity Recognition for Logic-Mesh)
341// ============================================================================
342
343/// NER models available for installation
344#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
345pub enum NerModel {
346    /// DistilBERT-NER: Fast & accurate NER, ~261 MB, 92% F1
347    #[value(name = "distilbert-ner")]
348    DistilbertNer,
349}
350
351impl NerModel {
352    fn all() -> impl Iterator<Item = NerModel> {
353        [NerModel::DistilbertNer].into_iter()
354    }
355
356    fn display_name(&self) -> &'static str {
357        match self {
358            NerModel::DistilbertNer => "DistilBERT-NER (dslim)",
359        }
360    }
361
362    fn cli_name(&self) -> &'static str {
363        match self {
364            NerModel::DistilbertNer => "distilbert-ner",
365        }
366    }
367
368    fn size_mb(&self) -> f32 {
369        match self {
370            NerModel::DistilbertNer => 261.0,
371        }
372    }
373
374    fn model_url(&self) -> &'static str {
375        match self {
376            NerModel::DistilbertNer => "https://huggingface.co/dslim/distilbert-NER/resolve/main/onnx/model.onnx",
377        }
378    }
379
380    fn tokenizer_url(&self) -> &'static str {
381        match self {
382            NerModel::DistilbertNer => "https://huggingface.co/dslim/distilbert-NER/resolve/main/tokenizer.json",
383        }
384    }
385
386    fn local_dir_name(&self) -> &'static str {
387        match self {
388            NerModel::DistilbertNer => "distilbert-ner",
389        }
390    }
391
392    fn is_default(&self) -> bool {
393        matches!(self, NerModel::DistilbertNer)
394    }
395}
396
397// ============================================================================
398// External Models (API-based, no download required)
399// ============================================================================
400
401/// External embedding providers (API-based)
402struct ExternalEmbeddingProvider {
403    name: &'static str,
404    models: &'static [(&'static str, usize, &'static str)], // (model_name, dimensions, description)
405    env_var: &'static str,
406}
407
408const EXTERNAL_EMBEDDING_PROVIDERS: &[ExternalEmbeddingProvider] = &[
409    ExternalEmbeddingProvider {
410        name: "OpenAI",
411        models: &[
412            ("text-embedding-3-large", 3072, "Highest quality"),
413            ("text-embedding-3-small", 1536, "Good balance"),
414            ("text-embedding-ada-002", 1536, "Legacy"),
415        ],
416        env_var: "OPENAI_API_KEY",
417    },
418    ExternalEmbeddingProvider {
419        name: "Cohere",
420        models: &[
421            ("embed-english-v3.0", 1024, "English"),
422            ("embed-multilingual-v3.0", 1024, "Multilingual"),
423        ],
424        env_var: "COHERE_API_KEY",
425    },
426    ExternalEmbeddingProvider {
427        name: "Voyage",
428        models: &[
429            ("voyage-3", 1024, "Code & technical docs"),
430            ("voyage-3-lite", 512, "Lightweight"),
431        ],
432        env_var: "VOYAGE_API_KEY",
433    },
434];
435
436// ============================================================================
437// CLI Commands
438// ============================================================================
439
440/// Model management commands
441#[derive(Args)]
442pub struct ModelsArgs {
443    #[command(subcommand)]
444    pub command: ModelsCommand,
445}
446
447#[derive(Subcommand)]
448pub enum ModelsCommand {
449    /// Install an LLM model for enrichment
450    Install(ModelsInstallArgs),
451    /// List all available and installed models
452    List(ModelsListArgs),
453    /// Remove an installed model
454    Remove(ModelsRemoveArgs),
455    /// Verify model integrity
456    Verify(ModelsVerifyArgs),
457}
458
459#[derive(Args)]
460pub struct ModelsInstallArgs {
461    /// LLM model to install (phi-3.5-mini, phi-3.5-mini-q8)
462    #[arg(value_enum, group = "model_choice")]
463    pub model: Option<LlmModel>,
464
465    /// CLIP model to install for visual search (mobileclip-s2, mobileclip-s2-fp16, siglip-base)
466    #[arg(long, value_enum, group = "model_choice")]
467    pub clip: Option<ClipModel>,
468
469    /// NER model to install for Logic-Mesh entity extraction (gliner-int8)
470    #[arg(long, value_enum, group = "model_choice")]
471    pub ner: Option<NerModel>,
472
473    /// Force re-download even if already installed
474    #[arg(long, short)]
475    pub force: bool,
476}
477
478#[derive(Args)]
479pub struct ModelsListArgs {
480    /// Output in JSON format
481    #[arg(long)]
482    pub json: bool,
483
484    /// Show only a specific model type
485    #[arg(long, value_enum)]
486    pub model_type: Option<ModelType>,
487}
488
489#[derive(Debug, Clone, Copy, ValueEnum)]
490pub enum ModelType {
491    /// Embedding models for semantic search
492    Embedding,
493    /// Reranker models for result reranking
494    Reranker,
495    /// LLM models for local inference
496    Llm,
497    /// CLIP models for visual search
498    Clip,
499    /// NER models for Logic-Mesh entity extraction
500    Ner,
501    /// External API-based models
502    External,
503}
504
505#[derive(Args)]
506pub struct ModelsRemoveArgs {
507    /// Model to remove
508    #[arg(value_enum)]
509    pub model: LlmModel,
510
511    /// Skip confirmation prompt
512    #[arg(long, short)]
513    pub yes: bool,
514}
515
516#[derive(Args)]
517pub struct ModelsVerifyArgs {
518    /// Model to verify (verifies all if not specified)
519    #[arg(value_enum)]
520    pub model: Option<LlmModel>,
521}
522
523/// Information about an installed model
524#[derive(Debug, Clone, serde::Serialize)]
525pub struct InstalledModel {
526    pub name: String,
527    pub model_type: String,
528    pub path: PathBuf,
529    pub size_bytes: u64,
530    pub verified: bool,
531}
532
533// ============================================================================
534// Directory Helpers
535// ============================================================================
536
537/// Get the LLM models directory
538fn llm_models_dir(config: &CliConfig) -> PathBuf {
539    config.models_dir.join("llm")
540}
541
542/// Get the fastembed cache directory (for embeddings and rerankers)
543fn fastembed_cache_dir(config: &CliConfig) -> PathBuf {
544    config.models_dir.clone()
545}
546
547/// Get the path where a specific LLM model should be stored
548fn llm_model_path(config: &CliConfig, model: LlmModel) -> PathBuf {
549    llm_models_dir(config)
550        .join(model.local_dir_name())
551        .join(model.hf_filename())
552}
553
554/// Check if an LLM model is installed
555fn is_llm_model_installed(config: &CliConfig, model: LlmModel) -> bool {
556    let path = llm_model_path(config, model);
557    path.exists() && path.is_file()
558}
559
560/// Get the CLIP models directory
561fn clip_models_dir(config: &CliConfig) -> PathBuf {
562    config.models_dir.clone()
563}
564
565/// Get the path where a specific CLIP model vision encoder should be stored
566fn clip_vision_path(config: &CliConfig, model: ClipModel) -> PathBuf {
567    clip_models_dir(config).join(model.vision_filename())
568}
569
570/// Get the path where a specific CLIP model text encoder should be stored
571fn clip_text_path(config: &CliConfig, model: ClipModel) -> PathBuf {
572    clip_models_dir(config).join(model.text_filename())
573}
574
575/// Check if a CLIP model is fully installed (both vision and text encoders)
576fn is_clip_model_installed(config: &CliConfig, model: ClipModel) -> bool {
577    let vision_path = clip_vision_path(config, model);
578    let text_path = clip_text_path(config, model);
579    vision_path.exists() && vision_path.is_file() && text_path.exists() && text_path.is_file()
580}
581
582/// Check if a CLIP model is partially installed
583fn clip_model_status(config: &CliConfig, model: ClipModel) -> (&'static str, bool, bool) {
584    let has_vision = clip_vision_path(config, model).exists();
585    let has_text = clip_text_path(config, model).exists();
586    let status = match (has_vision, has_text) {
587        (true, true) => "✓ installed",
588        (true, false) => "⚠ partial (missing text)",
589        (false, true) => "⚠ partial (missing vision)",
590        (false, false) => "○ available",
591    };
592    (status, has_vision, has_text)
593}
594
595/// Get the NER models directory
596fn ner_models_dir(config: &CliConfig) -> PathBuf {
597    config.models_dir.clone()
598}
599
600/// Get the path where a specific NER model should be stored
601fn ner_model_path(config: &CliConfig, model: NerModel) -> PathBuf {
602    ner_models_dir(config)
603        .join(model.local_dir_name())
604        .join("model.onnx")
605}
606
607/// Get the path where a specific NER tokenizer should be stored
608fn ner_tokenizer_path(config: &CliConfig, model: NerModel) -> PathBuf {
609    ner_models_dir(config)
610        .join(model.local_dir_name())
611        .join("tokenizer.json")
612}
613
614/// Check if a NER model is fully installed (both model and tokenizer)
615fn is_ner_model_installed(config: &CliConfig, model: NerModel) -> bool {
616    let model_path = ner_model_path(config, model);
617    let tokenizer_path = ner_tokenizer_path(config, model);
618    model_path.exists() && model_path.is_file() && tokenizer_path.exists() && tokenizer_path.is_file()
619}
620
621/// Check NER model install status
622fn ner_model_status(config: &CliConfig, model: NerModel) -> (&'static str, bool, bool) {
623    let has_model = ner_model_path(config, model).exists();
624    let has_tokenizer = ner_tokenizer_path(config, model).exists();
625    let status = match (has_model, has_tokenizer) {
626        (true, true) => "✓ installed",
627        (true, false) => "⚠ partial (missing tokenizer)",
628        (false, true) => "⚠ partial (missing model)",
629        (false, false) => "○ available",
630    };
631    (status, has_model, has_tokenizer)
632}
633
634/// Scan fastembed cache for installed embedding/reranker models
635fn scan_fastembed_cache(config: &CliConfig) -> Vec<(String, PathBuf, u64)> {
636    let cache_dir = fastembed_cache_dir(config);
637    let mut installed = Vec::new();
638
639    if let Ok(entries) = fs::read_dir(&cache_dir) {
640        for entry in entries.flatten() {
641            let path = entry.path();
642            if path.is_dir() {
643                let name = path.file_name().unwrap_or_default().to_string_lossy();
644                // fastembed caches models in directories like "models--BAAI--bge-small-en-v1.5"
645                if name.starts_with("models--") {
646                    let size = dir_size(&path).unwrap_or(0);
647                    let model_name = name.replace("models--", "").replace("--", "/");
648                    installed.push((model_name, path, size));
649                }
650            }
651        }
652    }
653
654    installed
655}
656
657/// Calculate directory size recursively
658fn dir_size(path: &Path) -> io::Result<u64> {
659    let mut size = 0;
660    if path.is_dir() {
661        for entry in fs::read_dir(path)? {
662            let entry = entry?;
663            let path = entry.path();
664            if path.is_dir() {
665                size += dir_size(&path)?;
666            } else {
667                size += entry.metadata()?.len();
668            }
669        }
670    }
671    Ok(size)
672}
673
674// ============================================================================
675// Command Handlers
676// ============================================================================
677
678/// Handle the models command
679pub fn handle_models(config: &CliConfig, args: ModelsArgs) -> Result<()> {
680    match args.command {
681        ModelsCommand::Install(install_args) => handle_models_install(config, install_args),
682        ModelsCommand::List(list_args) => handle_models_list(config, list_args),
683        ModelsCommand::Remove(remove_args) => handle_models_remove(config, remove_args),
684        ModelsCommand::Verify(verify_args) => handle_models_verify(config, verify_args),
685    }
686}
687
688/// Handle model installation
689pub fn handle_models_install(config: &CliConfig, args: ModelsInstallArgs) -> Result<()> {
690    // Check if CLIP model is being installed
691    if let Some(clip_model) = args.clip {
692        return handle_clip_install(config, clip_model, args.force);
693    }
694
695    // Check if NER model is being installed
696    if let Some(ner_model) = args.ner {
697        return handle_ner_install(config, ner_model, args.force);
698    }
699
700    // Check if LLM model is being installed
701    if let Some(llm_model) = args.model {
702        return handle_llm_install(config, llm_model, args.force);
703    }
704
705    // Neither specified - show help
706    bail!(
707        "Please specify a model to install:\n\
708         \n\
709         LLM models (for local inference):\n\
710         \x20 memvid models install phi-3.5-mini\n\
711         \x20 memvid models install phi-3.5-mini-q8\n\
712         \n\
713         CLIP models (for visual search):\n\
714         \x20 memvid models install --clip mobileclip-s2\n\
715         \x20 memvid models install --clip mobileclip-s2-fp16\n\
716         \x20 memvid models install --clip siglip-base\n\
717         \n\
718         NER models (for Logic-Mesh entity extraction):\n\
719         \x20 memvid models install --ner distilbert-ner"
720    );
721}
722
723/// Handle CLIP model installation
724fn handle_clip_install(config: &CliConfig, model: ClipModel, force: bool) -> Result<()> {
725    let vision_path = clip_vision_path(config, model);
726    let text_path = clip_text_path(config, model);
727
728    if is_clip_model_installed(config, model) && !force {
729        println!(
730            "{} is already installed at {}",
731            model.display_name(),
732            clip_models_dir(config).display()
733        );
734        println!("Use --force to re-download.");
735        return Ok(());
736    }
737
738    if config.offline {
739        bail!(
740            "Cannot install models while offline (MEMVID_OFFLINE=1). \
741             Run without MEMVID_OFFLINE to download the model."
742        );
743    }
744
745    // Create the directory structure
746    fs::create_dir_all(clip_models_dir(config))?;
747
748    println!("Installing {}...", model.display_name());
749    println!("Dimensions: {}", model.dimensions());
750    println!("Total size: {:.1} MB", model.total_size_mb());
751    println!();
752
753    // Download vision encoder
754    println!("Downloading vision encoder...");
755    download_file(model.vision_url(), &vision_path)?;
756
757    // Download text encoder
758    println!();
759    println!("Downloading text encoder...");
760    download_file(model.text_url(), &text_path)?;
761
762    // Calculate total size
763    let vision_size = fs::metadata(&vision_path).map(|m| m.len()).unwrap_or(0);
764    let text_size = fs::metadata(&text_path).map(|m| m.len()).unwrap_or(0);
765    let total_size = vision_size + text_size;
766
767    println!();
768    println!(
769        "Successfully installed {} ({:.1} MB)",
770        model.display_name(),
771        total_size as f64 / 1_000_000.0
772    );
773    println!("Vision encoder: {}", vision_path.display());
774    println!("Text encoder: {}", text_path.display());
775    println!();
776    println!("Usage:");
777    println!("  memvid put photos.mv2 --input ./images/ --clip");
778    println!("  memvid find photos.mv2 --query \"sunset over ocean\" --mode clip");
779
780    Ok(())
781}
782
783/// Handle NER model installation
784fn handle_ner_install(config: &CliConfig, model: NerModel, force: bool) -> Result<()> {
785    let model_path = ner_model_path(config, model);
786    let tokenizer_path = ner_tokenizer_path(config, model);
787
788    if is_ner_model_installed(config, model) && !force {
789        println!(
790            "{} is already installed at {}",
791            model.display_name(),
792            model_path.parent().unwrap_or(&model_path).display()
793        );
794        println!("Use --force to re-download.");
795        return Ok(());
796    }
797
798    if config.offline {
799        bail!(
800            "Cannot install models while offline (MEMVID_OFFLINE=1). \
801             Run without MEMVID_OFFLINE to download the model."
802        );
803    }
804
805    // Create the directory structure
806    if let Some(parent) = model_path.parent() {
807        fs::create_dir_all(parent)?;
808    }
809
810    println!("Installing {}...", model.display_name());
811    println!("Size: {:.1} MB", model.size_mb());
812    println!();
813
814    // Download model ONNX
815    println!("Downloading model...");
816    download_file(model.model_url(), &model_path)?;
817
818    // Download tokenizer
819    println!();
820    println!("Downloading tokenizer...");
821    download_file(model.tokenizer_url(), &tokenizer_path)?;
822
823    // Calculate total size
824    let model_size = fs::metadata(&model_path).map(|m| m.len()).unwrap_or(0);
825    let tokenizer_size = fs::metadata(&tokenizer_path).map(|m| m.len()).unwrap_or(0);
826    let total_size = model_size + tokenizer_size;
827
828    println!();
829    println!(
830        "Successfully installed {} ({:.1} MB)",
831        model.display_name(),
832        total_size as f64 / 1_000_000.0
833    );
834    println!("Model: {}", model_path.display());
835    println!("Tokenizer: {}", tokenizer_path.display());
836    println!();
837    println!("Usage:");
838    println!("  memvid enrich file.mv2 --logic-mesh");
839    println!("  memvid follow traverse file.mv2 --start \"John\" --link manager");
840
841    Ok(())
842}
843
844/// Handle LLM model installation
845fn handle_llm_install(config: &CliConfig, model: LlmModel, force: bool) -> Result<()> {
846    let target_path = llm_model_path(config, model);
847
848    if is_llm_model_installed(config, model) && !force {
849        println!(
850            "{} is already installed at {}",
851            model.display_name(),
852            target_path.display()
853        );
854        println!("Use --force to re-download.");
855        return Ok(());
856    }
857
858    if config.offline {
859        bail!(
860            "Cannot install models while offline (MEMVID_OFFLINE=1). \
861             Run without MEMVID_OFFLINE to download the model."
862        );
863    }
864
865    // Create the directory structure
866    if let Some(parent) = target_path.parent() {
867        fs::create_dir_all(parent)?;
868    }
869
870    println!("Installing {}...", model.display_name());
871    println!("Repository: {}", model.hf_repo());
872    println!("File: {}", model.hf_filename());
873    println!(
874        "Expected size: {:.1} GB",
875        model.expected_size_bytes() as f64 / 1_000_000_000.0
876    );
877    println!();
878
879    // Download using curl
880    download_llm_model(model, &target_path)?;
881
882    // Verify the download
883    let metadata = fs::metadata(&target_path)?;
884    let size = metadata.len();
885
886    // Allow 10% variance in file size
887    let min_size = (model.expected_size_bytes() as f64 * 0.9) as u64;
888    let max_size = (model.expected_size_bytes() as f64 * 1.1) as u64;
889
890    if size < min_size || size > max_size {
891        eprintln!(
892            "Warning: Downloaded file size ({:.2} GB) differs significantly from expected ({:.2} GB)",
893            size as f64 / 1_000_000_000.0,
894            model.expected_size_bytes() as f64 / 1_000_000_000.0
895        );
896    }
897
898    println!();
899    println!(
900        "Successfully installed {} ({:.2} GB)",
901        model.display_name(),
902        size as f64 / 1_000_000_000.0
903    );
904    println!("Location: {}", target_path.display());
905
906    Ok(())
907}
908
909/// Download a file from a URL using curl
910fn download_file(url: &str, target_path: &Path) -> Result<()> {
911    println!("URL: {}", url);
912
913    let status = std::process::Command::new("curl")
914        .args([
915            "-L",             // Follow redirects
916            "--progress-bar", // Show progress bar
917            "-o",
918            target_path
919                .to_str()
920                .ok_or_else(|| anyhow!("Invalid target path"))?,
921            url,
922        ])
923        .status()?;
924
925    if !status.success() {
926        // Clean up partial download
927        let _ = fs::remove_file(target_path);
928        bail!("Download failed. Please check your internet connection and try again.");
929    }
930
931    Ok(())
932}
933
934/// Download an LLM model from Hugging Face
935fn download_llm_model(model: LlmModel, target_path: &Path) -> Result<()> {
936    let url = format!(
937        "https://huggingface.co/{}/resolve/main/{}",
938        model.hf_repo(),
939        model.hf_filename()
940    );
941
942    println!("Downloading from Hugging Face...");
943    download_file(&url, target_path)
944}
945
946/// Handle listing models
947pub fn handle_models_list(config: &CliConfig, args: ModelsListArgs) -> Result<()> {
948    let fastembed_installed = scan_fastembed_cache(config);
949
950    if args.json {
951        return handle_models_list_json(config, &fastembed_installed);
952    }
953
954    // Check which sections to show
955    let show_all = args.model_type.is_none();
956    let show_embedding = show_all || matches!(args.model_type, Some(ModelType::Embedding));
957    let show_reranker = show_all || matches!(args.model_type, Some(ModelType::Reranker));
958    let show_llm = show_all || matches!(args.model_type, Some(ModelType::Llm));
959    let show_clip = show_all || matches!(args.model_type, Some(ModelType::Clip));
960    let show_ner = show_all || matches!(args.model_type, Some(ModelType::Ner));
961    let show_external = show_all || matches!(args.model_type, Some(ModelType::External));
962
963    println!("╔══════════════════════════════════════════════════════════════════╗");
964    println!("║                       MEMVID MODEL CATALOG                       ║");
965    println!("╚══════════════════════════════════════════════════════════════════╝");
966    println!();
967
968    // Show models directory
969    println!("Models Directory: {}", config.models_dir.display());
970    println!();
971
972    // =========================================================================
973    // Embedding Models
974    // =========================================================================
975    if show_embedding {
976        println!("┌──────────────────────────────────────────────────────────────────┐");
977        println!("│ 📊 EMBEDDING MODELS (Semantic Search)                            │");
978        println!("├──────────────────────────────────────────────────────────────────┤");
979
980        for model in EmbeddingModel::all() {
981            let is_installed = fastembed_installed.iter().any(|(name, _, _)| {
982                name.contains(&model.hf_repo().replace("/", "--").replace("--", "/"))
983            });
984
985            let status = if is_installed {
986                "✓ installed"
987            } else {
988                "○ available"
989            };
990            let default_marker = if model.is_default() { " (default)" } else { "" };
991
992            println!(
993                "│ {:20} {:4}D  {:>4} MB  {:15}{}",
994                model.cli_name(),
995                model.dimensions(),
996                model.size_mb(),
997                status,
998                default_marker
999            );
1000        }
1001
1002        println!("│                                                                  │");
1003        println!("│ Usage: memvid put file.mv2 --input doc.pdf --embedding           │");
1004        println!("│        memvid put file.mv2 --input doc.pdf --embedding-model nomic│");
1005        println!("└──────────────────────────────────────────────────────────────────┘");
1006        println!();
1007    }
1008
1009    // =========================================================================
1010    // Reranker Models
1011    // =========================================================================
1012    if show_reranker {
1013        println!("┌──────────────────────────────────────────────────────────────────┐");
1014        println!("│ 🔄 RERANKER MODELS (Result Reranking)                            │");
1015        println!("├──────────────────────────────────────────────────────────────────┤");
1016
1017        for model in RerankerModel::all() {
1018            let is_installed = fastembed_installed.iter().any(|(name, _, _)| {
1019                let repo = model.hf_repo();
1020                name.to_lowercase()
1021                    .contains(&repo.to_lowercase().replace("/", "--").replace("--", "/"))
1022                    || name
1023                        .to_lowercase()
1024                        .contains(&repo.split('/').last().unwrap_or("").to_lowercase())
1025            });
1026
1027            let status = if is_installed {
1028                "✓ installed"
1029            } else {
1030                "○ available"
1031            };
1032            let default_marker = if model.is_default() { " (default)" } else { "" };
1033
1034            println!(
1035                "│ {:25} {:>4} MB  {:12}  {:12}{}",
1036                model.cli_name(),
1037                model.size_mb(),
1038                model.language(),
1039                status,
1040                default_marker
1041            );
1042        }
1043
1044        println!("│                                                                  │");
1045        println!("│ Reranking is automatic in hybrid search mode (--mode auto)       │");
1046        println!("└──────────────────────────────────────────────────────────────────┘");
1047        println!();
1048    }
1049
1050    // =========================================================================
1051    // LLM Models
1052    // =========================================================================
1053    if show_llm {
1054        println!("┌──────────────────────────────────────────────────────────────────┐");
1055        println!("│ 🤖 LLM MODELS (Local Inference)                                  │");
1056        println!("├──────────────────────────────────────────────────────────────────┤");
1057
1058        for model in LlmModel::all() {
1059            let is_installed = is_llm_model_installed(config, model);
1060            let status = if is_installed {
1061                "✓ installed"
1062            } else {
1063                "○ available"
1064            };
1065            let default_marker = if model.is_default() { " (default)" } else { "" };
1066
1067            println!(
1068                "│ {:20} {:>5.1} GB  {:15}{}",
1069                model.cli_name(),
1070                model.expected_size_bytes() as f64 / 1_000_000_000.0,
1071                status,
1072                default_marker
1073            );
1074
1075            if is_installed {
1076                println!("│   Path: {}", llm_model_path(config, model).display());
1077            }
1078        }
1079
1080        println!("│                                                                  │");
1081        println!("│ Install: memvid models install phi-3.5-mini                      │");
1082        println!("│ Usage:   memvid ask file.mv2 --question \"...\" --model candle:phi │");
1083        println!("└──────────────────────────────────────────────────────────────────┘");
1084        println!();
1085    }
1086
1087    // =========================================================================
1088    // CLIP Models (Visual Search)
1089    // =========================================================================
1090    if show_clip {
1091        println!("┌──────────────────────────────────────────────────────────────────┐");
1092        println!("│ 🖼️  CLIP MODELS (Visual Search)                                   │");
1093        println!("├──────────────────────────────────────────────────────────────────┤");
1094
1095        for model in ClipModel::all() {
1096            let (status, _, _) = clip_model_status(config, model);
1097            let default_marker = if model.is_default() { " (default)" } else { "" };
1098
1099            println!(
1100                "│ {:20} {:4}D  {:>6.1} MB  {:15}{}",
1101                model.cli_name(),
1102                model.dimensions(),
1103                model.total_size_mb(),
1104                status,
1105                default_marker
1106            );
1107        }
1108
1109        println!("│                                                                  │");
1110        println!("│ Install: memvid models install --clip mobileclip-s2              │");
1111        println!("│ Usage:   memvid put photos.mv2 --input ./images/ --clip          │");
1112        println!("│          memvid find photos.mv2 --query \"sunset\" --mode clip     │");
1113        println!("└──────────────────────────────────────────────────────────────────┘");
1114        println!();
1115    }
1116
1117    // =========================================================================
1118    // NER Models (Logic-Mesh Entity Extraction)
1119    // =========================================================================
1120    if show_ner {
1121        println!("┌──────────────────────────────────────────────────────────────────┐");
1122        println!("│ 🔗 NER MODELS (Logic-Mesh Entity Extraction)                      │");
1123        println!("├──────────────────────────────────────────────────────────────────┤");
1124
1125        for model in NerModel::all() {
1126            let (status, _, _) = ner_model_status(config, model);
1127            let default_marker = if model.is_default() { " (default)" } else { "" };
1128
1129            println!(
1130                "│ {:20} {:>6.1} MB  {:15}{}",
1131                model.cli_name(),
1132                model.size_mb(),
1133                status,
1134                default_marker
1135            );
1136        }
1137
1138        println!("│                                                                  │");
1139        println!("│ Install: memvid models install --ner distilbert-ner              │");
1140        println!("│ Usage:   memvid put file.mv2 --input doc.txt --logic-mesh        │");
1141        println!("│          memvid follow traverse file.mv2 --start \"John\"          │");
1142        println!("└──────────────────────────────────────────────────────────────────┘");
1143        println!();
1144    }
1145
1146    // =========================================================================
1147    // External Models (API-based)
1148    // =========================================================================
1149    if show_external {
1150        println!("┌──────────────────────────────────────────────────────────────────┐");
1151        println!("│ ☁️  EXTERNAL MODELS (API-based, no download required)             │");
1152        println!("├──────────────────────────────────────────────────────────────────┤");
1153
1154        for provider in EXTERNAL_EMBEDDING_PROVIDERS {
1155            let api_key_set = std::env::var(provider.env_var).is_ok();
1156            let key_status = if api_key_set {
1157                format!("{} ✓", provider.env_var)
1158            } else {
1159                format!("{} ○", provider.env_var)
1160            };
1161
1162            println!("│ {} ({}):", provider.name, key_status);
1163
1164            for (model_name, dim, desc) in provider.models.iter() {
1165                println!("│   {:30} {:4}D  {}", model_name, dim, desc);
1166            }
1167            println!("│");
1168        }
1169
1170        println!("│ Usage: export OPENAI_API_KEY=sk-...                              │");
1171        println!("│        memvid put file.mv2 --input doc.pdf --embedding-model openai│");
1172        println!("└──────────────────────────────────────────────────────────────────┘");
1173        println!();
1174    }
1175
1176    // =========================================================================
1177    // Installed Models Summary
1178    // =========================================================================
1179    if !fastembed_installed.is_empty() {
1180        println!("┌──────────────────────────────────────────────────────────────────┐");
1181        println!(
1182            "│ 📦 INSTALLED MODELS (cached in {})     │",
1183            config.models_dir.display()
1184        );
1185        println!("├──────────────────────────────────────────────────────────────────┤");
1186
1187        let mut total_size: u64 = 0;
1188
1189        for (name, _path, size) in &fastembed_installed {
1190            total_size += size;
1191            println!(
1192                "│ {:40} {:>8.1} MB",
1193                if name.len() > 40 {
1194                    format!("{}...", &name[..37])
1195                } else {
1196                    name.clone()
1197                },
1198                *size as f64 / 1_000_000.0
1199            );
1200        }
1201
1202        // Add LLM models
1203        for model in LlmModel::all() {
1204            if is_llm_model_installed(config, model) {
1205                let path = llm_model_path(config, model);
1206                if let Ok(meta) = fs::metadata(&path) {
1207                    total_size += meta.len();
1208                    println!(
1209                        "│ {:40} {:>8.1} MB",
1210                        model.display_name(),
1211                        meta.len() as f64 / 1_000_000.0
1212                    );
1213                }
1214            }
1215        }
1216
1217        println!("├──────────────────────────────────────────────────────────────────┤");
1218        println!("│ Total: {:>55.1} MB │", total_size as f64 / 1_000_000.0);
1219        println!("└──────────────────────────────────────────────────────────────────┘");
1220        println!();
1221    }
1222
1223    // =========================================================================
1224    // Quick Help
1225    // =========================================================================
1226    println!("╔══════════════════════════════════════════════════════════════════╗");
1227    println!("║ QUICK REFERENCE                                                  ║");
1228    println!("╟──────────────────────────────────────────────────────────────────╢");
1229    println!("║ memvid models list                    List all models            ║");
1230    println!("║ memvid models list --model-type llm   List only LLM models       ║");
1231    println!("║ memvid models install phi-3.5-mini    Install LLM model          ║");
1232    println!("║ memvid models remove phi-3.5-mini     Remove LLM model           ║");
1233    println!("║ memvid models verify                  Verify installed models    ║");
1234    println!("╚══════════════════════════════════════════════════════════════════╝");
1235
1236    Ok(())
1237}
1238
1239/// Handle JSON output for models list
1240fn handle_models_list_json(
1241    config: &CliConfig,
1242    fastembed_installed: &[(String, PathBuf, u64)],
1243) -> Result<()> {
1244    let output = serde_json::json!({
1245        "models_dir": config.models_dir,
1246        "embedding_models": EmbeddingModel::all().map(|m| {
1247            let is_installed = fastembed_installed
1248                .iter()
1249                .any(|(name, _, _)| name.contains(m.hf_repo()));
1250            serde_json::json!({
1251                "name": m.cli_name(),
1252                "display_name": m.display_name(),
1253                "dimensions": m.dimensions(),
1254                "size_mb": m.size_mb(),
1255                "hf_repo": m.hf_repo(),
1256                "installed": is_installed,
1257                "is_default": m.is_default(),
1258            })
1259        }).collect::<Vec<_>>(),
1260        "reranker_models": RerankerModel::all().map(|m| {
1261            serde_json::json!({
1262                "name": m.cli_name(),
1263                "display_name": m.display_name(),
1264                "size_mb": m.size_mb(),
1265                "hf_repo": m.hf_repo(),
1266                "language": m.language(),
1267                "is_default": m.is_default(),
1268            })
1269        }).collect::<Vec<_>>(),
1270        "llm_models": LlmModel::all().map(|m| {
1271            serde_json::json!({
1272                "name": m.cli_name(),
1273                "display_name": m.display_name(),
1274                "size_gb": m.expected_size_bytes() as f64 / 1_000_000_000.0,
1275                "hf_repo": m.hf_repo(),
1276                "installed": is_llm_model_installed(config, m),
1277                "path": if is_llm_model_installed(config, m) {
1278                    Some(llm_model_path(config, m))
1279                } else {
1280                    None
1281                },
1282                "is_default": m.is_default(),
1283            })
1284        }).collect::<Vec<_>>(),
1285        "external_providers": EXTERNAL_EMBEDDING_PROVIDERS.iter().map(|p| {
1286            serde_json::json!({
1287                "name": p.name,
1288                "env_var": p.env_var,
1289                "configured": std::env::var(p.env_var).is_ok(),
1290                "models": p.models.iter().map(|(name, dim, desc)| {
1291                    serde_json::json!({
1292                        "name": name,
1293                        "dimensions": dim,
1294                        "description": desc,
1295                    })
1296                }).collect::<Vec<_>>(),
1297            })
1298        }).collect::<Vec<_>>(),
1299        "installed_cache": fastembed_installed.iter().map(|(name, path, size)| {
1300            serde_json::json!({
1301                "name": name,
1302                "path": path,
1303                "size_bytes": size,
1304            })
1305        }).collect::<Vec<_>>(),
1306    });
1307
1308    println!("{}", serde_json::to_string_pretty(&output)?);
1309    Ok(())
1310}
1311
1312/// Handle model removal
1313pub fn handle_models_remove(config: &CliConfig, args: ModelsRemoveArgs) -> Result<()> {
1314    let model = args.model;
1315    let path = llm_model_path(config, model);
1316
1317    if !path.exists() {
1318        println!("{} is not installed.", model.display_name());
1319        return Ok(());
1320    }
1321
1322    if !args.yes {
1323        print!(
1324            "Remove {} ({})? [y/N] ",
1325            model.display_name(),
1326            path.display()
1327        );
1328        io::stdout().flush()?;
1329
1330        let mut input = String::new();
1331        io::stdin().read_line(&mut input)?;
1332
1333        if !input.trim().eq_ignore_ascii_case("y") {
1334            println!("Aborted.");
1335            return Ok(());
1336        }
1337    }
1338
1339    fs::remove_file(&path)?;
1340
1341    // Try to remove parent directory if empty
1342    if let Some(parent) = path.parent() {
1343        let _ = fs::remove_dir(parent);
1344    }
1345
1346    println!("Removed {}.", model.display_name());
1347    Ok(())
1348}
1349
1350/// Handle model verification
1351pub fn handle_models_verify(config: &CliConfig, args: ModelsVerifyArgs) -> Result<()> {
1352    let models_to_verify: Vec<LlmModel> = match args.model {
1353        Some(m) => vec![m],
1354        None => LlmModel::all()
1355            .filter(|m| is_llm_model_installed(config, *m))
1356            .collect(),
1357    };
1358
1359    if models_to_verify.is_empty() {
1360        println!("No LLM models installed to verify.");
1361        return Ok(());
1362    }
1363
1364    let mut all_ok = true;
1365
1366    for model in models_to_verify {
1367        let path = llm_model_path(config, model);
1368        print!("Verifying {}... ", model.display_name());
1369        io::stdout().flush()?;
1370
1371        match verify_model_file(&path, model) {
1372            Ok(()) => println!("OK"),
1373            Err(err) => {
1374                println!("FAILED");
1375                eprintln!("  Error: {}", err);
1376                all_ok = false;
1377            }
1378        }
1379    }
1380
1381    if !all_ok {
1382        bail!("Some models failed verification.");
1383    }
1384
1385    Ok(())
1386}
1387
1388/// Verify a model file exists and has reasonable size
1389fn verify_model_file(path: &Path, model: LlmModel) -> Result<()> {
1390    if !path.exists() {
1391        bail!("Model file does not exist");
1392    }
1393
1394    let metadata = fs::metadata(path)?;
1395    let size = metadata.len();
1396
1397    // Check minimum size (at least 50% of expected)
1398    let min_size = model.expected_size_bytes() / 2;
1399    if size < min_size {
1400        bail!(
1401            "Model file too small ({:.2} GB, expected at least {:.2} GB)",
1402            size as f64 / 1_000_000_000.0,
1403            min_size as f64 / 1_000_000_000.0
1404        );
1405    }
1406
1407    // Check GGUF magic bytes
1408    let mut file = fs::File::open(path)?;
1409    let mut magic = [0u8; 4];
1410    io::Read::read_exact(&mut file, &mut magic)?;
1411
1412    // GGUF magic is "GGUF" (0x46554747)
1413    if &magic != b"GGUF" {
1414        bail!("Invalid GGUF file (bad magic bytes)");
1415    }
1416
1417    Ok(())
1418}
1419
1420/// Get the path to an installed model, or None if not installed
1421pub fn get_installed_model_path(config: &CliConfig, model: LlmModel) -> Option<PathBuf> {
1422    let path = llm_model_path(config, model);
1423    if path.exists() && path.is_file() {
1424        Some(path)
1425    } else {
1426        None
1427    }
1428}
1429
1430/// Get the default LLM model for enrichment
1431pub fn default_enrichment_model() -> LlmModel {
1432    LlmModel::Phi35Mini
1433}