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// External Models (API-based, no download required)
245// ============================================================================
246
247/// External embedding providers (API-based)
248struct ExternalEmbeddingProvider {
249    name: &'static str,
250    models: &'static [(&'static str, usize, &'static str)], // (model_name, dimensions, description)
251    env_var: &'static str,
252}
253
254const EXTERNAL_EMBEDDING_PROVIDERS: &[ExternalEmbeddingProvider] = &[
255    ExternalEmbeddingProvider {
256        name: "OpenAI",
257        models: &[
258            ("text-embedding-3-large", 3072, "Highest quality"),
259            ("text-embedding-3-small", 1536, "Good balance"),
260            ("text-embedding-ada-002", 1536, "Legacy"),
261        ],
262        env_var: "OPENAI_API_KEY",
263    },
264    ExternalEmbeddingProvider {
265        name: "Cohere",
266        models: &[
267            ("embed-english-v3.0", 1024, "English"),
268            ("embed-multilingual-v3.0", 1024, "Multilingual"),
269        ],
270        env_var: "COHERE_API_KEY",
271    },
272    ExternalEmbeddingProvider {
273        name: "Voyage",
274        models: &[
275            ("voyage-3", 1024, "Code & technical docs"),
276            ("voyage-3-lite", 512, "Lightweight"),
277        ],
278        env_var: "VOYAGE_API_KEY",
279    },
280];
281
282// ============================================================================
283// CLI Commands
284// ============================================================================
285
286/// Model management commands
287#[derive(Args)]
288pub struct ModelsArgs {
289    #[command(subcommand)]
290    pub command: ModelsCommand,
291}
292
293#[derive(Subcommand)]
294pub enum ModelsCommand {
295    /// Install an LLM model for enrichment
296    Install(ModelsInstallArgs),
297    /// List all available and installed models
298    List(ModelsListArgs),
299    /// Remove an installed model
300    Remove(ModelsRemoveArgs),
301    /// Verify model integrity
302    Verify(ModelsVerifyArgs),
303}
304
305#[derive(Args)]
306pub struct ModelsInstallArgs {
307    /// Model to install
308    #[arg(value_enum)]
309    pub model: LlmModel,
310
311    /// Force re-download even if already installed
312    #[arg(long, short)]
313    pub force: bool,
314}
315
316#[derive(Args)]
317pub struct ModelsListArgs {
318    /// Output in JSON format
319    #[arg(long)]
320    pub json: bool,
321
322    /// Show only a specific model type
323    #[arg(long, value_enum)]
324    pub model_type: Option<ModelType>,
325}
326
327#[derive(Debug, Clone, Copy, ValueEnum)]
328pub enum ModelType {
329    /// Embedding models for semantic search
330    Embedding,
331    /// Reranker models for result reranking
332    Reranker,
333    /// LLM models for local inference
334    Llm,
335    /// External API-based models
336    External,
337}
338
339#[derive(Args)]
340pub struct ModelsRemoveArgs {
341    /// Model to remove
342    #[arg(value_enum)]
343    pub model: LlmModel,
344
345    /// Skip confirmation prompt
346    #[arg(long, short)]
347    pub yes: bool,
348}
349
350#[derive(Args)]
351pub struct ModelsVerifyArgs {
352    /// Model to verify (verifies all if not specified)
353    #[arg(value_enum)]
354    pub model: Option<LlmModel>,
355}
356
357/// Information about an installed model
358#[derive(Debug, Clone, serde::Serialize)]
359pub struct InstalledModel {
360    pub name: String,
361    pub model_type: String,
362    pub path: PathBuf,
363    pub size_bytes: u64,
364    pub verified: bool,
365}
366
367// ============================================================================
368// Directory Helpers
369// ============================================================================
370
371/// Get the LLM models directory
372fn llm_models_dir(config: &CliConfig) -> PathBuf {
373    config.models_dir.join("llm")
374}
375
376/// Get the fastembed cache directory (for embeddings and rerankers)
377fn fastembed_cache_dir(config: &CliConfig) -> PathBuf {
378    config.models_dir.clone()
379}
380
381/// Get the path where a specific LLM model should be stored
382fn llm_model_path(config: &CliConfig, model: LlmModel) -> PathBuf {
383    llm_models_dir(config)
384        .join(model.local_dir_name())
385        .join(model.hf_filename())
386}
387
388/// Check if an LLM model is installed
389fn is_llm_model_installed(config: &CliConfig, model: LlmModel) -> bool {
390    let path = llm_model_path(config, model);
391    path.exists() && path.is_file()
392}
393
394/// Scan fastembed cache for installed embedding/reranker models
395fn scan_fastembed_cache(config: &CliConfig) -> Vec<(String, PathBuf, u64)> {
396    let cache_dir = fastembed_cache_dir(config);
397    let mut installed = Vec::new();
398
399    if let Ok(entries) = fs::read_dir(&cache_dir) {
400        for entry in entries.flatten() {
401            let path = entry.path();
402            if path.is_dir() {
403                let name = path.file_name().unwrap_or_default().to_string_lossy();
404                // fastembed caches models in directories like "models--BAAI--bge-small-en-v1.5"
405                if name.starts_with("models--") {
406                    let size = dir_size(&path).unwrap_or(0);
407                    let model_name = name.replace("models--", "").replace("--", "/");
408                    installed.push((model_name, path, size));
409                }
410            }
411        }
412    }
413
414    installed
415}
416
417/// Calculate directory size recursively
418fn dir_size(path: &Path) -> io::Result<u64> {
419    let mut size = 0;
420    if path.is_dir() {
421        for entry in fs::read_dir(path)? {
422            let entry = entry?;
423            let path = entry.path();
424            if path.is_dir() {
425                size += dir_size(&path)?;
426            } else {
427                size += entry.metadata()?.len();
428            }
429        }
430    }
431    Ok(size)
432}
433
434// ============================================================================
435// Command Handlers
436// ============================================================================
437
438/// Handle the models command
439pub fn handle_models(config: &CliConfig, args: ModelsArgs) -> Result<()> {
440    match args.command {
441        ModelsCommand::Install(install_args) => handle_models_install(config, install_args),
442        ModelsCommand::List(list_args) => handle_models_list(config, list_args),
443        ModelsCommand::Remove(remove_args) => handle_models_remove(config, remove_args),
444        ModelsCommand::Verify(verify_args) => handle_models_verify(config, verify_args),
445    }
446}
447
448/// Handle model installation
449pub fn handle_models_install(config: &CliConfig, args: ModelsInstallArgs) -> Result<()> {
450    let model = args.model;
451    let target_path = llm_model_path(config, model);
452
453    if is_llm_model_installed(config, model) && !args.force {
454        println!(
455            "{} is already installed at {}",
456            model.display_name(),
457            target_path.display()
458        );
459        println!("Use --force to re-download.");
460        return Ok(());
461    }
462
463    if config.offline {
464        bail!(
465            "Cannot install models while offline (MEMVID_OFFLINE=1). \
466             Run without MEMVID_OFFLINE to download the model."
467        );
468    }
469
470    // Create the directory structure
471    if let Some(parent) = target_path.parent() {
472        fs::create_dir_all(parent)?;
473    }
474
475    println!("Installing {}...", model.display_name());
476    println!("Repository: {}", model.hf_repo());
477    println!("File: {}", model.hf_filename());
478    println!(
479        "Expected size: {:.1} GB",
480        model.expected_size_bytes() as f64 / 1_000_000_000.0
481    );
482    println!();
483
484    // Download using curl
485    download_model(model, &target_path)?;
486
487    // Verify the download
488    let metadata = fs::metadata(&target_path)?;
489    let size = metadata.len();
490
491    // Allow 10% variance in file size
492    let min_size = (model.expected_size_bytes() as f64 * 0.9) as u64;
493    let max_size = (model.expected_size_bytes() as f64 * 1.1) as u64;
494
495    if size < min_size || size > max_size {
496        eprintln!(
497            "Warning: Downloaded file size ({:.2} GB) differs significantly from expected ({:.2} GB)",
498            size as f64 / 1_000_000_000.0,
499            model.expected_size_bytes() as f64 / 1_000_000_000.0
500        );
501    }
502
503    println!();
504    println!(
505        "Successfully installed {} ({:.2} GB)",
506        model.display_name(),
507        size as f64 / 1_000_000_000.0
508    );
509    println!("Location: {}", target_path.display());
510
511    Ok(())
512}
513
514/// Download a model from Hugging Face
515fn download_model(model: LlmModel, target_path: &Path) -> Result<()> {
516    let url = format!(
517        "https://huggingface.co/{}/resolve/main/{}",
518        model.hf_repo(),
519        model.hf_filename()
520    );
521
522    println!("Downloading from Hugging Face...");
523    println!("URL: {}", url);
524    println!();
525
526    let status = std::process::Command::new("curl")
527        .args([
528            "-L",             // Follow redirects
529            "--progress-bar", // Show progress bar
530            "-o",
531            target_path
532                .to_str()
533                .ok_or_else(|| anyhow!("Invalid target path"))?,
534            &url,
535        ])
536        .status()?;
537
538    if !status.success() {
539        // Clean up partial download
540        let _ = fs::remove_file(target_path);
541        bail!("Download failed. Please check your internet connection and try again.");
542    }
543
544    Ok(())
545}
546
547/// Handle listing models
548pub fn handle_models_list(config: &CliConfig, args: ModelsListArgs) -> Result<()> {
549    let fastembed_installed = scan_fastembed_cache(config);
550
551    if args.json {
552        return handle_models_list_json(config, &fastembed_installed);
553    }
554
555    // Check which sections to show
556    let show_all = args.model_type.is_none();
557    let show_embedding = show_all || matches!(args.model_type, Some(ModelType::Embedding));
558    let show_reranker = show_all || matches!(args.model_type, Some(ModelType::Reranker));
559    let show_llm = show_all || matches!(args.model_type, Some(ModelType::Llm));
560    let show_external = show_all || matches!(args.model_type, Some(ModelType::External));
561
562    println!("╔══════════════════════════════════════════════════════════════════╗");
563    println!("║                       MEMVID MODEL CATALOG                       ║");
564    println!("╚══════════════════════════════════════════════════════════════════╝");
565    println!();
566
567    // Show models directory
568    println!("Models Directory: {}", config.models_dir.display());
569    println!();
570
571    // =========================================================================
572    // Embedding Models
573    // =========================================================================
574    if show_embedding {
575        println!("┌──────────────────────────────────────────────────────────────────┐");
576        println!("│ 📊 EMBEDDING MODELS (Semantic Search)                            │");
577        println!("├──────────────────────────────────────────────────────────────────┤");
578
579        for model in EmbeddingModel::all() {
580            let is_installed = fastembed_installed
581                .iter()
582                .any(|(name, _, _)| name.contains(&model.hf_repo().replace("/", "--").replace("--", "/")));
583
584            let status = if is_installed { "✓ installed" } else { "○ available" };
585            let default_marker = if model.is_default() { " (default)" } else { "" };
586
587            println!(
588                "│ {:20} {:4}D  {:>4} MB  {:15}{}",
589                model.cli_name(),
590                model.dimensions(),
591                model.size_mb(),
592                status,
593                default_marker
594            );
595        }
596
597        println!("│                                                                  │");
598        println!("│ Usage: memvid put file.mv2 --input doc.pdf --embedding           │");
599        println!("│        memvid put file.mv2 --input doc.pdf --embedding-model nomic│");
600        println!("└──────────────────────────────────────────────────────────────────┘");
601        println!();
602    }
603
604    // =========================================================================
605    // Reranker Models
606    // =========================================================================
607    if show_reranker {
608        println!("┌──────────────────────────────────────────────────────────────────┐");
609        println!("│ 🔄 RERANKER MODELS (Result Reranking)                            │");
610        println!("├──────────────────────────────────────────────────────────────────┤");
611
612        for model in RerankerModel::all() {
613            let is_installed = fastembed_installed
614                .iter()
615                .any(|(name, _, _)| {
616                    let repo = model.hf_repo();
617                    name.to_lowercase().contains(&repo.to_lowercase().replace("/", "--").replace("--", "/"))
618                        || name.to_lowercase().contains(&repo.split('/').last().unwrap_or("").to_lowercase())
619                });
620
621            let status = if is_installed { "✓ installed" } else { "○ available" };
622            let default_marker = if model.is_default() { " (default)" } else { "" };
623
624            println!(
625                "│ {:25} {:>4} MB  {:12}  {:12}{}",
626                model.cli_name(),
627                model.size_mb(),
628                model.language(),
629                status,
630                default_marker
631            );
632        }
633
634        println!("│                                                                  │");
635        println!("│ Reranking is automatic in hybrid search mode (--mode auto)       │");
636        println!("└──────────────────────────────────────────────────────────────────┘");
637        println!();
638    }
639
640    // =========================================================================
641    // LLM Models
642    // =========================================================================
643    if show_llm {
644        println!("┌──────────────────────────────────────────────────────────────────┐");
645        println!("│ 🤖 LLM MODELS (Local Inference)                                  │");
646        println!("├──────────────────────────────────────────────────────────────────┤");
647
648        for model in LlmModel::all() {
649            let is_installed = is_llm_model_installed(config, model);
650            let status = if is_installed { "✓ installed" } else { "○ available" };
651            let default_marker = if model.is_default() { " (default)" } else { "" };
652
653            println!(
654                "│ {:20} {:>5.1} GB  {:15}{}",
655                model.cli_name(),
656                model.expected_size_bytes() as f64 / 1_000_000_000.0,
657                status,
658                default_marker
659            );
660
661            if is_installed {
662                println!("│   Path: {}", llm_model_path(config, model).display());
663            }
664        }
665
666        println!("│                                                                  │");
667        println!("│ Install: memvid models install phi-3.5-mini                      │");
668        println!("│ Usage:   memvid ask file.mv2 --question \"...\" --model candle:phi │");
669        println!("└──────────────────────────────────────────────────────────────────┘");
670        println!();
671    }
672
673    // =========================================================================
674    // External Models (API-based)
675    // =========================================================================
676    if show_external {
677        println!("┌──────────────────────────────────────────────────────────────────┐");
678        println!("│ ☁️  EXTERNAL MODELS (API-based, no download required)             │");
679        println!("├──────────────────────────────────────────────────────────────────┤");
680
681        for provider in EXTERNAL_EMBEDDING_PROVIDERS {
682            let api_key_set = std::env::var(provider.env_var).is_ok();
683            let key_status = if api_key_set {
684                format!("{} ✓", provider.env_var)
685            } else {
686                format!("{} ○", provider.env_var)
687            };
688
689            println!("│ {} ({}):", provider.name, key_status);
690
691            for (model_name, dim, desc) in provider.models.iter() {
692                println!("│   {:30} {:4}D  {}", model_name, dim, desc);
693            }
694            println!("│");
695        }
696
697        println!("│ Usage: export OPENAI_API_KEY=sk-...                              │");
698        println!("│        memvid put file.mv2 --input doc.pdf --embedding-model openai│");
699        println!("└──────────────────────────────────────────────────────────────────┘");
700        println!();
701    }
702
703    // =========================================================================
704    // Installed Models Summary
705    // =========================================================================
706    if !fastembed_installed.is_empty() {
707        println!("┌──────────────────────────────────────────────────────────────────┐");
708        println!("│ 📦 INSTALLED MODELS (cached in {})     │", config.models_dir.display());
709        println!("├──────────────────────────────────────────────────────────────────┤");
710
711        let mut total_size: u64 = 0;
712
713        for (name, _path, size) in &fastembed_installed {
714            total_size += size;
715            println!(
716                "│ {:40} {:>8.1} MB",
717                if name.len() > 40 {
718                    format!("{}...", &name[..37])
719                } else {
720                    name.clone()
721                },
722                *size as f64 / 1_000_000.0
723            );
724        }
725
726        // Add LLM models
727        for model in LlmModel::all() {
728            if is_llm_model_installed(config, model) {
729                let path = llm_model_path(config, model);
730                if let Ok(meta) = fs::metadata(&path) {
731                    total_size += meta.len();
732                    println!(
733                        "│ {:40} {:>8.1} MB",
734                        model.display_name(),
735                        meta.len() as f64 / 1_000_000.0
736                    );
737                }
738            }
739        }
740
741        println!("├──────────────────────────────────────────────────────────────────┤");
742        println!(
743            "│ Total: {:>55.1} MB │",
744            total_size as f64 / 1_000_000.0
745        );
746        println!("└──────────────────────────────────────────────────────────────────┘");
747        println!();
748    }
749
750    // =========================================================================
751    // Quick Help
752    // =========================================================================
753    println!("╔══════════════════════════════════════════════════════════════════╗");
754    println!("║ QUICK REFERENCE                                                  ║");
755    println!("╟──────────────────────────────────────────────────────────────────╢");
756    println!("║ memvid models list                    List all models            ║");
757    println!("║ memvid models list --model-type llm   List only LLM models       ║");
758    println!("║ memvid models install phi-3.5-mini    Install LLM model          ║");
759    println!("║ memvid models remove phi-3.5-mini     Remove LLM model           ║");
760    println!("║ memvid models verify                  Verify installed models    ║");
761    println!("╚══════════════════════════════════════════════════════════════════╝");
762
763    Ok(())
764}
765
766/// Handle JSON output for models list
767fn handle_models_list_json(
768    config: &CliConfig,
769    fastembed_installed: &[(String, PathBuf, u64)],
770) -> Result<()> {
771    let output = serde_json::json!({
772        "models_dir": config.models_dir,
773        "embedding_models": EmbeddingModel::all().map(|m| {
774            let is_installed = fastembed_installed
775                .iter()
776                .any(|(name, _, _)| name.contains(m.hf_repo()));
777            serde_json::json!({
778                "name": m.cli_name(),
779                "display_name": m.display_name(),
780                "dimensions": m.dimensions(),
781                "size_mb": m.size_mb(),
782                "hf_repo": m.hf_repo(),
783                "installed": is_installed,
784                "is_default": m.is_default(),
785            })
786        }).collect::<Vec<_>>(),
787        "reranker_models": RerankerModel::all().map(|m| {
788            serde_json::json!({
789                "name": m.cli_name(),
790                "display_name": m.display_name(),
791                "size_mb": m.size_mb(),
792                "hf_repo": m.hf_repo(),
793                "language": m.language(),
794                "is_default": m.is_default(),
795            })
796        }).collect::<Vec<_>>(),
797        "llm_models": LlmModel::all().map(|m| {
798            serde_json::json!({
799                "name": m.cli_name(),
800                "display_name": m.display_name(),
801                "size_gb": m.expected_size_bytes() as f64 / 1_000_000_000.0,
802                "hf_repo": m.hf_repo(),
803                "installed": is_llm_model_installed(config, m),
804                "path": if is_llm_model_installed(config, m) {
805                    Some(llm_model_path(config, m))
806                } else {
807                    None
808                },
809                "is_default": m.is_default(),
810            })
811        }).collect::<Vec<_>>(),
812        "external_providers": EXTERNAL_EMBEDDING_PROVIDERS.iter().map(|p| {
813            serde_json::json!({
814                "name": p.name,
815                "env_var": p.env_var,
816                "configured": std::env::var(p.env_var).is_ok(),
817                "models": p.models.iter().map(|(name, dim, desc)| {
818                    serde_json::json!({
819                        "name": name,
820                        "dimensions": dim,
821                        "description": desc,
822                    })
823                }).collect::<Vec<_>>(),
824            })
825        }).collect::<Vec<_>>(),
826        "installed_cache": fastembed_installed.iter().map(|(name, path, size)| {
827            serde_json::json!({
828                "name": name,
829                "path": path,
830                "size_bytes": size,
831            })
832        }).collect::<Vec<_>>(),
833    });
834
835    println!("{}", serde_json::to_string_pretty(&output)?);
836    Ok(())
837}
838
839/// Handle model removal
840pub fn handle_models_remove(config: &CliConfig, args: ModelsRemoveArgs) -> Result<()> {
841    let model = args.model;
842    let path = llm_model_path(config, model);
843
844    if !path.exists() {
845        println!("{} is not installed.", model.display_name());
846        return Ok(());
847    }
848
849    if !args.yes {
850        print!(
851            "Remove {} ({})? [y/N] ",
852            model.display_name(),
853            path.display()
854        );
855        io::stdout().flush()?;
856
857        let mut input = String::new();
858        io::stdin().read_line(&mut input)?;
859
860        if !input.trim().eq_ignore_ascii_case("y") {
861            println!("Aborted.");
862            return Ok(());
863        }
864    }
865
866    fs::remove_file(&path)?;
867
868    // Try to remove parent directory if empty
869    if let Some(parent) = path.parent() {
870        let _ = fs::remove_dir(parent);
871    }
872
873    println!("Removed {}.", model.display_name());
874    Ok(())
875}
876
877/// Handle model verification
878pub fn handle_models_verify(config: &CliConfig, args: ModelsVerifyArgs) -> Result<()> {
879    let models_to_verify: Vec<LlmModel> = match args.model {
880        Some(m) => vec![m],
881        None => LlmModel::all()
882            .filter(|m| is_llm_model_installed(config, *m))
883            .collect(),
884    };
885
886    if models_to_verify.is_empty() {
887        println!("No LLM models installed to verify.");
888        return Ok(());
889    }
890
891    let mut all_ok = true;
892
893    for model in models_to_verify {
894        let path = llm_model_path(config, model);
895        print!("Verifying {}... ", model.display_name());
896        io::stdout().flush()?;
897
898        match verify_model_file(&path, model) {
899            Ok(()) => println!("OK"),
900            Err(err) => {
901                println!("FAILED");
902                eprintln!("  Error: {}", err);
903                all_ok = false;
904            }
905        }
906    }
907
908    if !all_ok {
909        bail!("Some models failed verification.");
910    }
911
912    Ok(())
913}
914
915/// Verify a model file exists and has reasonable size
916fn verify_model_file(path: &Path, model: LlmModel) -> Result<()> {
917    if !path.exists() {
918        bail!("Model file does not exist");
919    }
920
921    let metadata = fs::metadata(path)?;
922    let size = metadata.len();
923
924    // Check minimum size (at least 50% of expected)
925    let min_size = model.expected_size_bytes() / 2;
926    if size < min_size {
927        bail!(
928            "Model file too small ({:.2} GB, expected at least {:.2} GB)",
929            size as f64 / 1_000_000_000.0,
930            min_size as f64 / 1_000_000_000.0
931        );
932    }
933
934    // Check GGUF magic bytes
935    let mut file = fs::File::open(path)?;
936    let mut magic = [0u8; 4];
937    io::Read::read_exact(&mut file, &mut magic)?;
938
939    // GGUF magic is "GGUF" (0x46554747)
940    if &magic != b"GGUF" {
941        bail!("Invalid GGUF file (bad magic bytes)");
942    }
943
944    Ok(())
945}
946
947/// Get the path to an installed model, or None if not installed
948pub fn get_installed_model_path(config: &CliConfig, model: LlmModel) -> Option<PathBuf> {
949    let path = llm_model_path(config, model);
950    if path.exists() && path.is_file() {
951        Some(path)
952    } else {
953        None
954    }
955}
956
957/// Get the default LLM model for enrichment
958pub fn default_enrichment_model() -> LlmModel {
959    LlmModel::Phi35Mini
960}