1use std::fmt;
16
17#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum EmbeddingModel {
21 AllMiniLmL6V2,
24 NomicEmbedV1_5,
27 #[serde(rename = "potion-code-16m")]
30 PotionCode16M,
31 Custom(CustomModelSpec),
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ModelFormat {
39 Onnx,
41 StaticSafetensors,
43}
44
45impl ModelFormat {
46 #[must_use]
48 pub fn local_file(self) -> &'static str {
49 match self {
50 Self::Onnx => "model.onnx",
51 Self::StaticSafetensors => "model.safetensors",
52 }
53 }
54}
55
56const POTION_CODE_REVISION: &str = "1b0ff71095656b23306542bbad34a09109673720";
60const POTION_CODE_SHA256: &[(&str, &str)] = &[
61 (
62 "model.safetensors",
63 "ca6159081a6e96cebe4ad878e5e8437bfccc761e8db16223370149cd2faa6c0b",
64 ),
65 (
66 "tokenizer.json",
67 "8e84217af15e70e8127c855435fc3d8a4cd91d7bbe686f72e75f188118ec78ae",
68 ),
69];
70
71#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
73pub struct CustomModelSpec {
74 pub repo: String,
76 pub revision: Option<String>,
80 pub dimensions: Option<usize>,
84}
85
86impl CustomModelSpec {
87 fn parse(s: &str) -> Option<Self> {
90 let (repo, revision) = match s.split_once('@') {
91 Some((r, rev)) => (
92 r.trim(),
93 Some(rev.trim().to_string()).filter(|v| !v.is_empty()),
94 ),
95 None => (s.trim(), None),
96 };
97 let mut parts = repo.split('/');
99 let (owner, name) = (parts.next()?, parts.next()?);
100 if parts.next().is_some()
101 || owner.is_empty()
102 || name.is_empty()
103 || repo.chars().any(char::is_whitespace)
104 {
105 return None;
106 }
107 Some(Self {
108 repo: repo.to_string(),
109 revision,
110 dimensions: None,
111 })
112 }
113
114 fn storage_slug(&self) -> String {
116 let mut slug = String::from("hf-");
117 for c in self.repo.chars() {
118 slug.push(match c {
119 'a'..='z' | '0'..='9' | '-' => c,
120 'A'..='Z' => c.to_ascii_lowercase(),
121 _ => '-',
122 });
123 }
124 if let Some(rev) = &self.revision {
125 slug.push('-');
126 for c in rev.chars().take(16) {
127 slug.push(match c {
128 'a'..='z' | '0'..='9' | '-' => c,
129 'A'..='Z' => c.to_ascii_lowercase(),
130 _ => '-',
131 });
132 }
133 }
134 slug
135 }
136}
137
138impl EmbeddingModel {
139 pub const DEFAULT: Self = Self::AllMiniLmL6V2;
140
141 pub fn config(&self) -> ModelConfig {
142 match self {
143 Self::AllMiniLmL6V2 => ModelConfig {
144 model: self.clone(),
145 name: "all-MiniLM-L6-v2".into(),
146 hf_repo: "sentence-transformers/all-MiniLM-L6-v2".into(),
147 revision: None,
148 onnx_path: "onnx/model.onnx".into(),
149 vocab_file: VocabSource::VocabTxt("vocab.txt".into()),
150 dimensions: 384,
151 max_seq_len: 256,
152 model_min_bytes: 1_000_000,
153 vocab_min_bytes: 100_000,
154 query_prefix: None,
155 document_prefix: None,
156 needs_token_type_ids: true,
157 format: ModelFormat::Onnx,
158 pinned_sha256: &[],
159 },
160 Self::PotionCode16M => ModelConfig {
161 model: self.clone(),
162 name: "potion-code-16M".into(),
163 hf_repo: "minishlab/potion-code-16M".into(),
164 revision: Some(POTION_CODE_REVISION.into()),
165 onnx_path: "model.safetensors".into(),
166 vocab_file: VocabSource::TokenizerJson("tokenizer.json".into()),
167 dimensions: 256,
168 max_seq_len: 512,
171 model_min_bytes: 10_000_000,
172 vocab_min_bytes: 100_000,
173 query_prefix: None,
174 document_prefix: None,
175 needs_token_type_ids: false,
176 format: ModelFormat::StaticSafetensors,
177 pinned_sha256: POTION_CODE_SHA256,
178 },
179 Self::NomicEmbedV1_5 => ModelConfig {
180 model: self.clone(),
181 name: "nomic-embed-text-v1.5".into(),
182 hf_repo: "nomic-ai/nomic-embed-text-v1.5".into(),
183 revision: None,
184 onnx_path: "onnx/model.onnx".into(),
185 vocab_file: VocabSource::VocabTxt("vocab.txt".into()),
186 dimensions: 768,
187 max_seq_len: 512,
188 model_min_bytes: 100_000_000,
189 vocab_min_bytes: 100_000,
190 query_prefix: Some("search_query: ".into()),
191 document_prefix: Some("search_document: ".into()),
192 needs_token_type_ids: false,
193 format: ModelFormat::Onnx,
194 pinned_sha256: &[],
195 },
196 Self::Custom(spec) => ModelConfig {
197 model: self.clone(),
198 name: match &spec.revision {
201 Some(rev) => format!("hf:{}@{rev}", spec.repo),
202 None => format!("hf:{}", spec.repo),
203 },
204 hf_repo: spec.repo.clone(),
205 revision: spec.revision.clone(),
206 onnx_path: "onnx/model.onnx".into(),
207 vocab_file: VocabSource::TokenizerJson("tokenizer.json".into()),
210 dimensions: spec.dimensions.unwrap_or(768),
213 max_seq_len: 512,
214 model_min_bytes: 1_000_000,
215 vocab_min_bytes: 1_000,
216 query_prefix: None,
217 document_prefix: None,
218 needs_token_type_ids: false,
221 format: ModelFormat::Onnx,
222 pinned_sha256: &[],
223 },
224 }
225 }
226
227 pub fn from_str_name(s: &str) -> Option<Self> {
232 let trimmed = s.trim();
233 if let Some(rest) = trimmed.strip_prefix("hf:") {
234 return CustomModelSpec::parse(rest).map(Self::Custom);
235 }
236 match trimmed.to_lowercase().replace('_', "-").as_str() {
237 "all-minilm-l6-v2" | "minilm" | "default" => Some(Self::AllMiniLmL6V2),
238 "nomic-embed-v1.5" | "nomic-embed-text-v1.5" | "nomic" | "nomic-embed" => {
239 Some(Self::NomicEmbedV1_5)
240 }
241 "potion-code-16m" | "potion-code" | "potion" | "static" => Some(Self::PotionCode16M),
242 _ => None,
243 }
244 }
245
246 pub const ALL: &'static [Self] = &[
248 Self::AllMiniLmL6V2,
249 Self::NomicEmbedV1_5,
250 Self::PotionCode16M,
251 ];
252
253 pub fn storage_dir_name(&self) -> String {
255 match self {
256 Self::AllMiniLmL6V2 => "all-minilm-l6-v2".to_string(),
257 Self::NomicEmbedV1_5 => "nomic-embed-v1.5".to_string(),
258 Self::PotionCode16M => "potion-code-16m".to_string(),
259 Self::Custom(spec) => spec.storage_slug(),
260 }
261 }
262}
263
264impl fmt::Display for EmbeddingModel {
265 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266 f.write_str(&self.config().name)
267 }
268}
269
270#[derive(Debug, Clone)]
272pub enum VocabSource {
273 VocabTxt(String),
275 TokenizerJson(String),
277}
278
279impl VocabSource {
280 pub fn filename(&self) -> &str {
281 match self {
282 Self::VocabTxt(f) | Self::TokenizerJson(f) => f,
283 }
284 }
285
286 pub fn is_wordpiece(&self) -> bool {
287 matches!(self, Self::VocabTxt(_))
288 }
289}
290
291#[derive(Debug, Clone)]
293pub struct ModelConfig {
294 pub model: EmbeddingModel,
295 pub name: String,
296 pub hf_repo: String,
297 pub revision: Option<String>,
299 pub onnx_path: String,
300 pub vocab_file: VocabSource,
301 pub dimensions: usize,
302 pub max_seq_len: usize,
303 pub model_min_bytes: u64,
304 pub vocab_min_bytes: u64,
305 pub query_prefix: Option<String>,
307 pub document_prefix: Option<String>,
309 pub needs_token_type_ids: bool,
312 pub format: ModelFormat,
314 pub pinned_sha256: &'static [(&'static str, &'static str)],
317}
318
319impl ModelConfig {
320 fn resolve_base(&self) -> String {
321 format!(
322 "https://huggingface.co/{}/resolve/{}",
323 self.hf_repo,
324 self.revision.as_deref().unwrap_or("main")
325 )
326 }
327
328 #[must_use]
330 pub fn local_model_file(&self) -> &'static str {
331 self.format.local_file()
332 }
333
334 pub fn model_url(&self) -> String {
336 format!("{}/{}", self.resolve_base(), self.onnx_path)
337 }
338
339 pub fn vocab_url(&self) -> String {
341 format!("{}/{}", self.resolve_base(), self.vocab_file.filename())
342 }
343}
344
345pub fn resolve_model() -> EmbeddingModel {
351 resolve_model_with_source().0
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
357pub enum ModelSource {
358 Env(String),
360 Config(String),
362 Default,
364}
365
366pub fn resolve_model_with_source() -> (EmbeddingModel, ModelSource) {
368 let env_val = std::env::var("LEAN_CTX_EMBEDDING_MODEL").ok();
369 let embedding_cfg = crate::core::config::Config::load().embedding;
370 resolve_model_with_source_from(
371 env_val.as_deref(),
372 embedding_cfg.model.as_deref(),
373 embedding_cfg.dimensions,
374 default_model(),
375 )
376}
377
378#[must_use]
390pub fn default_model() -> EmbeddingModel {
391 #[cfg(feature = "embeddings")]
392 {
393 static ORT_PRESENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
394 let ort_present = *ORT_PRESENT
395 .get_or_init(|| crate::core::ort_environment::resolved_ort_dylib_path().is_ok());
396 if !ort_present {
397 return EmbeddingModel::PotionCode16M;
398 }
399 }
400 EmbeddingModel::DEFAULT
401}
402
403#[cfg(test)]
407fn resolve_model_from(
408 env_val: Option<&str>,
409 config_val: Option<&str>,
410 config_dims: Option<usize>,
411) -> EmbeddingModel {
412 resolve_model_with_source_from(env_val, config_val, config_dims, EmbeddingModel::DEFAULT).0
413}
414
415fn resolve_model_with_source_from(
416 env_val: Option<&str>,
417 config_val: Option<&str>,
418 config_dims: Option<usize>,
419 default: EmbeddingModel,
420) -> (EmbeddingModel, ModelSource) {
421 for (source, raw) in [
422 ("LEAN_CTX_EMBEDDING_MODEL", env_val),
423 ("[embedding].model", config_val),
424 ] {
425 let Some(name) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
426 continue;
427 };
428 let origin = if source == "LEAN_CTX_EMBEDDING_MODEL" {
429 ModelSource::Env(name.to_string())
430 } else {
431 ModelSource::Config(name.to_string())
432 };
433 match EmbeddingModel::from_str_name(name) {
434 Some(EmbeddingModel::Custom(mut spec)) => {
435 spec.dimensions = config_dims;
436 if spec.revision.is_none() {
437 tracing::warn!(
438 "Custom embedding model {:?} has no revision pin — supply-chain best \
439 practice is `hf:{}@<commit-or-tag>` so upstream pushes can never \
440 silently change your index",
441 spec.repo,
442 spec.repo
443 );
444 }
445 return (EmbeddingModel::Custom(spec), origin);
446 }
447 Some(model) => return (model, origin),
448 None => {
449 tracing::warn!(
450 "Unknown embedding model {name:?} from {source}; using {default} instead \
451 (built-ins: minilm, nomic, potion-code — or hf:org/repo[@rev])"
452 );
453 }
454 }
455 }
456 (default, ModelSource::Default)
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[test]
464 fn default_model_is_minilm() {
465 assert_eq!(EmbeddingModel::DEFAULT, EmbeddingModel::AllMiniLmL6V2);
466 }
467
468 #[test]
469 fn from_str_name_variants() {
470 assert_eq!(
471 EmbeddingModel::from_str_name("minilm"),
472 Some(EmbeddingModel::AllMiniLmL6V2)
473 );
474 assert_eq!(
475 EmbeddingModel::from_str_name("nomic-embed-v1.5"),
476 Some(EmbeddingModel::NomicEmbedV1_5)
477 );
478 assert_eq!(
479 EmbeddingModel::from_str_name("nomic"),
480 Some(EmbeddingModel::NomicEmbedV1_5)
481 );
482 assert_eq!(
483 EmbeddingModel::from_str_name("default"),
484 Some(EmbeddingModel::AllMiniLmL6V2)
485 );
486 assert_eq!(EmbeddingModel::from_str_name("unknown"), None);
487 assert_eq!(EmbeddingModel::from_str_name("jina-code-v2"), None);
490 assert_eq!(EmbeddingModel::from_str_name("jina"), None);
491 }
492
493 #[test]
494 fn custom_hf_scheme_parses_repo_and_revision() {
495 let m = EmbeddingModel::from_str_name("hf:jinaai/jina-embeddings-v2-base-code").unwrap();
496 let EmbeddingModel::Custom(spec) = &m else {
497 panic!("expected custom")
498 };
499 assert_eq!(spec.repo, "jinaai/jina-embeddings-v2-base-code");
500 assert_eq!(spec.revision, None);
501
502 let m = EmbeddingModel::from_str_name("hf:org/model@abc123").unwrap();
503 let EmbeddingModel::Custom(spec) = &m else {
504 panic!("expected custom")
505 };
506 assert_eq!(spec.repo, "org/model");
507 assert_eq!(spec.revision.as_deref(), Some("abc123"));
508 }
509
510 #[test]
511 fn custom_hf_scheme_rejects_invalid_repos() {
512 for bad in [
513 "hf:",
514 "hf:no-slash",
515 "hf:too/many/slashes",
516 "hf:with space/repo",
517 "hf:/leading",
518 "hf:trailing/",
519 "hf:org/model@",
520 ] {
521 let parsed = EmbeddingModel::from_str_name(bad);
522 if bad == "hf:org/model@" {
523 let Some(EmbeddingModel::Custom(spec)) = parsed else {
525 panic!("expected custom for {bad}")
526 };
527 assert_eq!(spec.revision, None);
528 } else {
529 assert_eq!(parsed, None, "{bad} should be rejected");
530 }
531 }
532 }
533
534 #[test]
535 fn custom_config_urls_and_storage() {
536 let m = EmbeddingModel::from_str_name("hf:Org/My_Model@v1.2").unwrap();
537 let cfg = m.config();
538 assert_eq!(
539 cfg.model_url(),
540 "https://huggingface.co/Org/My_Model/resolve/v1.2/onnx/model.onnx"
541 );
542 assert_eq!(
543 cfg.vocab_url(),
544 "https://huggingface.co/Org/My_Model/resolve/v1.2/tokenizer.json"
545 );
546 assert!(!cfg.vocab_file.is_wordpiece());
547 assert_eq!(cfg.name, "hf:Org/My_Model@v1.2");
548 assert_eq!(m.storage_dir_name(), "hf-org-my-model-v1-2");
549 }
550
551 #[test]
552 fn custom_storage_slugs_differ_per_revision() {
553 let a = EmbeddingModel::from_str_name("hf:org/model@aaa").unwrap();
554 let b = EmbeddingModel::from_str_name("hf:org/model@bbb").unwrap();
555 let c = EmbeddingModel::from_str_name("hf:org/model").unwrap();
556 let slugs = [
557 a.storage_dir_name(),
558 b.storage_dir_name(),
559 c.storage_dir_name(),
560 ];
561 let unique: std::collections::HashSet<_> = slugs.iter().collect();
562 assert_eq!(unique.len(), 3);
563 }
564
565 #[test]
566 fn all_models_have_valid_configs() {
567 for model in EmbeddingModel::ALL {
568 let cfg = model.config();
569 assert!(!cfg.name.is_empty(), "`cfg.name` is empty");
570 assert!(!cfg.hf_repo.is_empty(), "`cfg.hf_repo` is empty");
571 assert!(cfg.dimensions > 0);
572 assert!(cfg.max_seq_len > 0);
573 assert!(cfg.model_min_bytes > 0);
574 assert!(cfg.vocab_min_bytes > 0);
575 }
576 }
577
578 #[test]
579 fn model_urls_are_valid() {
580 for model in EmbeddingModel::ALL {
581 let cfg = model.config();
582 let model_url = cfg.model_url();
583 let vocab_url = cfg.vocab_url();
584 assert!(model_url.starts_with("https://huggingface.co/"));
585 assert!(vocab_url.starts_with("https://huggingface.co/"));
586 let rev = cfg.revision.as_deref().unwrap_or("main");
588 assert!(
589 model_url.contains(&format!("resolve/{rev}/")),
590 "{model_url}"
591 );
592 }
593 }
594
595 #[test]
596 fn storage_dir_names_are_unique() {
597 let names: Vec<_> = EmbeddingModel::ALL
598 .iter()
599 .map(EmbeddingModel::storage_dir_name)
600 .collect();
601 let unique: std::collections::HashSet<_> = names.iter().collect();
602 assert_eq!(names.len(), unique.len());
603 }
604
605 #[test]
606 fn display_uses_model_name() {
607 assert_eq!(
608 format!("{}", EmbeddingModel::AllMiniLmL6V2),
609 "all-MiniLM-L6-v2"
610 );
611 assert_eq!(
612 format!("{}", EmbeddingModel::NomicEmbedV1_5),
613 "nomic-embed-text-v1.5"
614 );
615 }
616
617 #[test]
618 fn resolve_defaults_when_nothing_set() {
619 assert_eq!(
620 resolve_model_from(None, None, None),
621 EmbeddingModel::DEFAULT
622 );
623 assert_eq!(
624 resolve_model_from(Some(""), Some(" "), None),
625 EmbeddingModel::DEFAULT
626 );
627 }
628
629 #[test]
630 fn config_selects_model_when_env_unset() {
631 assert_eq!(
632 resolve_model_from(None, Some("nomic"), None),
633 EmbeddingModel::NomicEmbedV1_5
634 );
635 assert_eq!(
636 resolve_model_from(None, Some("minilm"), None),
637 EmbeddingModel::AllMiniLmL6V2
638 );
639 }
640
641 #[test]
642 fn resolution_reports_the_setting_that_won() {
643 assert_eq!(
644 resolve_model_with_source_from(
645 Some(" nomic "),
646 Some("minilm"),
647 None,
648 EmbeddingModel::DEFAULT
649 )
650 .1,
651 ModelSource::Env("nomic".into())
652 );
653 assert_eq!(
654 resolve_model_with_source_from(
655 Some("bogus"),
656 Some("nomic"),
657 None,
658 EmbeddingModel::DEFAULT
659 )
660 .1,
661 ModelSource::Config("nomic".into())
662 );
663 assert_eq!(
664 resolve_model_with_source_from(None, Some("nope"), None, EmbeddingModel::DEFAULT).1,
665 ModelSource::Default
666 );
667 }
668
669 #[test]
670 fn env_var_overrides_config() {
671 assert_eq!(
672 resolve_model_from(Some("minilm"), Some("nomic"), None),
673 EmbeddingModel::AllMiniLmL6V2
674 );
675 }
676
677 #[test]
678 fn unknown_name_falls_through_then_defaults() {
679 assert_eq!(
681 resolve_model_from(Some("bogus"), Some("nomic"), None),
682 EmbeddingModel::NomicEmbedV1_5
683 );
684 assert_eq!(
686 resolve_model_from(Some("bogus"), Some("nope"), None),
687 EmbeddingModel::DEFAULT
688 );
689 assert_eq!(
691 resolve_model_from(Some(" "), Some("nomic"), None),
692 EmbeddingModel::NomicEmbedV1_5
693 );
694 }
695
696 #[test]
697 fn resolve_custom_picks_up_config_dimensions() {
698 let m = resolve_model_from(None, Some("hf:org/model@pin"), Some(1024));
699 let EmbeddingModel::Custom(spec) = m else {
700 panic!("expected custom")
701 };
702 assert_eq!(spec.dimensions, Some(1024));
703 assert_eq!(spec.revision.as_deref(), Some("pin"));
704 }
705
706 #[test]
707 fn nomic_has_prefixes() {
708 let cfg = EmbeddingModel::NomicEmbedV1_5.config();
709 assert!(cfg.query_prefix.is_some());
710 assert!(cfg.document_prefix.is_some());
711 assert!(!cfg.needs_token_type_ids);
712 }
713
714 #[test]
715 fn minilm_is_wordpiece() {
716 let cfg = EmbeddingModel::AllMiniLmL6V2.config();
717 assert!(cfg.vocab_file.is_wordpiece());
718 }
719
720 #[test]
721 fn builtin_models_have_valid_vocab_sources() {
722 for model in EmbeddingModel::ALL {
725 let cfg = model.config();
726 match cfg.format {
727 ModelFormat::Onnx => assert!(
728 cfg.vocab_file.is_wordpiece(),
729 "{model} should use WordPiece vocab.txt"
730 ),
731 ModelFormat::StaticSafetensors => assert_eq!(
732 cfg.vocab_file.filename(),
733 "tokenizer.json",
734 "{model} ships tokenizer.json"
735 ),
736 }
737 }
738 }
739
740 #[test]
741 fn custom_models_use_tokenizer_json() {
742 let m = EmbeddingModel::from_str_name("hf:org/model").unwrap();
743 assert!(!m.config().vocab_file.is_wordpiece());
744 assert_eq!(m.config().vocab_file.filename(), "tokenizer.json");
745 }
746}