Skip to main content

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 => {
377                "https://huggingface.co/dslim/distilbert-NER/resolve/main/onnx/model.onnx"
378            }
379        }
380    }
381
382    fn tokenizer_url(&self) -> &'static str {
383        match self {
384            NerModel::DistilbertNer => {
385                "https://huggingface.co/dslim/distilbert-NER/resolve/main/tokenizer.json"
386            }
387        }
388    }
389
390    fn local_dir_name(&self) -> &'static str {
391        match self {
392            NerModel::DistilbertNer => "distilbert-ner",
393        }
394    }
395
396    fn is_default(&self) -> bool {
397        matches!(self, NerModel::DistilbertNer)
398    }
399}
400
401// ============================================================================
402// Whisper Models (Audio Transcription)
403// ============================================================================
404
405/// Whisper models - now using Candle with auto-download from HuggingFace
406/// No manual installation needed, kept for backwards compatibility
407#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
408pub enum WhisperModel {
409    /// whisper-small-en: Good quality English transcription (auto-downloads via Candle)
410    #[value(name = "whisper-small-en")]
411    WhisperSmallEn,
412}
413
414// WhisperModel is kept for CLI argument compatibility but models auto-download via Candle
415
416// ============================================================================
417// External Models (API-based, no download required)
418// ============================================================================
419
420/// External embedding providers (API-based)
421struct ExternalEmbeddingProvider {
422    name: &'static str,
423    models: &'static [(&'static str, usize, &'static str)], // (model_name, dimensions, description)
424    env_var: &'static str,
425}
426
427const EXTERNAL_EMBEDDING_PROVIDERS: &[ExternalEmbeddingProvider] = &[
428    ExternalEmbeddingProvider {
429        name: "OpenAI",
430        models: &[
431            ("text-embedding-3-large", 3072, "Highest quality"),
432            ("text-embedding-3-small", 1536, "Good balance"),
433            ("text-embedding-ada-002", 1536, "Legacy"),
434        ],
435        env_var: "OPENAI_API_KEY",
436    },
437    ExternalEmbeddingProvider {
438        name: "Cohere",
439        models: &[
440            ("embed-english-v3.0", 1024, "English"),
441            ("embed-multilingual-v3.0", 1024, "Multilingual"),
442        ],
443        env_var: "COHERE_API_KEY",
444    },
445    ExternalEmbeddingProvider {
446        name: "Voyage",
447        models: &[
448            ("voyage-3", 1024, "Code & technical docs"),
449            ("voyage-3-lite", 512, "Lightweight"),
450        ],
451        env_var: "VOYAGE_API_KEY",
452    },
453];
454
455// ============================================================================
456// CLI Commands
457// ============================================================================
458
459/// Model management commands
460#[derive(Args)]
461pub struct ModelsArgs {
462    #[command(subcommand)]
463    pub command: ModelsCommand,
464}
465
466#[derive(Subcommand)]
467pub enum ModelsCommand {
468    /// Install an LLM model for enrichment
469    Install(ModelsInstallArgs),
470    /// List all available and installed models
471    List(ModelsListArgs),
472    /// Remove an installed model
473    Remove(ModelsRemoveArgs),
474    /// Verify model integrity
475    Verify(ModelsVerifyArgs),
476}
477
478#[derive(Args)]
479pub struct ModelsInstallArgs {
480    /// LLM model to install (phi-3.5-mini, phi-3.5-mini-q8)
481    #[arg(value_enum, group = "model_choice")]
482    pub model: Option<LlmModel>,
483
484    /// CLIP model to install for visual search (mobileclip-s2, mobileclip-s2-fp16, siglip-base)
485    #[arg(long, value_enum, group = "model_choice")]
486    pub clip: Option<ClipModel>,
487
488    /// NER model to install for Logic-Mesh entity extraction (gliner-int8)
489    #[arg(long, value_enum, group = "model_choice")]
490    pub ner: Option<NerModel>,
491
492    /// Whisper model (auto-downloads via Candle, no install needed)
493    #[arg(long, value_enum, group = "model_choice", hide = true)]
494    pub whisper: Option<WhisperModel>,
495
496    /// Force re-download even if already installed
497    #[arg(long, short)]
498    pub force: bool,
499}
500
501#[derive(Args)]
502pub struct ModelsListArgs {
503    /// Output in JSON format
504    #[arg(long)]
505    pub json: bool,
506
507    /// Show only a specific model type
508    #[arg(long, value_enum)]
509    pub model_type: Option<ModelType>,
510}
511
512#[derive(Debug, Clone, Copy, ValueEnum)]
513pub enum ModelType {
514    /// Embedding models for semantic search
515    Embedding,
516    /// Reranker models for result reranking
517    Reranker,
518    /// LLM models for local inference
519    Llm,
520    /// CLIP models for visual search
521    Clip,
522    /// NER models for Logic-Mesh entity extraction
523    Ner,
524    /// Whisper models for audio transcription
525    Whisper,
526    /// External API-based models
527    External,
528}
529
530#[derive(Args)]
531pub struct ModelsRemoveArgs {
532    /// Model to remove
533    #[arg(value_enum)]
534    pub model: LlmModel,
535
536    /// Skip confirmation prompt
537    #[arg(long, short)]
538    pub yes: bool,
539}
540
541#[derive(Args)]
542pub struct ModelsVerifyArgs {
543    /// Model to verify (verifies all if not specified)
544    #[arg(value_enum)]
545    pub model: Option<LlmModel>,
546}
547
548/// Information about an installed model
549#[derive(Debug, Clone, serde::Serialize)]
550pub struct InstalledModel {
551    pub name: String,
552    pub model_type: String,
553    pub path: PathBuf,
554    pub size_bytes: u64,
555    pub verified: bool,
556}
557
558// ============================================================================
559// Directory Helpers
560// ============================================================================
561
562/// Get the LLM models directory
563fn llm_models_dir(config: &CliConfig) -> PathBuf {
564    config.models_dir.join("llm")
565}
566
567/// Get the fastembed cache directory (for embeddings and rerankers)
568#[cfg(feature = "local-embeddings")]
569fn fastembed_cache_dir(config: &CliConfig) -> PathBuf {
570    config.models_dir.clone()
571}
572
573/// Get the path where a specific LLM model should be stored
574fn llm_model_path(config: &CliConfig, model: LlmModel) -> PathBuf {
575    llm_models_dir(config)
576        .join(model.local_dir_name())
577        .join(model.hf_filename())
578}
579
580/// Check if an LLM model is installed
581fn is_llm_model_installed(config: &CliConfig, model: LlmModel) -> bool {
582    let path = llm_model_path(config, model);
583    path.exists() && path.is_file()
584}
585
586/// Get the CLIP models directory
587fn clip_models_dir(config: &CliConfig) -> PathBuf {
588    config.models_dir.clone()
589}
590
591/// Get the path where a specific CLIP model vision encoder should be stored
592fn clip_vision_path(config: &CliConfig, model: ClipModel) -> PathBuf {
593    clip_models_dir(config).join(model.vision_filename())
594}
595
596/// Get the path where a specific CLIP model text encoder should be stored
597fn clip_text_path(config: &CliConfig, model: ClipModel) -> PathBuf {
598    clip_models_dir(config).join(model.text_filename())
599}
600
601/// Check if a CLIP model is fully installed (both vision and text encoders)
602fn is_clip_model_installed(config: &CliConfig, model: ClipModel) -> bool {
603    let vision_path = clip_vision_path(config, model);
604    let text_path = clip_text_path(config, model);
605    vision_path.exists() && vision_path.is_file() && text_path.exists() && text_path.is_file()
606}
607
608/// Check if a CLIP model is partially installed
609fn clip_model_status(config: &CliConfig, model: ClipModel) -> (&'static str, bool, bool) {
610    let has_vision = clip_vision_path(config, model).exists();
611    let has_text = clip_text_path(config, model).exists();
612    let status = match (has_vision, has_text) {
613        (true, true) => "✓ installed",
614        (true, false) => "⚠ partial (missing text)",
615        (false, true) => "⚠ partial (missing vision)",
616        (false, false) => "○ available",
617    };
618    (status, has_vision, has_text)
619}
620
621/// Get the NER models directory
622fn ner_models_dir(config: &CliConfig) -> PathBuf {
623    config.models_dir.clone()
624}
625
626/// Get the path where a specific NER model should be stored
627fn ner_model_path(config: &CliConfig, model: NerModel) -> PathBuf {
628    ner_models_dir(config)
629        .join(model.local_dir_name())
630        .join("model.onnx")
631}
632
633/// Get the path where a specific NER tokenizer should be stored
634fn ner_tokenizer_path(config: &CliConfig, model: NerModel) -> PathBuf {
635    ner_models_dir(config)
636        .join(model.local_dir_name())
637        .join("tokenizer.json")
638}
639
640/// Check if a NER model is fully installed (both model and tokenizer)
641fn is_ner_model_installed(config: &CliConfig, model: NerModel) -> bool {
642    let model_path = ner_model_path(config, model);
643    let tokenizer_path = ner_tokenizer_path(config, model);
644    model_path.exists()
645        && model_path.is_file()
646        && tokenizer_path.exists()
647        && tokenizer_path.is_file()
648}
649
650/// Check NER model install status
651fn ner_model_status(config: &CliConfig, model: NerModel) -> (&'static str, bool, bool) {
652    let has_model = ner_model_path(config, model).exists();
653    let has_tokenizer = ner_tokenizer_path(config, model).exists();
654    let status = match (has_model, has_tokenizer) {
655        (true, true) => "✓ installed",
656        (true, false) => "⚠ partial (missing tokenizer)",
657        (false, true) => "⚠ partial (missing model)",
658        (false, false) => "○ available",
659    };
660    (status, has_model, has_tokenizer)
661}
662
663// Note: Whisper models now auto-download from HuggingFace via Candle.
664// No manual installation helpers are needed - the models are cached in ~/.cache/huggingface/hub/
665
666/// Scan fastembed cache for installed embedding/reranker models
667#[cfg(feature = "local-embeddings")]
668fn scan_fastembed_cache(config: &CliConfig) -> Vec<(String, PathBuf, u64)> {
669    let cache_dir = fastembed_cache_dir(config);
670    let mut installed = Vec::new();
671
672    if let Ok(entries) = fs::read_dir(&cache_dir) {
673        for entry in entries.flatten() {
674            let path = entry.path();
675            if path.is_dir() {
676                let name = path.file_name().unwrap_or_default().to_string_lossy();
677                // fastembed caches models in directories like "models--BAAI--bge-small-en-v1.5"
678                if name.starts_with("models--") {
679                    let size = dir_size(&path).unwrap_or(0);
680                    let model_name = name.replace("models--", "").replace("--", "/");
681                    installed.push((model_name, path, size));
682                }
683            }
684        }
685    }
686
687    installed
688}
689
690/// Stub for scan_fastembed_cache when local-embeddings is disabled
691#[cfg(not(feature = "local-embeddings"))]
692fn scan_fastembed_cache(_config: &CliConfig) -> Vec<(String, PathBuf, u64)> {
693    Vec::new()
694}
695
696/// Calculate directory size recursively
697fn dir_size(path: &Path) -> io::Result<u64> {
698    let mut size = 0;
699    if path.is_dir() {
700        for entry in fs::read_dir(path)? {
701            let entry = entry?;
702            let path = entry.path();
703            if path.is_dir() {
704                size += dir_size(&path)?;
705            } else {
706                size += entry.metadata()?.len();
707            }
708        }
709    }
710    Ok(size)
711}
712
713// ============================================================================
714// Command Handlers
715// ============================================================================
716
717/// Handle the models command
718pub fn handle_models(config: &CliConfig, args: ModelsArgs) -> Result<()> {
719    match args.command {
720        ModelsCommand::Install(install_args) => handle_models_install(config, install_args),
721        ModelsCommand::List(list_args) => handle_models_list(config, list_args),
722        ModelsCommand::Remove(remove_args) => handle_models_remove(config, remove_args),
723        ModelsCommand::Verify(verify_args) => handle_models_verify(config, verify_args),
724    }
725}
726
727/// Handle model installation
728pub fn handle_models_install(config: &CliConfig, args: ModelsInstallArgs) -> Result<()> {
729    // Check if CLIP model is being installed
730    if let Some(clip_model) = args.clip {
731        return handle_clip_install(config, clip_model, args.force);
732    }
733
734    // Check if NER model is being installed
735    if let Some(ner_model) = args.ner {
736        return handle_ner_install(config, ner_model, args.force);
737    }
738
739    // Whisper models auto-download via Candle, no manual install needed
740    if args.whisper.is_some() {
741        println!("ℹ️  Whisper models now auto-download from HuggingFace on first use.");
742        println!("   No manual installation required!");
743        println!();
744        println!("   Just use: memvid put file.mv2 --input audio.mp3 --transcribe");
745        println!();
746        println!("   The model will download automatically (~244 MB for whisper-small-en).");
747        return Ok(());
748    }
749
750    // Check if LLM model is being installed
751    if let Some(llm_model) = args.model {
752        return handle_llm_install(config, llm_model, args.force);
753    }
754
755    // Neither specified - show help
756    bail!(
757        "Please specify a model to install:\n\
758         \n\
759         LLM models (for local inference):\n\
760         \x20 memvid models install phi-3.5-mini\n\
761         \x20 memvid models install phi-3.5-mini-q8\n\
762         \n\
763         CLIP models (for visual search):\n\
764         \x20 memvid models install --clip mobileclip-s2\n\
765         \x20 memvid models install --clip mobileclip-s2-fp16\n\
766         \x20 memvid models install --clip siglip-base\n\
767         \n\
768         NER models (for Logic-Mesh entity extraction):\n\
769         \x20 memvid models install --ner distilbert-ner\n\
770         \n\
771         Note: Whisper models auto-download on first use (no install needed)"
772    );
773}
774
775/// Handle CLIP model installation
776fn handle_clip_install(config: &CliConfig, model: ClipModel, force: bool) -> Result<()> {
777    let vision_path = clip_vision_path(config, model);
778    let text_path = clip_text_path(config, model);
779
780    if is_clip_model_installed(config, model) && !force {
781        println!(
782            "{} is already installed at {}",
783            model.display_name(),
784            clip_models_dir(config).display()
785        );
786        println!("Use --force to re-download.");
787        return Ok(());
788    }
789
790    if config.offline {
791        bail!(
792            "Cannot install models while offline (MEMVID_OFFLINE=1). \
793             Run without MEMVID_OFFLINE to download the model."
794        );
795    }
796
797    // Create the directory structure
798    fs::create_dir_all(clip_models_dir(config))?;
799
800    println!("Installing {}...", model.display_name());
801    println!("Dimensions: {}", model.dimensions());
802    println!("Total size: {:.1} MB", model.total_size_mb());
803    println!();
804
805    // Download vision encoder
806    println!("Downloading vision encoder...");
807    download_file(model.vision_url(), &vision_path)?;
808
809    // Download text encoder
810    println!();
811    println!("Downloading text encoder...");
812    download_file(model.text_url(), &text_path)?;
813
814    // Calculate total size
815    let vision_size = fs::metadata(&vision_path).map(|m| m.len()).unwrap_or(0);
816    let text_size = fs::metadata(&text_path).map(|m| m.len()).unwrap_or(0);
817    let total_size = vision_size + text_size;
818
819    println!();
820    println!(
821        "Successfully installed {} ({:.1} MB)",
822        model.display_name(),
823        total_size as f64 / 1_000_000.0
824    );
825    println!("Vision encoder: {}", vision_path.display());
826    println!("Text encoder: {}", text_path.display());
827    println!();
828    println!("Usage:");
829    println!("  memvid put photos.mv2 --input ./images/ --clip");
830    println!("  memvid find photos.mv2 --query \"sunset over ocean\" --mode clip");
831
832    Ok(())
833}
834
835/// Handle NER model installation
836fn handle_ner_install(config: &CliConfig, model: NerModel, force: bool) -> Result<()> {
837    let model_path = ner_model_path(config, model);
838    let tokenizer_path = ner_tokenizer_path(config, model);
839
840    if is_ner_model_installed(config, model) && !force {
841        println!(
842            "{} is already installed at {}",
843            model.display_name(),
844            model_path.parent().unwrap_or(&model_path).display()
845        );
846        println!("Use --force to re-download.");
847        return Ok(());
848    }
849
850    if config.offline {
851        bail!(
852            "Cannot install models while offline (MEMVID_OFFLINE=1). \
853             Run without MEMVID_OFFLINE to download the model."
854        );
855    }
856
857    // Create the directory structure
858    if let Some(parent) = model_path.parent() {
859        fs::create_dir_all(parent)?;
860    }
861
862    println!("Installing {}...", model.display_name());
863    println!("Size: {:.1} MB", model.size_mb());
864    println!();
865
866    // Download model ONNX
867    println!("Downloading model...");
868    download_file(model.model_url(), &model_path)?;
869
870    // Download tokenizer
871    println!();
872    println!("Downloading tokenizer...");
873    download_file(model.tokenizer_url(), &tokenizer_path)?;
874
875    // Calculate total size
876    let model_size = fs::metadata(&model_path).map(|m| m.len()).unwrap_or(0);
877    let tokenizer_size = fs::metadata(&tokenizer_path).map(|m| m.len()).unwrap_or(0);
878    let total_size = model_size + tokenizer_size;
879
880    println!();
881    println!(
882        "Successfully installed {} ({:.1} MB)",
883        model.display_name(),
884        total_size as f64 / 1_000_000.0
885    );
886    println!("Model: {}", model_path.display());
887    println!("Tokenizer: {}", tokenizer_path.display());
888    println!();
889    println!("Usage:");
890    println!("  memvid enrich file.mv2 --logic-mesh");
891    println!("  memvid follow traverse file.mv2 --start \"John\" --link manager");
892
893    Ok(())
894}
895
896// Note: Whisper model installation is no longer needed - Candle auto-downloads from HuggingFace.
897// The --whisper install flag now just shows an informational message (see handle_models_install).
898
899/// Handle LLM model installation
900fn handle_llm_install(config: &CliConfig, model: LlmModel, force: bool) -> Result<()> {
901    let target_path = llm_model_path(config, model);
902
903    if is_llm_model_installed(config, model) && !force {
904        println!(
905            "{} is already installed at {}",
906            model.display_name(),
907            target_path.display()
908        );
909        println!("Use --force to re-download.");
910        return Ok(());
911    }
912
913    if config.offline {
914        bail!(
915            "Cannot install models while offline (MEMVID_OFFLINE=1). \
916             Run without MEMVID_OFFLINE to download the model."
917        );
918    }
919
920    // Create the directory structure
921    if let Some(parent) = target_path.parent() {
922        fs::create_dir_all(parent)?;
923    }
924
925    println!("Installing {}...", model.display_name());
926    println!("Repository: {}", model.hf_repo());
927    println!("File: {}", model.hf_filename());
928    println!(
929        "Expected size: {:.1} GB",
930        model.expected_size_bytes() as f64 / 1_000_000_000.0
931    );
932    println!();
933
934    // Download using curl
935    download_llm_model(model, &target_path)?;
936
937    // Verify the download
938    let metadata = fs::metadata(&target_path)?;
939    let size = metadata.len();
940
941    // Allow 10% variance in file size
942    let min_size = (model.expected_size_bytes() as f64 * 0.9) as u64;
943    let max_size = (model.expected_size_bytes() as f64 * 1.1) as u64;
944
945    if size < min_size || size > max_size {
946        eprintln!(
947            "Warning: Downloaded file size ({:.2} GB) differs significantly from expected ({:.2} GB)",
948            size as f64 / 1_000_000_000.0,
949            model.expected_size_bytes() as f64 / 1_000_000_000.0
950        );
951    }
952
953    println!();
954    println!(
955        "Successfully installed {} ({:.2} GB)",
956        model.display_name(),
957        size as f64 / 1_000_000_000.0
958    );
959    println!("Location: {}", target_path.display());
960
961    Ok(())
962}
963
964/// Download a file from a URL using curl
965fn download_file(url: &str, target_path: &Path) -> Result<()> {
966    println!("URL: {}", url);
967
968    let status = std::process::Command::new("curl")
969        .args([
970            "-L",             // Follow redirects
971            "--progress-bar", // Show progress bar
972            "-o",
973            target_path
974                .to_str()
975                .ok_or_else(|| anyhow!("Invalid target path"))?,
976            url,
977        ])
978        .status()?;
979
980    if !status.success() {
981        // Clean up partial download
982        let _ = fs::remove_file(target_path);
983        bail!("Download failed. Please check your internet connection and try again.");
984    }
985
986    Ok(())
987}
988
989/// Download an LLM model from Hugging Face
990fn download_llm_model(model: LlmModel, target_path: &Path) -> Result<()> {
991    let url = format!(
992        "https://huggingface.co/{}/resolve/main/{}",
993        model.hf_repo(),
994        model.hf_filename()
995    );
996
997    println!("Downloading from Hugging Face...");
998    download_file(&url, target_path)
999}
1000
1001/// Handle listing models
1002pub fn handle_models_list(config: &CliConfig, args: ModelsListArgs) -> Result<()> {
1003    let fastembed_installed = scan_fastembed_cache(config);
1004
1005    if args.json {
1006        return handle_models_list_json(config, &fastembed_installed);
1007    }
1008
1009    // Check which sections to show
1010    let show_all = args.model_type.is_none();
1011    let show_embedding = show_all || matches!(args.model_type, Some(ModelType::Embedding));
1012    let show_reranker = show_all || matches!(args.model_type, Some(ModelType::Reranker));
1013    let show_llm = show_all || matches!(args.model_type, Some(ModelType::Llm));
1014    let show_clip = show_all || matches!(args.model_type, Some(ModelType::Clip));
1015    let show_ner = show_all || matches!(args.model_type, Some(ModelType::Ner));
1016    let show_whisper = show_all || matches!(args.model_type, Some(ModelType::Whisper));
1017    let show_external = show_all || matches!(args.model_type, Some(ModelType::External));
1018
1019    println!("╔══════════════════════════════════════════════════════════════════╗");
1020    println!("║                       MEMVID MODEL CATALOG                       ║");
1021    println!("╚══════════════════════════════════════════════════════════════════╝");
1022    println!();
1023
1024    // Show models directory
1025    println!("Models Directory: {}", config.models_dir.display());
1026    println!();
1027
1028    // =========================================================================
1029    // Embedding Models
1030    // =========================================================================
1031    if show_embedding {
1032        println!("┌──────────────────────────────────────────────────────────────────┐");
1033        println!("│ 📊 EMBEDDING MODELS (Semantic Search)                            │");
1034        println!("├──────────────────────────────────────────────────────────────────┤");
1035
1036        for model in EmbeddingModel::all() {
1037            let is_installed = fastembed_installed.iter().any(|(name, _, _)| {
1038                name.contains(&model.hf_repo().replace("/", "--").replace("--", "/"))
1039            });
1040
1041            let status = if is_installed {
1042                "✓ installed"
1043            } else {
1044                "○ available"
1045            };
1046            let default_marker = if model.is_default() { " (default)" } else { "" };
1047
1048            println!(
1049                "│ {:20} {:4}D  {:>4} MB  {:15}{}",
1050                model.cli_name(),
1051                model.dimensions(),
1052                model.size_mb(),
1053                status,
1054                default_marker
1055            );
1056        }
1057
1058        println!("│                                                                  │");
1059        println!("│ Usage: memvid put mem.mv2 --input doc.pdf --embedding            │");
1060        println!("│        --embedding-model nomic                                   │");
1061        println!("└──────────────────────────────────────────────────────────────────┘");
1062        println!();
1063    }
1064
1065    // =========================================================================
1066    // Reranker Models
1067    // =========================================================================
1068    if show_reranker {
1069        println!("┌──────────────────────────────────────────────────────────────────┐");
1070        println!("│ 🔄 RERANKER MODELS (Result Reranking)                            │");
1071        println!("├──────────────────────────────────────────────────────────────────┤");
1072
1073        for model in RerankerModel::all() {
1074            let is_installed = fastembed_installed.iter().any(|(name, _, _)| {
1075                let repo = model.hf_repo();
1076                name.to_lowercase()
1077                    .contains(&repo.to_lowercase().replace("/", "--").replace("--", "/"))
1078                    || name
1079                        .to_lowercase()
1080                        .contains(&repo.split('/').last().unwrap_or("").to_lowercase())
1081            });
1082
1083            let status = if is_installed {
1084                "✓ installed"
1085            } else {
1086                "○ available"
1087            };
1088            let default_marker = if model.is_default() { " (default)" } else { "" };
1089
1090            println!(
1091                "│ {:25} {:>4} MB  {:12}  {:12}{}",
1092                model.cli_name(),
1093                model.size_mb(),
1094                model.language(),
1095                status,
1096                default_marker
1097            );
1098        }
1099
1100        println!("│                                                                  │");
1101        println!("│ Reranking is automatic in hybrid search mode (--mode auto)       │");
1102        println!("└──────────────────────────────────────────────────────────────────┘");
1103        println!();
1104    }
1105
1106    // =========================================================================
1107    // LLM Models
1108    // =========================================================================
1109    if show_llm {
1110        println!("┌──────────────────────────────────────────────────────────────────┐");
1111        println!("│ 🤖 LLM MODELS (Local Inference)                                  │");
1112        println!("├──────────────────────────────────────────────────────────────────┤");
1113
1114        for model in LlmModel::all() {
1115            let is_installed = is_llm_model_installed(config, model);
1116            let status = if is_installed {
1117                "✓ installed"
1118            } else {
1119                "○ available"
1120            };
1121            let default_marker = if model.is_default() { " (default)" } else { "" };
1122
1123            println!(
1124                "│ {:20} {:>5.1} GB  {:15}{}",
1125                model.cli_name(),
1126                model.expected_size_bytes() as f64 / 1_000_000_000.0,
1127                status,
1128                default_marker
1129            );
1130
1131            if is_installed {
1132                println!("│   Path: {}", llm_model_path(config, model).display());
1133            }
1134        }
1135
1136        println!("│                                                                  │");
1137        println!("│ Install: memvid models install phi-3.5-mini                      │");
1138        println!("│ Usage:   memvid ask file.mv2 --question \"...\" --model candle:phi │");
1139        println!("└──────────────────────────────────────────────────────────────────┘");
1140        println!();
1141    }
1142
1143    // =========================================================================
1144    // CLIP Models (Visual Search)
1145    // =========================================================================
1146    if show_clip {
1147        println!("┌──────────────────────────────────────────────────────────────────┐");
1148        println!("│ 🖼️  CLIP MODELS (Visual Search)                                   │");
1149        println!("├──────────────────────────────────────────────────────────────────┤");
1150
1151        for model in ClipModel::all() {
1152            let (status, _, _) = clip_model_status(config, model);
1153            let default_marker = if model.is_default() { " (default)" } else { "" };
1154
1155            println!(
1156                "│ {:20} {:4}D  {:>6.1} MB  {:15}{}",
1157                model.cli_name(),
1158                model.dimensions(),
1159                model.total_size_mb(),
1160                status,
1161                default_marker
1162            );
1163        }
1164
1165        println!("│                                                                  │");
1166        println!("│ Install: memvid models install --clip mobileclip-s2              │");
1167        println!("│ Usage:   memvid put photos.mv2 --input ./images/ --clip          │");
1168        println!("│          memvid find photos.mv2 --query \"sunset\" --mode clip     │");
1169        println!("└──────────────────────────────────────────────────────────────────┘");
1170        println!();
1171    }
1172
1173    // =========================================================================
1174    // NER Models (Logic-Mesh Entity Extraction)
1175    // =========================================================================
1176    if show_ner {
1177        println!("┌──────────────────────────────────────────────────────────────────┐");
1178        println!("│ 🔗 NER MODELS (Logic-Mesh Entity Extraction)                      │");
1179        println!("├──────────────────────────────────────────────────────────────────┤");
1180
1181        for model in NerModel::all() {
1182            let (status, _, _) = ner_model_status(config, model);
1183            let default_marker = if model.is_default() { " (default)" } else { "" };
1184
1185            println!(
1186                "│ {:20} {:>6.1} MB  {:15}{}",
1187                model.cli_name(),
1188                model.size_mb(),
1189                status,
1190                default_marker
1191            );
1192        }
1193
1194        println!("│                                                                  │");
1195        println!("│ Install: memvid models install --ner distilbert-ner              │");
1196        println!("│ Usage:   memvid put file.mv2 --input doc.txt --logic-mesh        │");
1197        println!("│          memvid follow traverse file.mv2 --start \"John\"          │");
1198        println!("└──────────────────────────────────────────────────────────────────┘");
1199        println!();
1200    }
1201
1202    // =========================================================================
1203    // Whisper Models (Audio Transcription) - Using Candle (auto-download)
1204    // =========================================================================
1205    if show_whisper {
1206        println!("┌──────────────────────────────────────────────────────────────────┐");
1207        println!("│ 🎙️  WHISPER MODELS (Audio Transcription via Candle)               │");
1208        println!("├──────────────────────────────────────────────────────────────────┤");
1209        println!("│ whisper-small-en          244 MB  Auto-download    (default)     │");
1210        println!("│ whisper-small             244 MB  Auto-download    multilingual  │");
1211        println!("│ whisper-tiny-en            75 MB  Auto-download    fastest       │");
1212        println!("│ whisper-base-en           145 MB  Auto-download                  │");
1213        println!("│                                                                  │");
1214        println!("│ Models download automatically from HuggingFace on first use.     │");
1215        println!("│ GPU acceleration: --features metal (Mac) or --features cuda      │");
1216        println!("│                                                                  │");
1217        println!("│ Usage: memvid put file.mv2 --input audio.mp3 --transcribe        │");
1218        println!("└──────────────────────────────────────────────────────────────────┘");
1219        println!();
1220    }
1221
1222    // =========================================================================
1223    // External Models (API-based)
1224    // =========================================================================
1225    if show_external {
1226        println!("┌──────────────────────────────────────────────────────────────────┐");
1227        println!("│ ☁️  EXTERNAL MODELS (API-based, no download required)             │");
1228        println!("├──────────────────────────────────────────────────────────────────┤");
1229
1230        for provider in EXTERNAL_EMBEDDING_PROVIDERS {
1231            let api_key_set = std::env::var(provider.env_var).is_ok();
1232            let key_status = if api_key_set {
1233                format!("{} ✓", provider.env_var)
1234            } else {
1235                format!("{} ○", provider.env_var)
1236            };
1237
1238            println!("│ {} ({}):", provider.name, key_status);
1239
1240            for (model_name, dim, desc) in provider.models.iter() {
1241                println!("│   {:30} {:4}D  {}", model_name, dim, desc);
1242            }
1243            println!("│");
1244        }
1245
1246        println!("│ Usage: export OPENAI_API_KEY=sk-...                              │");
1247        println!("│        memvid put mem.mv2 --input doc.pdf --embedding            │");
1248        println!("│        --embedding-model openai-small                            │");
1249        println!("└──────────────────────────────────────────────────────────────────┘");
1250        println!();
1251    }
1252
1253    // =========================================================================
1254    // Installed Models Summary
1255    // =========================================================================
1256    if !fastembed_installed.is_empty() {
1257        println!("┌──────────────────────────────────────────────────────────────────┐");
1258        println!(
1259            "│ 📦 INSTALLED MODELS (cached in {})     │",
1260            config.models_dir.display()
1261        );
1262        println!("├──────────────────────────────────────────────────────────────────┤");
1263
1264        let mut total_size: u64 = 0;
1265
1266        for (name, _path, size) in &fastembed_installed {
1267            total_size += size;
1268            println!(
1269                "│ {:40} {:>8.1} MB",
1270                if name.len() > 40 {
1271                    format!("{}...", &name[..37])
1272                } else {
1273                    name.clone()
1274                },
1275                *size as f64 / 1_000_000.0
1276            );
1277        }
1278
1279        // Add LLM models
1280        for model in LlmModel::all() {
1281            if is_llm_model_installed(config, model) {
1282                let path = llm_model_path(config, model);
1283                if let Ok(meta) = fs::metadata(&path) {
1284                    total_size += meta.len();
1285                    println!(
1286                        "│ {:40} {:>8.1} MB",
1287                        model.display_name(),
1288                        meta.len() as f64 / 1_000_000.0
1289                    );
1290                }
1291            }
1292        }
1293
1294        println!("├──────────────────────────────────────────────────────────────────┤");
1295        println!("│ Total: {:>55.1} MB │", total_size as f64 / 1_000_000.0);
1296        println!("└──────────────────────────────────────────────────────────────────┘");
1297        println!();
1298    }
1299
1300    // =========================================================================
1301    // Quick Help
1302    // =========================================================================
1303    println!("╔══════════════════════════════════════════════════════════════════╗");
1304    println!("║ QUICK REFERENCE                                                  ║");
1305    println!("╟──────────────────────────────────────────────────────────────────╢");
1306    println!("║ memvid models list                    List all models            ║");
1307    println!("║ memvid models list --model-type llm   List only LLM models       ║");
1308    println!("║ memvid models install phi-3.5-mini    Install LLM model          ║");
1309    println!("║ memvid models remove phi-3.5-mini     Remove LLM model           ║");
1310    println!("║ memvid models verify                  Verify installed models    ║");
1311    println!("╚══════════════════════════════════════════════════════════════════╝");
1312
1313    Ok(())
1314}
1315
1316/// Handle JSON output for models list
1317fn handle_models_list_json(
1318    config: &CliConfig,
1319    fastembed_installed: &[(String, PathBuf, u64)],
1320) -> Result<()> {
1321    let output = serde_json::json!({
1322        "models_dir": config.models_dir,
1323        "embedding_models": EmbeddingModel::all().map(|m| {
1324            let is_installed = fastembed_installed
1325                .iter()
1326                .any(|(name, _, _)| name.contains(m.hf_repo()));
1327            serde_json::json!({
1328                "name": m.cli_name(),
1329                "display_name": m.display_name(),
1330                "dimensions": m.dimensions(),
1331                "size_mb": m.size_mb(),
1332                "hf_repo": m.hf_repo(),
1333                "installed": is_installed,
1334                "is_default": m.is_default(),
1335            })
1336        }).collect::<Vec<_>>(),
1337        "reranker_models": RerankerModel::all().map(|m| {
1338            serde_json::json!({
1339                "name": m.cli_name(),
1340                "display_name": m.display_name(),
1341                "size_mb": m.size_mb(),
1342                "hf_repo": m.hf_repo(),
1343                "language": m.language(),
1344                "is_default": m.is_default(),
1345            })
1346        }).collect::<Vec<_>>(),
1347        "llm_models": LlmModel::all().map(|m| {
1348            serde_json::json!({
1349                "name": m.cli_name(),
1350                "display_name": m.display_name(),
1351                "size_gb": m.expected_size_bytes() as f64 / 1_000_000_000.0,
1352                "hf_repo": m.hf_repo(),
1353                "installed": is_llm_model_installed(config, m),
1354                "path": if is_llm_model_installed(config, m) {
1355                    Some(llm_model_path(config, m))
1356                } else {
1357                    None
1358                },
1359                "is_default": m.is_default(),
1360            })
1361        }).collect::<Vec<_>>(),
1362        "external_providers": EXTERNAL_EMBEDDING_PROVIDERS.iter().map(|p| {
1363            serde_json::json!({
1364                "name": p.name,
1365                "env_var": p.env_var,
1366                "configured": std::env::var(p.env_var).is_ok(),
1367                "models": p.models.iter().map(|(name, dim, desc)| {
1368                    serde_json::json!({
1369                        "name": name,
1370                        "dimensions": dim,
1371                        "description": desc,
1372                    })
1373                }).collect::<Vec<_>>(),
1374            })
1375        }).collect::<Vec<_>>(),
1376        "installed_cache": fastembed_installed.iter().map(|(name, path, size)| {
1377            serde_json::json!({
1378                "name": name,
1379                "path": path,
1380                "size_bytes": size,
1381            })
1382        }).collect::<Vec<_>>(),
1383    });
1384
1385    println!("{}", serde_json::to_string_pretty(&output)?);
1386    Ok(())
1387}
1388
1389/// Handle model removal
1390pub fn handle_models_remove(config: &CliConfig, args: ModelsRemoveArgs) -> Result<()> {
1391    let model = args.model;
1392    let path = llm_model_path(config, model);
1393
1394    if !path.exists() {
1395        println!("{} is not installed.", model.display_name());
1396        return Ok(());
1397    }
1398
1399    if !args.yes {
1400        print!(
1401            "Remove {} ({})? [y/N] ",
1402            model.display_name(),
1403            path.display()
1404        );
1405        io::stdout().flush()?;
1406
1407        let mut input = String::new();
1408        io::stdin().read_line(&mut input)?;
1409
1410        if !input.trim().eq_ignore_ascii_case("y") {
1411            println!("Aborted.");
1412            return Ok(());
1413        }
1414    }
1415
1416    fs::remove_file(&path)?;
1417
1418    // Try to remove parent directory if empty
1419    if let Some(parent) = path.parent() {
1420        let _ = fs::remove_dir(parent);
1421    }
1422
1423    println!("Removed {}.", model.display_name());
1424    Ok(())
1425}
1426
1427/// Handle model verification
1428pub fn handle_models_verify(config: &CliConfig, args: ModelsVerifyArgs) -> Result<()> {
1429    let models_to_verify: Vec<LlmModel> = match args.model {
1430        Some(m) => vec![m],
1431        None => LlmModel::all()
1432            .filter(|m| is_llm_model_installed(config, *m))
1433            .collect(),
1434    };
1435
1436    if models_to_verify.is_empty() {
1437        println!("No LLM models installed to verify.");
1438        return Ok(());
1439    }
1440
1441    let mut all_ok = true;
1442
1443    for model in models_to_verify {
1444        let path = llm_model_path(config, model);
1445        print!("Verifying {}... ", model.display_name());
1446        io::stdout().flush()?;
1447
1448        match verify_model_file(&path, model) {
1449            Ok(()) => println!("OK"),
1450            Err(err) => {
1451                println!("FAILED");
1452                eprintln!("  Error: {}", err);
1453                all_ok = false;
1454            }
1455        }
1456    }
1457
1458    if !all_ok {
1459        bail!("Some models failed verification.");
1460    }
1461
1462    Ok(())
1463}
1464
1465/// Verify a model file exists and has reasonable size
1466fn verify_model_file(path: &Path, model: LlmModel) -> Result<()> {
1467    if !path.exists() {
1468        bail!("Model file does not exist");
1469    }
1470
1471    let metadata = fs::metadata(path)?;
1472    let size = metadata.len();
1473
1474    // Check minimum size (at least 50% of expected)
1475    let min_size = model.expected_size_bytes() / 2;
1476    if size < min_size {
1477        bail!(
1478            "Model file too small ({:.2} GB, expected at least {:.2} GB)",
1479            size as f64 / 1_000_000_000.0,
1480            min_size as f64 / 1_000_000_000.0
1481        );
1482    }
1483
1484    // Check GGUF magic bytes
1485    let mut file = fs::File::open(path)?;
1486    let mut magic = [0u8; 4];
1487    io::Read::read_exact(&mut file, &mut magic)?;
1488
1489    // GGUF magic is "GGUF" (0x46554747)
1490    if &magic != b"GGUF" {
1491        bail!("Invalid GGUF file (bad magic bytes)");
1492    }
1493
1494    Ok(())
1495}
1496
1497/// Get the path to an installed model, or None if not installed
1498pub fn get_installed_model_path(config: &CliConfig, model: LlmModel) -> Option<PathBuf> {
1499    let path = llm_model_path(config, model);
1500    if path.exists() && path.is_file() {
1501        Some(path)
1502    } else {
1503        None
1504    }
1505}
1506
1507/// Get the default LLM model for enrichment
1508pub fn default_enrichment_model() -> LlmModel {
1509    LlmModel::Phi35Mini
1510}