1#![allow(clippy::cast_possible_wrap)]
23
24use serde::{Deserialize, Serialize};
25use std::time::Duration;
26use wm_core::{CoreError, Result};
27
28pub trait Embedder: Send + Sync {
34 fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>;
38
39 fn embed(&self, text: &str) -> Result<Vec<f32>> {
41 self.embed_batch(&[text])?
42 .into_iter()
43 .next()
44 .ok_or_else(|| CoreError::Memory("embedder returned empty result".into()))
45 }
46
47 fn embed_query(&self, query: &str) -> Result<Vec<f32>> {
49 self.embed(query)
50 }
51
52 fn dimension(&self) -> usize;
54
55 fn is_available(&self) -> bool;
57
58 fn backend_name(&self) -> &'static str;
60
61 fn cache_namespace(&self) -> String {
67 self.backend_name().to_string()
68 }
69
70 fn preferred_max_batch_texts(&self) -> usize {
77 usize::MAX
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct EmbedderConfig {
84 pub endpoint: String,
86 pub model: String,
88 pub dimension: usize,
90 pub timeout: Duration,
92}
93
94impl EmbedderConfig {
95 #[must_use]
100 pub fn from_env() -> Option<Self> {
101 let endpoint = std::env::var("WM_EMBEDDER_ENDPOINT").ok()?;
102 if !is_endpoint_safe(&endpoint) {
103 tracing::warn!(
104 "embedder endpoint rejected by SSRF validation: {}",
105 endpoint
106 );
107 return None;
108 }
109 let model = std::env::var("WM_EMBEDDER_MODEL").unwrap_or_else(|_| "local".into());
110 let dimension = std::env::var("WM_EMBEDDER_DIM")
111 .ok()
112 .and_then(|v| v.parse::<usize>().ok())
113 .unwrap_or(384);
114 let timeout_ms = std::env::var("WM_EMBEDDER_TIMEOUT_MS")
115 .ok()
116 .and_then(|v| v.parse::<u64>().ok())
117 .unwrap_or(30_000);
118
119 Some(Self {
120 endpoint,
121 model,
122 dimension,
123 timeout: Duration::from_millis(timeout_ms),
124 })
125 }
126}
127
128#[must_use]
136pub fn is_endpoint_safe(endpoint: &str) -> bool {
137 if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
139 return false;
140 }
141
142 let without_scheme = endpoint
144 .strip_prefix("http://")
145 .or_else(|| endpoint.strip_prefix("https://"))
146 .unwrap_or(endpoint);
147
148 let host_end = without_scheme
149 .find(['/', '?', '#'])
150 .unwrap_or(without_scheme.len());
151 let host_port = &without_scheme[..host_end];
152
153 let host = if host_port.starts_with('[') {
155 if let Some(end) = host_port.find(']') {
156 &host_port[1..end]
157 } else {
158 return false; }
160 } else {
161 host_port.rsplit_once(':').map_or(host_port, |(h, _)| h)
162 };
163
164 if host.is_empty() {
165 return false;
166 }
167
168 let lower = host.to_ascii_lowercase();
170 if matches!(
171 lower.as_str(),
172 "metadata.google.internal"
173 | "metadata.aws.internal"
174 | "metadata"
175 | "169.254.169.254"
176 | "169.254.170.2"
177 ) {
178 return false;
179 }
180
181 true
182}
183
184pub struct HttpEmbedder {
190 config: EmbedderConfig,
191 agent: ureq::Agent,
192 available: bool,
193}
194
195impl HttpEmbedder {
196 #[must_use]
198 pub fn new(config: EmbedderConfig) -> Self {
199 let agent = ureq::config::Config::builder()
200 .timeout_global(Some(config.timeout))
201 .build()
202 .new_agent();
203 Self {
204 config,
205 agent,
206 available: true,
207 }
208 }
209
210 #[must_use]
212 pub fn from_env() -> Option<Self> {
213 EmbedderConfig::from_env().map(Self::new)
214 }
215
216 fn embeddings_url(&self) -> String {
218 if self.config.endpoint.ends_with("/v1/embeddings") {
219 self.config.endpoint.clone()
220 } else if self.config.endpoint.ends_with('/') {
221 format!("{}v1/embeddings", self.config.endpoint)
222 } else {
223 format!("{}/v1/embeddings", self.config.endpoint)
224 }
225 }
226}
227
228const HTTP_EMBED_MAX_CHARS: usize = 1024;
235
236fn truncate_for_embedding(text: &str) -> &str {
238 if text.len() <= HTTP_EMBED_MAX_CHARS {
239 return text;
240 }
241 let mut end = HTTP_EMBED_MAX_CHARS;
242 while !text.is_char_boundary(end) {
243 end -= 1;
244 }
245 &text[..end]
246}
247
248impl Embedder for HttpEmbedder {
249 fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
250 if texts.is_empty() {
251 return Ok(Vec::new());
252 }
253
254 let url = self.embeddings_url();
255
256 let prepared: Vec<&str> = texts.iter().map(|t| truncate_for_embedding(t)).collect();
260 let truncated = texts
261 .iter()
262 .filter(|t| t.len() > HTTP_EMBED_MAX_CHARS)
263 .count();
264 if truncated > 0 {
265 tracing::debug!(
266 truncated,
267 budget_chars = HTTP_EMBED_MAX_CHARS,
268 "http embedder truncated oversized input(s) to the model window"
269 );
270 }
271
272 let request = EmbeddingsRequest {
274 model: &self.config.model,
275 input: &prepared,
276 };
277
278 let response = self
279 .agent
280 .post(&url)
281 .header("Content-Type", "application/json")
282 .send_json(&request)
283 .map_err(|e| CoreError::Memory(format!("Embedder HTTP error: {e}")))?;
284
285 let embed_resp: EmbeddingsResponse = response
286 .into_body()
287 .read_json()
288 .map_err(|e| CoreError::Memory(format!("Embedder response parse error: {e}")))?;
289
290 let vectors: Vec<Vec<f32>> = embed_resp.data.into_iter().map(|d| d.embedding).collect();
291
292 if vectors.len() != texts.len() {
293 return Err(CoreError::Memory(format!(
294 "Embedder returned {} vectors for {} inputs",
295 vectors.len(),
296 texts.len()
297 )));
298 }
299
300 Ok(vectors)
301 }
302
303 fn dimension(&self) -> usize {
304 self.config.dimension
305 }
306
307 fn is_available(&self) -> bool {
308 self.available
309 }
310
311 fn backend_name(&self) -> &'static str {
312 "http"
313 }
314
315 fn cache_namespace(&self) -> String {
316 format!(
322 "http:{}:{}:{}",
323 self.config.endpoint, self.config.model, self.config.dimension
324 )
325 }
326}
327
328#[derive(Debug, Serialize)]
330struct EmbeddingsRequest<'a> {
331 model: &'a str,
332 input: &'a [&'a str],
333}
334
335#[derive(Debug, Deserialize)]
337struct EmbeddingsResponse {
338 data: Vec<EmbeddingData>,
339}
340
341#[derive(Debug, Deserialize)]
342struct EmbeddingData {
343 embedding: Vec<f32>,
344}
345
346pub struct StubEmbedder {
352 dimension: usize,
353}
354
355impl StubEmbedder {
356 #[must_use]
358 pub const fn new(dimension: usize) -> Self {
359 Self { dimension }
360 }
361}
362
363impl Default for StubEmbedder {
364 fn default() -> Self {
365 Self::new(384)
366 }
367}
368
369impl Embedder for StubEmbedder {
370 fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
371 use sha2::{Digest, Sha256};
372
373 let mut results = Vec::with_capacity(texts.len());
374 for text in texts {
375 let mut hasher = Sha256::new();
376 hasher.update(text.as_bytes());
377 let hash = hasher.finalize();
378
379 let mut embedding = Vec::with_capacity(self.dimension);
381 for i in 0..self.dimension {
382 let byte = f32::from(hash[i % hash.len()]);
383 embedding.push(byte.mul_add(2.0 / 255.0, -1.0)); }
385 results.push(embedding);
386 }
387 Ok(results)
388 }
389
390 fn dimension(&self) -> usize {
391 self.dimension
392 }
393
394 fn is_available(&self) -> bool {
395 true
396 }
397
398 fn backend_name(&self) -> &'static str {
399 "stub"
400 }
401}
402
403#[cfg(feature = "onnx")]
423pub struct OrtEmbedder {
424 shards: Vec<std::sync::Mutex<Option<fastembed::TextEmbedding>>>,
430 model_name: String,
431 cache_dir: Option<std::path::PathBuf>,
432 threads: usize,
433 dimension: usize,
434 available: std::sync::atomic::AtomicBool,
435 batch_size: usize,
437}
438
439#[cfg(feature = "onnx")]
449fn default_intra_threads() -> usize {
450 std::thread::available_parallelism()
451 .map(std::num::NonZero::get)
452 .unwrap_or(4)
453 .min(4)
454}
455
456#[cfg(feature = "onnx")]
457impl OrtEmbedder {
458 #[must_use]
460 pub fn new(
461 model_name: &str,
462 cache_dir: Option<std::path::PathBuf>,
463 threads: usize,
464 dimension: usize,
465 ) -> Self {
466 let threads = threads.max(1);
467 let shard_count = (threads / 2).clamp(1, 4);
477 let shards = (0..shard_count)
478 .map(|_| std::sync::Mutex::new(None))
479 .collect();
480 Self {
481 shards,
482 model_name: model_name.to_string(),
483 cache_dir,
484 threads,
485 dimension,
486 available: std::sync::atomic::AtomicBool::new(false),
487 batch_size: 32,
488 }
489 }
490
491 #[must_use]
495 pub fn from_env() -> Option<Self> {
496 let model_name = std::env::var("WM_EMBEDDER_ORT_MODEL")
497 .unwrap_or_else(|_| "BAAI/bge-small-en-v1.5".into());
498 let cache_dir = std::env::var("WM_EMBEDDER_CACHE_DIR")
499 .ok()
500 .map(std::path::PathBuf::from);
501 let threads = std::env::var("WM_EMBEDDER_ORT_THREADS")
502 .ok()
503 .and_then(|v| v.parse::<usize>().ok())
504 .unwrap_or_else(default_intra_threads);
505 let dimension = std::env::var("WM_EMBEDDER_DIM")
506 .ok()
507 .and_then(|v| v.parse::<usize>().ok())
508 .unwrap_or(384);
509
510 Some(Self::new(&model_name, cache_dir, threads, dimension))
511 }
512
513 #[must_use]
520 pub fn with_shard_override(mut self, shards: usize) -> Self {
521 self.shards = (0..shards.max(1))
522 .map(|_| std::sync::Mutex::new(None))
523 .collect();
524 self
525 }
526
527 fn resolve_model(&self) -> Option<fastembed::EmbeddingModel> {
535 match self.model_name.as_str() {
536 "BAAI/bge-small-en-v1.5" | "bge-small-en-v1.5" | "bge-small" => {
537 Some(fastembed::EmbeddingModel::BGESmallENV15)
538 }
539 "BAAI/bge-small-en-v1.5-q" | "bge-small-en-v1.5-q" | "bge-small-q" => {
540 Some(fastembed::EmbeddingModel::BGESmallENV15Q)
541 }
542 "BAAI/bge-base-en-v1.5" | "bge-base-en-v1.5" | "bge-base" => {
543 Some(fastembed::EmbeddingModel::BGEBaseENV15)
544 }
545 "BAAI/bge-base-en-v1.5-q" | "bge-base-en-v1.5-q" | "bge-base-q" => {
546 Some(fastembed::EmbeddingModel::BGEBaseENV15Q)
547 }
548 "BAAI/bge-large-en-v1.5" | "bge-large-en-v1.5" | "bge-large" => {
549 Some(fastembed::EmbeddingModel::BGELargeENV15)
550 }
551 "sentence-transformers/all-MiniLM-L6-v2" | "all-MiniLM-L6-v2" | "minilm" => {
552 Some(fastembed::EmbeddingModel::AllMiniLML6V2)
553 }
554 "sentence-transformers/all-MiniLM-L6-v2-q" | "all-MiniLM-L6-v2-q" | "minilm-q" => {
555 Some(fastembed::EmbeddingModel::AllMiniLML6V2Q)
556 }
557 "sentence-transformers/all-MiniLM-L12-v2" | "all-MiniLM-L12-v2" => {
558 Some(fastembed::EmbeddingModel::AllMiniLML12V2)
559 }
560 "nomic-ai/nomic-embed-text-v1.5" | "nomic-embed-text-v1.5" | "nomic" => {
561 Some(fastembed::EmbeddingModel::NomicEmbedTextV15)
562 }
563 _ => {
564 tracing::warn!(
565 "unknown embedder model '{}', falling back to bge-small-en-v1.5",
566 self.model_name
567 );
568 Some(fastembed::EmbeddingModel::BGESmallENV15)
569 }
570 }
571 }
572
573 #[cfg(feature = "onnx")]
576 #[must_use]
577 pub const fn threads(&self) -> usize {
578 self.threads
579 }
580
581 #[cfg(feature = "onnx")]
583 #[must_use]
584 pub fn shard_count(&self) -> usize {
585 self.shards.len()
586 }
587
588 #[cfg(feature = "onnx")]
590 #[must_use]
591 fn intra_per_shard(&self) -> usize {
592 (self.threads / self.shards.len().max(1)).max(1)
593 }
594
595 fn ensure_loaded(&self) -> bool {
598 if self.available.load(std::sync::atomic::Ordering::Relaxed) {
600 return true;
601 }
602
603 let intra = self.intra_per_shard();
604 let mut loaded = Vec::with_capacity(self.shards.len());
605 for _ in 0..self.shards.len() {
606 let Some(embedding_model) = self.resolve_model() else {
607 self.available
608 .store(false, std::sync::atomic::Ordering::Relaxed);
609 return false;
610 };
611 let mut options = fastembed::TextInitOptions::new(embedding_model)
612 .with_show_download_progress(false)
613 .with_intra_threads(intra);
614
615 if let Some(ref cache_dir) = self.cache_dir {
616 options = options.with_cache_dir(cache_dir.clone());
617 }
618
619 match fastembed::TextEmbedding::try_new(options) {
620 Ok(model) => loaded.push(Some(model)),
621 Err(e) => {
622 tracing::warn!("failed to load ONNX embedding model: {e}");
623 self.available
624 .store(false, std::sync::atomic::Ordering::Relaxed);
625 return false;
626 }
627 }
628 }
629
630 {
631 for (shard, model) in self.shards.iter().zip(loaded) {
632 let mut guard = shard.lock().expect("shard mutex poisoned");
633 *guard = model;
634 }
635 }
636 self.available
637 .store(true, std::sync::atomic::Ordering::Relaxed);
638 tracing::info!(
639 "ONNX embedding model loaded: {} ({} session shards × {} intra threads, dim={})",
640 self.model_name,
641 self.shards.len(),
642 intra,
643 self.dimension
644 );
645 true
646 }
647}
648
649#[cfg(feature = "onnx")]
650impl Embedder for OrtEmbedder {
651 fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
652 if texts.is_empty() {
653 return Ok(Vec::new());
654 }
655
656 if !self.ensure_loaded() {
657 return Err(CoreError::Memory(
658 "ONNX embedder model not available".into(),
659 ));
660 }
661
662 let owned_texts: Vec<String> = texts.iter().map(|t| (*t).to_string()).collect();
663
664 if owned_texts.len() == 1 {
666 let mut guard = self.shards[0].lock().expect("shard mutex poisoned");
667 let Some(ref mut model) = guard.as_mut() else {
668 return Err(CoreError::Memory("ONNX embedder model not loaded".into()));
669 };
670 let embeddings = model
671 .embed(owned_texts, Some(self.batch_size))
672 .map_err(|e| CoreError::Memory(format!("ONNX embedding error: {e}")))?;
673 return Ok(embeddings);
674 }
675
676 let shard_count = self.shards.len();
679 let mut split: Vec<Vec<String>> = vec![Vec::new(); shard_count];
680 for (i, text) in owned_texts.into_iter().enumerate() {
681 split[i % shard_count].push(text);
682 }
683
684 let results: Vec<Result<Vec<Vec<f32>>>> = std::thread::scope(|scope| {
685 let handles: Vec<_> = split
686 .into_iter()
687 .zip(&self.shards)
688 .map(|(subset, shard)| {
689 scope.spawn(move || {
690 let mut guard = shard.lock().expect("shard mutex poisoned");
691 let Some(ref mut model) = guard.as_mut() else {
692 return Err(CoreError::Memory("ONNX embedder model not loaded".into()));
693 };
694 model
695 .embed(subset, Some(self.batch_size))
696 .map_err(|e| CoreError::Memory(format!("ONNX embedding error: {e}")))
697 })
698 })
699 .collect();
700 handles
701 .into_iter()
702 .map(|h| h.join().expect("shard worker panicked"))
703 .collect()
704 });
705
706 let mut per_shard: Vec<std::collections::VecDeque<Vec<f32>>> = results
710 .into_iter()
711 .map(|r| {
712 r.map(|vectors| {
713 vectors
714 .into_iter()
715 .collect::<std::collections::VecDeque<_>>()
716 })
717 })
718 .collect::<Result<Vec<_>>>()?;
719 let mut out: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
720 for i in 0..texts.len() {
721 let shard_idx = i % shard_count;
722 let Some(vector) = per_shard[shard_idx].pop_front() else {
723 return Err(CoreError::Memory(format!(
724 "embed_batch shard {shard_idx} returned too few vectors"
725 )));
726 };
727 out.push(vector);
728 }
729
730 if out.len() != texts.len() {
731 return Err(CoreError::Memory(format!(
732 "embed_batch returned {} vectors for {} inputs",
733 out.len(),
734 texts.len()
735 )));
736 }
737 Ok(out)
738 }
739
740 fn dimension(&self) -> usize {
741 self.dimension
742 }
743
744 fn is_available(&self) -> bool {
745 self.ensure_loaded()
746 }
747
748 fn backend_name(&self) -> &'static str {
749 "onnx"
750 }
751
752 fn cache_namespace(&self) -> String {
753 format!("onnx:{}:{}", self.model_name, self.dimension)
756 }
757
758 fn preferred_max_batch_texts(&self) -> usize {
759 128
762 }
763}
764
765#[must_use]
776pub fn create_embedder() -> Box<dyn Embedder> {
777 #[cfg(feature = "onnx")]
778 {
779 let prefer_ort = std::env::var("WM_EMBEDDER_BACKEND")
780 .map(|v| v == "onnx" || v == "ort")
781 .unwrap_or(false);
782
783 if prefer_ort {
784 if let Some(mut ort) = OrtEmbedder::from_env() {
785 if let Ok(shards) = std::env::var("WM_EMBEDDER_ORT_SHARDS") {
786 if let Ok(n) = shards.parse::<usize>() {
787 ort = ort.with_shard_override(n);
788 }
789 }
790 tracing::info!(
791 "onnx embedder configured (dim={}, shards={})",
792 ort.dimension(),
793 ort.shard_count()
794 );
795 return Box::new(ort);
796 }
797 }
798 }
799
800 if let Some(http) = HttpEmbedder::from_env() {
801 tracing::info!("http embedder configured (dim={})", http.dimension());
802 return Box::new(http);
803 }
804
805 #[cfg(feature = "onnx")]
806 {
807 if let Some(ort) = OrtEmbedder::from_env() {
809 tracing::info!(
810 "onnx embedder configured as default (dim={})",
811 ort.dimension()
812 );
813 return Box::new(ort);
814 }
815 }
816
817 tracing::info!("no embedder endpoint configured, using stub embedder");
818 Box::new(StubEmbedder::default())
819}
820
821#[cfg(test)]
822mod tests {
823 use super::*;
824
825 #[test]
828 fn stub_embedder_dimension() {
829 let embedder = StubEmbedder::new(128);
830 assert_eq!(embedder.dimension(), 128);
831 }
832
833 #[test]
834 fn stub_embedder_single() {
835 let embedder = StubEmbedder::new(64);
836 let vec = embedder.embed("hello world").unwrap();
837 assert_eq!(vec.len(), 64);
838 for v in &vec {
840 assert!(*v >= -1.0 && *v <= 1.0);
841 }
842 }
843
844 #[test]
845 fn stub_embedder_batch() {
846 let embedder = StubEmbedder::new(32);
847 let texts = ["hello", "world", "test"];
848 let vectors = embedder.embed_batch(&texts).unwrap();
849 assert_eq!(vectors.len(), 3);
850 for v in &vectors {
851 assert_eq!(v.len(), 32);
852 }
853 }
854
855 #[test]
856 fn stub_embedder_deterministic() {
857 let embedder = StubEmbedder::new(64);
858 let v1 = embedder.embed("same text").unwrap();
859 let v2 = embedder.embed("same text").unwrap();
860 assert_eq!(v1, v2);
861 }
862
863 #[test]
864 fn stub_embedder_different_texts_differ() {
865 let embedder = StubEmbedder::new(64);
866 let v1 = embedder.embed("hello").unwrap();
867 let v2 = embedder.embed("world").unwrap();
868 assert_ne!(v1, v2);
869 }
870
871 #[test]
872 fn stub_embedder_empty_batch() {
873 let embedder = StubEmbedder::new(64);
874 let vectors = embedder.embed_batch(&[]).unwrap();
875 assert!(vectors.is_empty());
876 }
877
878 #[test]
879 fn stub_embedder_is_available() {
880 let embedder = StubEmbedder::new(64);
881 assert!(embedder.is_available());
882 }
883
884 #[test]
885 fn stub_embedder_backend_name() {
886 let embedder = StubEmbedder::new(64);
887 assert_eq!(embedder.backend_name(), "stub");
888 }
889
890 #[test]
891 fn stub_embedder_default_dimension() {
892 let embedder = StubEmbedder::default();
893 assert_eq!(embedder.dimension(), 384);
894 }
895
896 #[test]
899 fn http_embedder_config_from_env_absent() {
900 let config = EmbedderConfig {
902 endpoint: "http://localhost:8080".into(),
903 model: "local".into(),
904 dimension: 384,
905 timeout: Duration::from_secs(30),
906 };
907 assert_eq!(config.endpoint, "http://localhost:8080");
908 assert_eq!(config.model, "local");
909 assert_eq!(config.dimension, 384);
910 }
911
912 #[test]
913 fn http_embedder_embeddings_url() {
914 let config = EmbedderConfig {
915 endpoint: "http://localhost:8080".into(),
916 model: "local".into(),
917 dimension: 384,
918 timeout: Duration::from_secs(30),
919 };
920 let embedder = HttpEmbedder::new(config);
921 assert_eq!(
922 embedder.embeddings_url(),
923 "http://localhost:8080/v1/embeddings"
924 );
925 }
926
927 #[test]
928 fn http_embedder_embeddings_url_trailing_slash() {
929 let config = EmbedderConfig {
930 endpoint: "http://localhost:8080/".into(),
931 model: "local".into(),
932 dimension: 384,
933 timeout: Duration::from_secs(30),
934 };
935 let embedder = HttpEmbedder::new(config);
936 assert_eq!(
937 embedder.embeddings_url(),
938 "http://localhost:8080/v1/embeddings"
939 );
940 }
941
942 #[test]
943 fn http_embedder_embeddings_url_full_path() {
944 let config = EmbedderConfig {
945 endpoint: "http://localhost:8080/v1/embeddings".into(),
946 model: "local".into(),
947 dimension: 384,
948 timeout: Duration::from_secs(30),
949 };
950 let embedder = HttpEmbedder::new(config);
951 assert_eq!(
952 embedder.embeddings_url(),
953 "http://localhost:8080/v1/embeddings"
954 );
955 }
956
957 #[test]
958 fn http_embedder_dimension() {
959 let config = EmbedderConfig {
960 endpoint: "http://localhost:8080".into(),
961 model: "local".into(),
962 dimension: 768,
963 timeout: Duration::from_secs(10),
964 };
965 let embedder = HttpEmbedder::new(config);
966 assert_eq!(embedder.dimension(), 768);
967 }
968
969 #[test]
970 fn http_embedder_backend_name() {
971 let config = EmbedderConfig {
972 endpoint: "http://localhost:8080".into(),
973 model: "local".into(),
974 dimension: 384,
975 timeout: Duration::from_secs(10),
976 };
977 let embedder = HttpEmbedder::new(config);
978 assert_eq!(embedder.backend_name(), "http");
979 }
980
981 #[test]
982 fn http_embedder_is_available() {
983 let config = EmbedderConfig {
984 endpoint: "http://localhost:8080".into(),
985 model: "local".into(),
986 dimension: 384,
987 timeout: Duration::from_secs(10),
988 };
989 let embedder = HttpEmbedder::new(config);
990 assert!(embedder.is_available());
991 }
992
993 #[test]
994 fn http_embedder_cache_namespace_separates_models_and_dims() {
995 let make = |model: &str, dim: usize| {
996 HttpEmbedder::new(EmbedderConfig {
997 endpoint: "http://localhost:8080".into(),
998 model: model.into(),
999 dimension: dim,
1000 timeout: Duration::from_secs(10),
1001 })
1002 };
1003 let base = make("bge-small", 384).cache_namespace();
1004 assert_eq!(base, "http:http://localhost:8080:bge-small:384");
1005 assert_ne!(base, make("nomic", 384).cache_namespace());
1006 assert_ne!(base, make("bge-small", 768).cache_namespace());
1007 }
1008
1009 #[test]
1010 fn truncate_for_embedding_respects_budget_and_utf8_boundaries() {
1011 let short = "short text";
1012 assert_eq!(truncate_for_embedding(short), short);
1013
1014 let ascii = "a".repeat(HTTP_EMBED_MAX_CHARS + 500);
1015 assert_eq!(truncate_for_embedding(&ascii).len(), HTTP_EMBED_MAX_CHARS);
1016
1017 let multibyte = "🦀".repeat(HTTP_EMBED_MAX_CHARS);
1019 let truncated = truncate_for_embedding(&multibyte);
1020 assert!(truncated.len() <= HTTP_EMBED_MAX_CHARS);
1021 assert!(truncated.chars().all(|c| c == '🦀'));
1022 assert!(multibyte.starts_with(truncated));
1023 }
1024
1025 #[test]
1028 fn create_embedder_falls_back_to_stub() {
1029 let embedder = create_embedder();
1031 let name = embedder.backend_name();
1032 assert!(name == "stub" || name == "http" || name == "onnx");
1033 }
1034
1035 #[test]
1038 fn embedder_trait_object() {
1039 let embedder: Box<dyn Embedder> = Box::new(StubEmbedder::new(128));
1040 assert_eq!(embedder.dimension(), 128);
1041 let vec = embedder.embed("test").unwrap();
1042 assert_eq!(vec.len(), 128);
1043 }
1044
1045 #[test]
1046 fn embedder_embed_query() {
1047 let embedder = StubEmbedder::new(64);
1048 let v1 = embedder.embed("hello").unwrap();
1049 let v2 = embedder.embed_query("hello").unwrap();
1050 assert_eq!(v1, v2);
1051 }
1052
1053 #[cfg(feature = "onnx")]
1056 #[test]
1057 fn ort_embedder_backend_name() {
1058 let embedder = OrtEmbedder::new("bge-small", None, 2, 384);
1059 assert_eq!(embedder.backend_name(), "onnx");
1060 }
1061
1062 #[cfg(feature = "onnx")]
1063 #[test]
1064 fn ort_embedder_resolves_quantized_models() {
1065 let q = OrtEmbedder::new("bge-small-q", None, 2, 384);
1068 assert!(matches!(
1069 q.resolve_model(),
1070 Some(fastembed::EmbeddingModel::BGESmallENV15Q)
1071 ));
1072 let q_full = OrtEmbedder::new("BAAI/bge-small-en-v1.5-q", None, 2, 384);
1073 assert!(matches!(
1074 q_full.resolve_model(),
1075 Some(fastembed::EmbeddingModel::BGESmallENV15Q)
1076 ));
1077 let minilm_q = OrtEmbedder::new("minilm-q", None, 2, 384);
1078 assert!(matches!(
1079 minilm_q.resolve_model(),
1080 Some(fastembed::EmbeddingModel::AllMiniLML6V2Q)
1081 ));
1082 let fp32 = OrtEmbedder::new("bge-small", None, 2, 384);
1084 assert!(matches!(
1085 fp32.resolve_model(),
1086 Some(fastembed::EmbeddingModel::BGESmallENV15)
1087 ));
1088 }
1089
1090 #[cfg(feature = "onnx")]
1091 #[test]
1092 fn ort_embedder_default_threads_are_capped() {
1093 assert!(
1097 default_intra_threads() <= 4,
1098 "default threads must be capped at 4, got {}",
1099 default_intra_threads()
1100 );
1101 }
1102
1103 #[cfg(feature = "onnx")]
1104 #[test]
1105 fn ort_embedder_dimension() {
1106 let embedder = OrtEmbedder::new("bge-small", None, 2, 384);
1107 assert_eq!(embedder.dimension(), 384);
1108 }
1109
1110 #[cfg(feature = "onnx")]
1111 #[test]
1112 fn ort_embedder_dimension_custom() {
1113 let embedder = OrtEmbedder::new("minilm", None, 1, 256);
1114 assert_eq!(embedder.dimension(), 256);
1115 }
1116
1117 #[cfg(feature = "onnx")]
1118 #[test]
1119 fn ort_embedder_pool_shards_the_thread_budget() {
1120 for threads in [1usize, 2, 4, 8] {
1124 let embedder = OrtEmbedder::new("bge-small", None, threads, 384);
1125 assert_eq!(
1126 embedder.threads(),
1127 threads,
1128 "total budget must be preserved"
1129 );
1130 let shards = embedder.shard_count();
1131 assert!(shards >= 1, "at least one session");
1132 assert!(
1133 shards <= threads,
1134 "shards ({shards}) must not exceed the total budget ({threads})"
1135 );
1136 }
1137 let single = OrtEmbedder::new("bge-small", None, 1, 384);
1138 assert_eq!(single.shard_count(), 1, "threads=1 is the legacy shape");
1139 }
1140
1141 #[cfg(feature = "onnx")]
1142 #[test]
1143 fn ort_embedder_namespace_carries_model_and_dimension() {
1144 let small = OrtEmbedder::new("bge-small", None, 2, 384);
1146 let large = OrtEmbedder::new("bge-large", None, 2, 1024);
1147 assert_ne!(
1148 small.cache_namespace(),
1149 large.cache_namespace(),
1150 "different models must not share cache entries"
1151 );
1152 assert!(small.cache_namespace().contains("bge-small"));
1153 }
1154
1155 #[cfg(feature = "onnx")]
1160 #[test]
1161 #[ignore = "loads the real ONNX model; run explicitly for pool tuning"]
1162 fn ort_pool_shape_microbench() {
1163 let texts: Vec<String> = (0..128)
1164 .map(|i| {
1165 format!(
1166 "Session {i} of the deployment retrospective covered the rollout \
1167 schedule for the telemetry agent, the budget review outcomes, and \
1168 the follow-up decisions about the quarterly report timeline {i}."
1169 )
1170 })
1171 .collect();
1172 let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
1173
1174 let configs: Vec<(usize, Option<usize>)> = vec![
1176 (1, Some(1)),
1177 (2, Some(1)),
1178 (4, Some(1)),
1179 (4, None),
1180 (4, Some(2)),
1181 (8, None),
1182 (8, Some(4)),
1183 ];
1184 for (threads, shards) in configs {
1185 let mut embedder = OrtEmbedder::new("bge-small-q", None, threads, 384);
1186 if let Some(n) = shards {
1187 embedder = embedder.with_shard_override(n);
1188 }
1189 let warm = embedder.embed_batch(&refs[..1]).unwrap();
1191 assert_eq!(warm.len(), 1);
1192 let t0 = std::time::Instant::now();
1193 let out = embedder.embed_batch(&refs).unwrap();
1194 let dt = t0.elapsed();
1195 assert_eq!(out.len(), refs.len());
1196 println!(
1197 "pool shape: threads={threads} shards={} intra={} → {} texts in {:.2?} ({:.1} texts/s)",
1198 embedder.shard_count(),
1199 embedder.intra_per_shard(),
1200 refs.len(),
1201 dt,
1202 refs.len() as f64 / dt.as_secs_f64()
1203 );
1204
1205 let t1 = std::time::Instant::now();
1208 let mut total = 0usize;
1209 for chunk in refs.chunks(30) {
1210 total += embedder.embed_batch(chunk).unwrap().len();
1211 }
1212 let dt1 = t1.elapsed();
1213 assert_eq!(total, refs.len());
1214 println!(
1215 " ingest shape (30-text chunks): {:.2?} ({:.1} texts/s)",
1216 dt1,
1217 refs.len() as f64 / dt1.as_secs_f64()
1218 );
1219 }
1220 }
1221
1222 #[cfg(feature = "onnx")]
1226 #[test]
1227 #[ignore = "loads the real ONNX model; run explicitly as a pool-identity gate"]
1228 fn ort_pool_shapes_produce_identical_vectors() {
1229 let text = "determinism probe across session pool shapes";
1230 let single = OrtEmbedder::new("bge-small-q", None, 1, 384).with_shard_override(1);
1231 let pooled = OrtEmbedder::new("bge-small-q", None, 4, 384);
1232 let v1 = single.embed(text).unwrap();
1233 let v2 = pooled.embed(text).unwrap();
1234 assert_eq!(v1.len(), v2.len());
1235 let max_diff = v1
1236 .iter()
1237 .zip(v2.iter())
1238 .map(|(a, b)| (a - b).abs())
1239 .fold(0.0f32, f32::max);
1240 assert!(
1241 max_diff < 1e-6,
1242 "pool shape changed the vector math (max diff {max_diff})"
1243 );
1244 }
1245
1246 #[cfg(feature = "onnx")]
1247 #[test]
1248 fn ort_embedder_empty_batch() {
1249 let embedder = OrtEmbedder::new("bge-small", None, 2, 384);
1250 let result = embedder.embed_batch(&[]).unwrap();
1251 assert!(result.is_empty());
1252 }
1253
1254 #[cfg(feature = "onnx")]
1255 #[test]
1256 fn ort_embedder_trait_object() {
1257 let embedder: Box<dyn Embedder> = Box::new(OrtEmbedder::new("bge-small", None, 2, 384));
1258 assert_eq!(embedder.dimension(), 384);
1259 assert_eq!(embedder.backend_name(), "onnx");
1260 }
1261
1262 #[cfg(feature = "onnx")]
1263 #[test]
1264 fn ort_embedder_resolve_model_known() {
1265 let embedder = OrtEmbedder::new("bge-small-en-v1.5", None, 2, 384);
1266 assert!(embedder.resolve_model().is_some());
1267 }
1268
1269 #[cfg(feature = "onnx")]
1270 #[test]
1271 fn ort_embedder_resolve_model_unknown_falls_back() {
1272 let embedder = OrtEmbedder::new("some-unknown-model", None, 2, 384);
1273 assert!(embedder.resolve_model().is_some());
1275 }
1276
1277 #[test]
1280 fn endpoint_safe_allows_localhost() {
1281 assert!(is_endpoint_safe("http://localhost:8080"));
1282 assert!(is_endpoint_safe("http://127.0.0.1:8080"));
1283 assert!(is_endpoint_safe("http://localhost:11434/v1/embeddings"));
1284 }
1285
1286 #[test]
1287 fn endpoint_safe_allows_private_ip() {
1288 assert!(is_endpoint_safe("http://10.0.0.2:8080"));
1289 assert!(is_endpoint_safe("http://192.168.1.100:8080"));
1290 assert!(is_endpoint_safe("http://172.16.0.5:8080"));
1291 }
1292
1293 #[test]
1294 fn endpoint_safe_blocks_non_http_schemes() {
1295 assert!(!is_endpoint_safe("file:///etc/passwd"));
1296 assert!(!is_endpoint_safe("gopher://localhost:8080"));
1297 assert!(!is_endpoint_safe("ftp://example.com"));
1298 assert!(!is_endpoint_safe("javascript:alert(1)"));
1299 assert!(!is_endpoint_safe("data:text/plain,hello"));
1300 }
1301
1302 #[test]
1303 fn endpoint_safe_blocks_metadata_endpoints() {
1304 assert!(!is_endpoint_safe("http://169.254.169.254/latest/meta-data"));
1305 assert!(!is_endpoint_safe("http://169.254.170.2/v2/metadata"));
1306 assert!(!is_endpoint_safe(
1307 "http://metadata.google.internal/computeMetadata"
1308 ));
1309 assert!(!is_endpoint_safe("http://metadata.aws.internal"));
1310 assert!(!is_endpoint_safe("http://metadata"));
1311 }
1312
1313 #[test]
1314 fn endpoint_safe_blocks_empty_host() {
1315 assert!(!is_endpoint_safe("http://"));
1316 assert!(!is_endpoint_safe("http:///path"));
1317 }
1318
1319 #[test]
1320 fn endpoint_safe_blocks_malformed_ipv6() {
1321 assert!(!is_endpoint_safe("http://[::1:8080"));
1322 }
1323
1324 #[test]
1325 fn endpoint_safe_allows_https() {
1326 assert!(is_endpoint_safe("https://localhost:8080"));
1327 assert!(is_endpoint_safe("https://example.com/api"));
1328 }
1329
1330 #[test]
1331 fn endpoint_safe_allows_ipv6_loopback() {
1332 assert!(is_endpoint_safe("http://[::1]:8080"));
1333 }
1334}