1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum EmbeddingModel {
22 BgeSmall,
24 BgeBase,
26 Nomic,
28 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum RerankerModel {
100 JinaTurbo,
102 JinaMultilingual,
104 BgeRerankerBase,
106 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#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
177pub enum LlmModel {
178 #[value(name = "phi-3.5-mini")]
180 Phi35Mini,
181 #[value(name = "phi-3.5-mini-q8")]
183 Phi35MiniQ8,
184}
185
186impl LlmModel {
187 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 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 fn expected_size_bytes(&self) -> u64 {
204 match self {
205 LlmModel::Phi35Mini => 2_360_000_000, LlmModel::Phi35MiniQ8 => 3_860_000_000, }
208 }
209
210 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 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 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
243struct ExternalEmbeddingProvider {
249 name: &'static str,
250 models: &'static [(&'static str, usize, &'static str)], 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#[derive(Args)]
288pub struct ModelsArgs {
289 #[command(subcommand)]
290 pub command: ModelsCommand,
291}
292
293#[derive(Subcommand)]
294pub enum ModelsCommand {
295 Install(ModelsInstallArgs),
297 List(ModelsListArgs),
299 Remove(ModelsRemoveArgs),
301 Verify(ModelsVerifyArgs),
303}
304
305#[derive(Args)]
306pub struct ModelsInstallArgs {
307 #[arg(value_enum)]
309 pub model: LlmModel,
310
311 #[arg(long, short)]
313 pub force: bool,
314}
315
316#[derive(Args)]
317pub struct ModelsListArgs {
318 #[arg(long)]
320 pub json: bool,
321
322 #[arg(long, value_enum)]
324 pub model_type: Option<ModelType>,
325}
326
327#[derive(Debug, Clone, Copy, ValueEnum)]
328pub enum ModelType {
329 Embedding,
331 Reranker,
333 Llm,
335 External,
337}
338
339#[derive(Args)]
340pub struct ModelsRemoveArgs {
341 #[arg(value_enum)]
343 pub model: LlmModel,
344
345 #[arg(long, short)]
347 pub yes: bool,
348}
349
350#[derive(Args)]
351pub struct ModelsVerifyArgs {
352 #[arg(value_enum)]
354 pub model: Option<LlmModel>,
355}
356
357#[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
367fn llm_models_dir(config: &CliConfig) -> PathBuf {
373 config.models_dir.join("llm")
374}
375
376fn fastembed_cache_dir(config: &CliConfig) -> PathBuf {
378 config.models_dir.clone()
379}
380
381fn 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
388fn 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
394fn 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 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
417fn 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
434pub 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
448pub 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 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_model(model, &target_path)?;
486
487 let metadata = fs::metadata(&target_path)?;
489 let size = metadata.len();
490
491 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
514fn 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", "--progress-bar", "-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 let _ = fs::remove_file(target_path);
541 bail!("Download failed. Please check your internet connection and try again.");
542 }
543
544 Ok(())
545}
546
547pub 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 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 println!("Models Directory: {}", config.models_dir.display());
569 println!();
570
571 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 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 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 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 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 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 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
766fn 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
839pub 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 if let Some(parent) = path.parent() {
870 let _ = fs::remove_dir(parent);
871 }
872
873 println!("Removed {}.", model.display_name());
874 Ok(())
875}
876
877pub 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
915fn 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 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 let mut file = fs::File::open(path)?;
936 let mut magic = [0u8; 4];
937 io::Read::read_exact(&mut file, &mut magic)?;
938
939 if &magic != b"GGUF" {
941 bail!("Invalid GGUF file (bad magic bytes)");
942 }
943
944 Ok(())
945}
946
947pub 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
957pub fn default_enrichment_model() -> LlmModel {
959 LlmModel::Phi35Mini
960}