1use std::path::{Path, PathBuf};
17
18use crate::embedding::EmbedderError;
19
20pub(crate) const DEFAULT_REPO: &str = "BAAI/bge-small-en-v1.5";
24pub(crate) const DEFAULT_REVISION: &str = "5c38ec7c405ec4b44b94cc5a9bb96e735b38267a";
25pub(crate) const DEFAULT_QUERY_INSTRUCTION: &str =
27 "Represent this sentence for searching relevant passages: ";
28
29pub(crate) const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434/v1/embeddings";
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Pooling {
39 Cls,
41 Mean,
43}
44
45impl Pooling {
46 pub(crate) fn as_str(self) -> &'static str {
47 match self {
48 Pooling::Cls => "cls",
49 Pooling::Mean => "mean",
50 }
51 }
52}
53
54impl std::str::FromStr for Pooling {
55 type Err = EmbedderError;
56 fn from_str(s: &str) -> Result<Self, Self::Err> {
57 match s.to_ascii_lowercase().as_str() {
58 "cls" => Ok(Pooling::Cls),
59 "mean" => Ok(Pooling::Mean),
60 other => Err(cfg(format!(
61 "unknown pooling '{other}'; expected 'cls' or 'mean'"
62 ))),
63 }
64 }
65}
66
67pub(crate) fn fingerprint_suffix(
71 pooling: Option<Pooling>,
72 query_prefix: &str,
73 doc_prefix: &str,
74) -> String {
75 let mut s = String::new();
76 if let Some(p) = pooling {
77 push_fingerprint_field(&mut s, "pool", p.as_str());
78 }
79 if !query_prefix.is_empty() {
80 push_fingerprint_field(&mut s, "q", query_prefix);
81 }
82 if !doc_prefix.is_empty() {
83 push_fingerprint_field(&mut s, "d", doc_prefix);
84 }
85 s
86}
87
88pub(crate) fn huggingface_fingerprint(repo: &str, revision: &str) -> String {
89 fingerprint("hf", &[("repo", repo), ("revision", revision)])
90}
91
92pub(crate) fn local_fingerprint(path: &str) -> String {
93 fingerprint("local", &[("path", path)])
94}
95
96pub(crate) fn endpoint_fingerprint(url: &str, model: &str) -> String {
97 fingerprint("endpoint", &[("url", url), ("model", model)])
98}
99
100#[derive(Debug, Clone, PartialEq)]
102pub enum EmbeddingModel {
103 Default,
105 HuggingFace {
110 repo: String,
112 revision: Option<String>,
114 query_prefix: Option<String>,
116 doc_prefix: Option<String>,
118 pooling: Option<Pooling>,
120 download: bool,
122 },
123 Local {
126 path: PathBuf,
128 query_prefix: Option<String>,
130 doc_prefix: Option<String>,
132 pooling: Option<Pooling>,
134 },
135 Endpoint {
139 url: String,
141 model: String,
143 api_key_env: Option<String>,
145 query_prefix: Option<String>,
147 doc_prefix: Option<String>,
149 },
150}
151
152#[derive(Debug, Clone, Default)]
157pub struct EmbeddingSpec {
158 pub spec: Option<String>,
161 pub huggingface: Option<String>,
163 pub local: Option<String>,
165 pub ollama: Option<String>,
167 pub url: Option<String>,
169 pub model: Option<String>,
171 pub revision: Option<String>,
173 pub api_key_env: Option<String>,
175 pub query_prefix: Option<String>,
177 pub doc_prefix: Option<String>,
179 pub pooling: Option<String>,
181 pub download: Option<bool>,
184}
185
186fn cfg(message: impl Into<String>) -> EmbedderError {
187 EmbedderError::Config {
188 message: message.into(),
189 }
190}
191
192impl EmbeddingModel {
193 pub fn validate(&self) -> Result<(), EmbedderError> {
202 match self {
203 EmbeddingModel::Default => Ok(()),
204 EmbeddingModel::HuggingFace { repo, .. } => validate_nonblank("huggingface", repo),
205 EmbeddingModel::Local { path, .. } => {
206 validate_nonblank("local", &path.to_string_lossy())
207 }
208 EmbeddingModel::Endpoint {
209 url,
210 model,
211 api_key_env,
212 ..
213 } => {
214 validate_nonblank("url", url)?;
215 validate_nonblank("model", model)?;
216 if let Some(api_key_env) = api_key_env {
217 validate_nonblank("api_key_env", api_key_env)?;
218 }
219 Ok(())
220 }
221 }
222 }
223
224 pub fn resolve(spec: EmbeddingSpec) -> Result<EmbeddingModel, EmbedderError> {
227 for (name, value) in [
228 ("spec", spec.spec.as_deref()),
229 ("huggingface", spec.huggingface.as_deref()),
230 ("local", spec.local.as_deref()),
231 ("ollama", spec.ollama.as_deref()),
232 ("url", spec.url.as_deref()),
233 ("model", spec.model.as_deref()),
234 ("api_key_env", spec.api_key_env.as_deref()),
235 ] {
236 if value.is_some_and(|value| value.trim().is_empty()) {
237 return Err(cfg(format!("embedding '{name}' must not be blank")));
238 }
239 }
240
241 let primaries = [
242 ("spec", spec.spec.is_some()),
243 ("huggingface", spec.huggingface.is_some()),
244 ("local", spec.local.is_some()),
245 ("ollama", spec.ollama.is_some()),
246 ("url", spec.url.is_some()),
247 ];
248 let set: Vec<&str> = primaries
249 .iter()
250 .filter(|(_, present)| *present)
251 .map(|(key, _)| *key)
252 .collect();
253 match set.len() {
254 0 => {
255 return Err(cfg(
256 "no embedding source given; pass a local directory path, or one of \
257 huggingface/local/ollama/url",
258 ));
259 }
260 1 => {}
261 _ => {
262 return Err(cfg(format!(
263 "conflicting embedding keys {set:?}; give exactly one of \
264 spec/huggingface/local/ollama/url",
265 )));
266 }
267 }
268
269 let pooling = spec
272 .pooling
273 .as_deref()
274 .map(str::parse::<Pooling>)
275 .transpose()?;
276
277 if spec.download.is_some() && set[0] != "huggingface" {
279 return Err(cfg("'download' is only valid for a HuggingFace repo"));
280 }
281
282 let model = match set[0] {
283 "spec" => infer_from_string(spec.spec.as_deref().unwrap(), &spec, pooling),
284 "huggingface" => {
285 reject_endpoint_only(&spec, "a HuggingFace repo")?;
286 Ok(EmbeddingModel::HuggingFace {
287 repo: spec.huggingface.unwrap(),
288 revision: spec.revision,
289 query_prefix: spec.query_prefix,
290 doc_prefix: spec.doc_prefix,
291 pooling,
292 download: spec.download.unwrap_or(false),
293 })
294 }
295 "local" => {
296 reject_endpoint_only(&spec, "a local model")?;
297 if spec.revision.is_some() {
298 return Err(cfg("'revision' is only valid for a HuggingFace repo"));
299 }
300 Ok(EmbeddingModel::Local {
301 path: PathBuf::from(spec.local.unwrap()),
302 query_prefix: spec.query_prefix,
303 doc_prefix: spec.doc_prefix,
304 pooling,
305 })
306 }
307 "ollama" => {
308 if spec.model.is_some() {
309 return Err(cfg(
310 "'model' is redundant with 'ollama' (the ollama value is the model name)",
311 ));
312 }
313 if spec.api_key_env.is_some() {
314 return Err(cfg(
315 "'api_key_env' is not valid with the Ollama shortcut; use a full endpoint 'url'",
316 ));
317 }
318 reject_in_process_only(&spec, pooling)?;
319 Ok(EmbeddingModel::Endpoint {
320 url: OLLAMA_DEFAULT_URL.to_string(),
321 model: spec.ollama.unwrap(),
322 api_key_env: None,
323 query_prefix: spec.query_prefix,
324 doc_prefix: spec.doc_prefix,
325 })
326 }
327 "url" => {
328 reject_in_process_only(&spec, pooling)?;
329 let model = spec
330 .model
331 .ok_or_else(|| cfg("endpoint embedding requires both 'url' and 'model'"))?;
332 Ok(EmbeddingModel::Endpoint {
333 url: spec.url.unwrap(),
334 model,
335 api_key_env: spec.api_key_env,
336 query_prefix: spec.query_prefix,
337 doc_prefix: spec.doc_prefix,
338 })
339 }
340 _ => unreachable!("primary key set is closed"),
341 }?;
342 model.validate()?;
343 Ok(model)
344 }
345
346 pub(crate) fn query_prefix(&self) -> &str {
349 match self {
350 EmbeddingModel::Default => DEFAULT_QUERY_INSTRUCTION,
351 EmbeddingModel::HuggingFace { query_prefix, .. }
352 | EmbeddingModel::Local { query_prefix, .. }
353 | EmbeddingModel::Endpoint { query_prefix, .. } => {
354 query_prefix.as_deref().unwrap_or("")
355 }
356 }
357 }
358
359 pub(crate) fn doc_prefix(&self) -> &str {
362 match self {
363 EmbeddingModel::Default => "",
364 EmbeddingModel::HuggingFace { doc_prefix, .. }
365 | EmbeddingModel::Local { doc_prefix, .. }
366 | EmbeddingModel::Endpoint { doc_prefix, .. } => doc_prefix.as_deref().unwrap_or(""),
367 }
368 }
369
370 pub(crate) fn pooling_override(&self) -> Option<Pooling> {
374 match self {
375 EmbeddingModel::Default => Some(Pooling::Cls),
376 EmbeddingModel::HuggingFace { pooling, .. } | EmbeddingModel::Local { pooling, .. } => {
377 *pooling
378 }
379 EmbeddingModel::Endpoint { .. } => None,
380 }
381 }
382
383 pub(crate) fn display_name(&self) -> String {
386 match self {
387 EmbeddingModel::Default => DEFAULT_REPO.to_string(),
388 EmbeddingModel::HuggingFace { repo, .. } => repo.clone(),
389 EmbeddingModel::Local { path, .. } => path.display().to_string(),
390 EmbeddingModel::Endpoint { url, model, .. } => format!("{model} @ {url}"),
391 }
392 }
393
394 pub(crate) fn configured_fingerprint(&self) -> String {
399 let base = match self {
400 EmbeddingModel::Default => huggingface_fingerprint(DEFAULT_REPO, DEFAULT_REVISION),
401 EmbeddingModel::HuggingFace { repo, revision, .. } => {
402 huggingface_fingerprint(repo, revision.as_deref().unwrap_or("main"))
403 }
404 EmbeddingModel::Local { path, .. } => local_fingerprint(&path.display().to_string()),
405 EmbeddingModel::Endpoint { url, model, .. } => endpoint_fingerprint(url, model),
406 };
407 format!(
409 "{base}{}",
410 fingerprint_suffix(
411 self.pooling_override(),
412 self.query_prefix(),
413 self.doc_prefix()
414 )
415 )
416 }
417
418 pub(crate) fn embedder_cache_key(&self) -> String {
423 let vector_identity = self.configured_fingerprint();
424 match self {
425 EmbeddingModel::Endpoint { api_key_env, .. } => match api_key_env {
426 Some(name) => {
427 let mut key = vector_identity;
428 push_fingerprint_field(&mut key, "api_key_env", name);
429 key
430 }
431 None => format!("{vector_identity}|api_key_env=none"),
432 },
433 _ => vector_identity,
434 }
435 }
436}
437
438fn fingerprint(kind: &str, fields: &[(&str, &str)]) -> String {
439 let mut fingerprint = kind.to_string();
440 for (name, value) in fields {
441 push_fingerprint_field(&mut fingerprint, name, value);
442 }
443 fingerprint
444}
445
446fn push_fingerprint_field(fingerprint: &mut String, name: &str, value: &str) {
447 fingerprint.push_str(&format!("|{name}={}:{}", value.len(), value));
448}
449
450fn validate_nonblank(name: &str, value: &str) -> Result<(), EmbedderError> {
451 if value.trim().is_empty() {
452 return Err(cfg(format!("embedding '{name}' must not be blank")));
453 }
454 Ok(())
455}
456
457fn reject_endpoint_only(spec: &EmbeddingSpec, what: &str) -> Result<(), EmbedderError> {
459 if spec.model.is_some() {
460 return Err(cfg(format!(
461 "'model' is only valid with an endpoint 'url', not {what}"
462 )));
463 }
464 if spec.api_key_env.is_some() {
465 return Err(cfg(format!(
466 "'api_key_env' is only valid with an endpoint 'url', not {what}"
467 )));
468 }
469 Ok(())
470}
471
472fn reject_in_process_only(
475 spec: &EmbeddingSpec,
476 pooling: Option<Pooling>,
477) -> Result<(), EmbedderError> {
478 if spec.revision.is_some() {
479 return Err(cfg("'revision' is only valid for a HuggingFace repo"));
480 }
481 if pooling.is_some() {
482 return Err(cfg(
483 "'pooling' is only valid for an in-process model (huggingface/local); \
484 an endpoint pools server-side",
485 ));
486 }
487 Ok(())
488}
489
490fn infer_from_string(
496 s: &str,
497 spec: &EmbeddingSpec,
498 pooling: Option<Pooling>,
499) -> Result<EmbeddingModel, EmbedderError> {
500 if spec.model.is_some() || spec.api_key_env.is_some() {
501 return Err(cfg(
502 "'model'/'api_key_env' are only valid with an endpoint 'url'; a bare string \
503 is only a local model directory path",
504 ));
505 }
506 if spec.revision.is_some() {
507 return Err(cfg("'revision' is only valid for a HuggingFace repo; use \
508 {\"huggingface\": \"…\", \"revision\": \"…\"}"));
509 }
510 if looks_like_url(s) {
511 return Err(cfg(format!(
512 "'{s}' looks like an endpoint URL but has no model name; use \
513 {{\"url\": \"{s}\", \"model\": \"…\"}}"
514 )));
515 }
516 if looks_like_path(s) || Path::new(s).is_dir() {
517 return Ok(EmbeddingModel::Local {
518 path: PathBuf::from(s),
519 query_prefix: spec.query_prefix.clone(),
520 doc_prefix: spec.doc_prefix.clone(),
521 pooling,
522 });
523 }
524 Err(cfg(format!(
527 "'{s}' is not a local directory path; to use a HuggingFace repo pass \
528 {{\"huggingface\": \"{s}\"}}, or give an absolute/relative directory \
529 path for a local model"
530 )))
531}
532
533fn looks_like_url(s: &str) -> bool {
535 match s.find("://") {
536 Some(idx) if idx > 0 => {
537 let scheme = &s[..idx];
538 scheme
539 .chars()
540 .next()
541 .is_some_and(|c| c.is_ascii_alphabetic())
542 && scheme
543 .chars()
544 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
545 }
546 _ => false,
547 }
548}
549
550fn looks_like_path(s: &str) -> bool {
553 s.starts_with('/')
554 || s.starts_with("./")
555 || s.starts_with("../")
556 || s.starts_with('~')
557 || s.starts_with(r"\\") || s.starts_with(r".\")
559 || s.starts_with(r"..\")
560 || is_windows_drive(s)
561}
562
563fn is_windows_drive(s: &str) -> bool {
565 let b = s.as_bytes();
566 b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 fn from_str(s: &str) -> Result<EmbeddingModel, EmbedderError> {
574 EmbeddingModel::resolve(EmbeddingSpec {
575 spec: Some(s.to_string()),
576 ..Default::default()
577 })
578 }
579
580 #[test]
581 fn bare_repo_id_string_is_rejected_pointing_to_huggingface() {
582 let err = from_str("BAAI/bge-base-en-v1.5").unwrap_err();
585 assert!(matches!(err, EmbedderError::Config { .. }));
586 assert!(err.to_string().contains("huggingface"), "got: {err}");
587 }
588
589 #[test]
590 fn huggingface_object_infers_default_revision() {
591 assert_eq!(
592 EmbeddingModel::resolve(EmbeddingSpec {
593 huggingface: Some("BAAI/bge-base-en-v1.5".into()),
594 ..Default::default()
595 })
596 .unwrap(),
597 EmbeddingModel::HuggingFace {
598 repo: "BAAI/bge-base-en-v1.5".into(),
599 revision: None,
600 query_prefix: None,
601 doc_prefix: None,
602 pooling: None,
603 download: false,
604 }
605 );
606 }
607
608 #[test]
609 fn absolute_and_relative_paths_infer_local_even_when_absent() {
610 for p in [
611 "/opt/models/x",
612 "./models/x",
613 "../x",
614 "~/models/x",
615 r"\\host\share\x",
616 ] {
617 assert!(
618 matches!(from_str(p).unwrap(), EmbeddingModel::Local { .. }),
619 "{p} should infer Local"
620 );
621 }
622 }
623
624 #[test]
625 fn windows_drive_path_is_local_not_url() {
626 for p in [r"C:\models\bge", "C:/models/bge"] {
627 assert!(
628 matches!(from_str(p).unwrap(), EmbeddingModel::Local { .. }),
629 "{p} should infer Local, not be mistaken for a URL"
630 );
631 }
632 }
633
634 #[test]
635 fn existing_directory_infers_local() {
636 let dir = tempfile::tempdir().unwrap();
637 let path = dir.path().to_str().unwrap();
638 assert!(matches!(
639 from_str(path).unwrap(),
640 EmbeddingModel::Local { .. }
641 ));
642 }
643
644 #[test]
645 fn bare_url_string_is_rejected_needs_model() {
646 let err = from_str("https://api.openai.com/v1/embeddings").unwrap_err();
647 assert!(matches!(err, EmbedderError::Config { .. }));
648 assert!(err.to_string().contains("model"), "got: {err}");
649 }
650
651 #[test]
652 fn ollama_object_expands_to_localhost_endpoint() {
653 let m = EmbeddingModel::resolve(EmbeddingSpec {
654 ollama: Some("nomic-embed-text".into()),
655 ..Default::default()
656 })
657 .unwrap();
658 assert_eq!(
659 m,
660 EmbeddingModel::Endpoint {
661 url: OLLAMA_DEFAULT_URL.into(),
662 model: "nomic-embed-text".into(),
663 api_key_env: None,
664 query_prefix: None,
665 doc_prefix: None,
666 }
667 );
668 }
669
670 #[test]
671 fn endpoint_object_requires_model() {
672 let err = EmbeddingModel::resolve(EmbeddingSpec {
673 url: Some("https://api.openai.com/v1/embeddings".into()),
674 ..Default::default()
675 })
676 .unwrap_err();
677 assert!(err.to_string().contains("'url' and 'model'"), "got: {err}");
678 }
679
680 #[test]
681 fn ollama_and_url_together_conflict() {
682 let err = EmbeddingModel::resolve(EmbeddingSpec {
683 ollama: Some("nomic".into()),
684 url: Some("http://host:11434/v1/embeddings".into()),
685 model: Some("nomic".into()),
686 ..Default::default()
687 })
688 .unwrap_err();
689 assert!(err.to_string().contains("conflicting"), "got: {err}");
690 }
691
692 #[test]
693 fn huggingface_object_with_revision() {
694 let m = EmbeddingModel::resolve(EmbeddingSpec {
695 huggingface: Some("BAAI/bge-base-en-v1.5".into()),
696 revision: Some("abc123".into()),
697 ..Default::default()
698 })
699 .unwrap();
700 assert_eq!(
701 m,
702 EmbeddingModel::HuggingFace {
703 repo: "BAAI/bge-base-en-v1.5".into(),
704 revision: Some("abc123".into()),
705 query_prefix: None,
706 doc_prefix: None,
707 pooling: None,
708 download: false,
709 }
710 );
711 }
712
713 #[test]
714 fn empty_spec_is_rejected() {
715 assert!(EmbeddingModel::resolve(EmbeddingSpec::default()).is_err());
716 }
717
718 #[test]
719 fn blank_source_and_endpoint_fields_are_rejected() {
720 for spec in [
721 EmbeddingSpec {
722 huggingface: Some(" ".into()),
723 ..Default::default()
724 },
725 EmbeddingSpec {
726 local: Some("\t".into()),
727 ..Default::default()
728 },
729 EmbeddingSpec {
730 ollama: Some("\n".into()),
731 ..Default::default()
732 },
733 EmbeddingSpec {
734 url: Some(" ".into()),
735 model: Some("model".into()),
736 ..Default::default()
737 },
738 EmbeddingSpec {
739 url: Some("http://localhost/v1/embeddings".into()),
740 model: Some(" ".into()),
741 ..Default::default()
742 },
743 EmbeddingSpec {
744 url: Some("http://localhost/v1/embeddings".into()),
745 model: Some("model".into()),
746 api_key_env: Some(" ".into()),
747 ..Default::default()
748 },
749 ] {
750 assert!(matches!(
751 EmbeddingModel::resolve(spec),
752 Err(EmbedderError::Config { .. })
753 ));
754 }
755 }
756
757 #[test]
758 fn ollama_rejects_api_key_env_instead_of_ignoring_it() {
759 let err = EmbeddingModel::resolve(EmbeddingSpec {
760 ollama: Some("nomic-embed-text".into()),
761 api_key_env: Some("OLLAMA_KEY".into()),
762 ..Default::default()
763 })
764 .unwrap_err();
765 assert!(matches!(err, EmbedderError::Config { .. }));
766 assert!(err.to_string().contains("api_key_env"));
767 }
768
769 #[test]
770 fn download_defaults_false_and_is_huggingface_only() {
771 assert!(matches!(
773 EmbeddingModel::resolve(EmbeddingSpec {
774 huggingface: Some("org/m".into()),
775 ..Default::default()
776 })
777 .unwrap(),
778 EmbeddingModel::HuggingFace {
779 download: false,
780 ..
781 }
782 ));
783 assert!(matches!(
785 EmbeddingModel::resolve(EmbeddingSpec {
786 huggingface: Some("org/m".into()),
787 download: Some(true),
788 ..Default::default()
789 })
790 .unwrap(),
791 EmbeddingModel::HuggingFace { download: true, .. }
792 ));
793 let err = EmbeddingModel::resolve(EmbeddingSpec {
795 ollama: Some("nomic".into()),
796 download: Some(true),
797 ..Default::default()
798 })
799 .unwrap_err();
800 assert!(err.to_string().contains("download"), "got: {err}");
801 }
802
803 #[test]
804 fn default_query_prefix_is_bge_instruction() {
805 assert_eq!(
806 EmbeddingModel::Default.query_prefix(),
807 DEFAULT_QUERY_INSTRUCTION
808 );
809 assert_eq!(
810 EmbeddingModel::Endpoint {
811 url: "u".into(),
812 model: "m".into(),
813 api_key_env: None,
814 query_prefix: None,
815 doc_prefix: None,
816 }
817 .query_prefix(),
818 ""
819 );
820 }
821
822 #[test]
823 fn fingerprints_are_distinct_per_source() {
824 assert_eq!(
826 EmbeddingModel::Default.configured_fingerprint(),
827 format!(
828 "hf|repo={}:{}|revision={}:{}|pool=3:cls|q={}:{}",
829 DEFAULT_REPO.len(),
830 DEFAULT_REPO,
831 DEFAULT_REVISION.len(),
832 DEFAULT_REVISION,
833 DEFAULT_QUERY_INSTRUCTION.len(),
834 DEFAULT_QUERY_INSTRUCTION
835 )
836 );
837 assert_eq!(
838 EmbeddingModel::HuggingFace {
839 repo: "r".into(),
840 revision: None,
841 query_prefix: None,
842 doc_prefix: None,
843 pooling: None,
844 download: false,
845 }
846 .configured_fingerprint(),
847 "hf|repo=1:r|revision=4:main"
848 );
849 assert_eq!(
850 EmbeddingModel::Endpoint {
851 url: "u".into(),
852 model: "m".into(),
853 api_key_env: None,
854 query_prefix: None,
855 doc_prefix: None,
856 }
857 .configured_fingerprint(),
858 "endpoint|url=1:u|model=1:m"
859 );
860 }
861
862 #[test]
863 fn endpoint_client_cache_key_includes_env_name_but_vector_identity_does_not() {
864 let endpoint = |api_key_env: &str| EmbeddingModel::Endpoint {
865 url: "https://example.test/v1/embeddings".into(),
866 model: "embed-v1".into(),
867 api_key_env: Some(api_key_env.into()),
868 query_prefix: None,
869 doc_prefix: None,
870 };
871 let a = endpoint("KEY_A");
872 let b = endpoint("KEY_B");
873
874 assert_eq!(a.configured_fingerprint(), b.configured_fingerprint());
875 assert_ne!(a.embedder_cache_key(), b.embedder_cache_key());
876 assert!(a.embedder_cache_key().contains("KEY_A"));
877 }
878
879 #[test]
880 fn fingerprint_fields_cannot_collide_through_delimiters() {
881 let endpoint = |url: &str, model: &str, query_prefix: &str, doc_prefix: Option<&str>| {
882 EmbeddingModel::Endpoint {
883 url: url.into(),
884 model: model.into(),
885 api_key_env: None,
886 query_prefix: Some(query_prefix.into()),
887 doc_prefix: doc_prefix.map(str::to_string),
888 }
889 };
890
891 assert_ne!(
892 endpoint("https://example.test#a", "b", "", None).configured_fingerprint(),
893 endpoint("https://example.test", "a#b", "", None).configured_fingerprint()
894 );
895 assert_ne!(
896 endpoint("u", "m", "x|d=y", None).configured_fingerprint(),
897 endpoint("u", "m", "x", Some("y")).configured_fingerprint()
898 );
899 }
900
901 #[test]
902 fn endpoint_cache_key_distinguishes_no_key_from_literal_sentinel_name() {
903 let endpoint = |api_key_env| EmbeddingModel::Endpoint {
904 url: "u".into(),
905 model: "m".into(),
906 api_key_env,
907 query_prefix: None,
908 doc_prefix: None,
909 };
910
911 assert_ne!(
912 endpoint(None).embedder_cache_key(),
913 endpoint(Some("<none>".into())).embedder_cache_key()
914 );
915 }
916
917 #[test]
918 fn pooling_override_parses_and_is_rejected_on_endpoint() {
919 assert!(matches!(
921 EmbeddingModel::resolve(EmbeddingSpec {
922 huggingface: Some("org/m".into()),
923 pooling: Some("mean".into()),
924 ..Default::default()
925 })
926 .unwrap(),
927 EmbeddingModel::HuggingFace {
928 pooling: Some(Pooling::Mean),
929 ..
930 }
931 ));
932 assert!(
934 EmbeddingModel::resolve(EmbeddingSpec {
935 huggingface: Some("org/m".into()),
936 pooling: Some("median".into()),
937 ..Default::default()
938 })
939 .is_err()
940 );
941 let err = EmbeddingModel::resolve(EmbeddingSpec {
943 ollama: Some("nomic".into()),
944 pooling: Some("mean".into()),
945 ..Default::default()
946 })
947 .unwrap_err();
948 assert!(err.to_string().contains("pooling"), "got: {err}");
949 }
950
951 #[test]
952 fn doc_prefix_threads_through_and_affects_fingerprint() {
953 let m = EmbeddingModel::resolve(EmbeddingSpec {
954 huggingface: Some("intfloat/e5-small-v2".into()),
955 query_prefix: Some("query: ".into()),
956 doc_prefix: Some("passage: ".into()),
957 ..Default::default()
958 })
959 .unwrap();
960 assert_eq!(m.doc_prefix(), "passage: ");
961 assert!(m.configured_fingerprint().contains("|d=9:passage: "));
962 let cls = EmbeddingModel::resolve(EmbeddingSpec {
964 huggingface: Some("org/m".into()),
965 pooling: Some("cls".into()),
966 ..Default::default()
967 })
968 .unwrap();
969 let mean = EmbeddingModel::resolve(EmbeddingSpec {
970 huggingface: Some("org/m".into()),
971 pooling: Some("mean".into()),
972 ..Default::default()
973 })
974 .unwrap();
975 assert_ne!(cls.configured_fingerprint(), mean.configured_fingerprint());
976 }
977}