1#![allow(clippy::cast_possible_wrap)]
24
25use serde::{Deserialize, Serialize};
26use std::time::Duration;
27use wm_core::{CoreError, Result};
28
29pub trait Embedder: Send + Sync {
35 fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>;
39
40 fn embed(&self, text: &str) -> Result<Vec<f32>> {
42 self.embed_batch(&[text])?
43 .into_iter()
44 .next()
45 .ok_or_else(|| CoreError::Memory("embedder returned empty result".into()))
46 }
47
48 fn embed_query(&self, query: &str) -> Result<Vec<f32>> {
50 self.embed(query)
51 }
52
53 fn dimension(&self) -> usize;
55
56 fn is_available(&self) -> bool;
58
59 fn backend_name(&self) -> &'static str;
61
62 fn cache_namespace(&self) -> String {
68 self.backend_name().to_string()
69 }
70
71 fn preferred_max_batch_texts(&self) -> usize {
78 usize::MAX
79 }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct EmbedderConfig {
85 pub endpoint: String,
87 pub model: String,
89 pub dimension: usize,
91 pub timeout: Duration,
93}
94
95impl EmbedderConfig {
96 #[must_use]
101 pub fn from_env() -> Option<Self> {
102 let endpoint = std::env::var("WM_EMBEDDER_ENDPOINT").ok()?;
103 if !is_endpoint_safe(&endpoint) {
104 tracing::warn!(
105 "embedder endpoint rejected by SSRF validation: {}",
106 endpoint
107 );
108 return None;
109 }
110 let model = std::env::var("WM_EMBEDDER_MODEL").unwrap_or_else(|_| "local".into());
111 let dimension = std::env::var("WM_EMBEDDER_DIM")
112 .ok()
113 .and_then(|v| v.parse::<usize>().ok())
114 .unwrap_or(384);
115 let timeout_ms = std::env::var("WM_EMBEDDER_TIMEOUT_MS")
116 .ok()
117 .and_then(|v| v.parse::<u64>().ok())
118 .unwrap_or(30_000);
119
120 Some(Self {
121 endpoint,
122 model,
123 dimension,
124 timeout: Duration::from_millis(timeout_ms),
125 })
126 }
127}
128
129#[must_use]
137pub fn is_endpoint_safe(endpoint: &str) -> bool {
138 if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
140 return false;
141 }
142
143 let without_scheme = endpoint
145 .strip_prefix("http://")
146 .or_else(|| endpoint.strip_prefix("https://"))
147 .unwrap_or(endpoint);
148
149 let host_end = without_scheme
150 .find(['/', '?', '#'])
151 .unwrap_or(without_scheme.len());
152 let host_port = &without_scheme[..host_end];
153
154 let host = if host_port.starts_with('[') {
156 if let Some(end) = host_port.find(']') {
157 &host_port[1..end]
158 } else {
159 return false; }
161 } else {
162 host_port.rsplit_once(':').map_or(host_port, |(h, _)| h)
163 };
164
165 if host.is_empty() {
166 return false;
167 }
168
169 let lower = host.to_ascii_lowercase();
171 if matches!(
172 lower.as_str(),
173 "metadata.google.internal"
174 | "metadata.aws.internal"
175 | "metadata"
176 | "169.254.169.254"
177 | "169.254.170.2"
178 ) {
179 return false;
180 }
181
182 true
183}
184
185pub struct HttpEmbedder {
191 config: EmbedderConfig,
192 agent: ureq::Agent,
193 available: bool,
194 concurrency: usize,
197}
198
199const HTTP_EMBED_CONCURRENCY_DEFAULT: usize = 4;
201
202fn http_concurrency_from_env() -> usize {
203 std::env::var("WM_EMBEDDER_HTTP_CONCURRENCY")
204 .ok()
205 .and_then(|v| v.parse::<usize>().ok())
206 .filter(|n| *n >= 1)
207 .unwrap_or(HTTP_EMBED_CONCURRENCY_DEFAULT)
208}
209
210impl HttpEmbedder {
211 #[must_use]
213 pub fn new(config: EmbedderConfig) -> Self {
214 let agent = ureq::config::Config::builder()
215 .timeout_global(Some(config.timeout))
216 .build()
217 .new_agent();
218 Self {
219 config,
220 agent,
221 available: true,
222 concurrency: http_concurrency_from_env(),
223 }
224 }
225
226 #[must_use]
228 pub fn with_concurrency(mut self, concurrency: usize) -> Self {
229 self.concurrency = concurrency.max(1);
230 self
231 }
232
233 #[must_use]
235 pub fn from_env() -> Option<Self> {
236 EmbedderConfig::from_env().map(Self::new)
237 }
238
239 fn embeddings_url(&self) -> String {
241 if self.config.endpoint.ends_with("/v1/embeddings") {
242 self.config.endpoint.clone()
243 } else if self.config.endpoint.ends_with('/') {
244 format!("{}v1/embeddings", self.config.endpoint)
245 } else {
246 format!("{}/v1/embeddings", self.config.endpoint)
247 }
248 }
249
250 fn embed_chunk(&self, url: &str, prepared: &[&str]) -> Result<Vec<Vec<f32>>> {
252 let request = EmbeddingsRequest {
253 model: &self.config.model,
254 input: prepared,
255 };
256
257 let response = self
258 .agent
259 .post(url)
260 .header("Content-Type", "application/json")
261 .send_json(&request)
262 .map_err(|e| CoreError::Memory(format!("Embedder HTTP error: {e}")))?;
263
264 let embed_resp: EmbeddingsResponse = response
265 .into_body()
266 .read_json()
267 .map_err(|e| CoreError::Memory(format!("Embedder response parse error: {e}")))?;
268
269 let vectors: Vec<Vec<f32>> = embed_resp.data.into_iter().map(|d| d.embedding).collect();
270 if vectors.len() != prepared.len() {
271 return Err(CoreError::Memory(format!(
272 "Embedder returned {} vectors for {} inputs",
273 vectors.len(),
274 prepared.len()
275 )));
276 }
277 Ok(vectors)
278 }
279}
280
281const HTTP_EMBED_MAX_CHARS: usize = 1024;
288
289fn truncate_for_embedding(text: &str) -> &str {
291 if text.len() <= HTTP_EMBED_MAX_CHARS {
292 return text;
293 }
294 let mut end = HTTP_EMBED_MAX_CHARS;
295 while !text.is_char_boundary(end) {
296 end -= 1;
297 }
298 &text[..end]
299}
300
301impl Embedder for HttpEmbedder {
302 fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
303 if texts.is_empty() {
304 return Ok(Vec::new());
305 }
306
307 let url = self.embeddings_url();
308
309 let prepared: Vec<&str> = texts.iter().map(|t| truncate_for_embedding(t)).collect();
313 let truncated = texts
314 .iter()
315 .filter(|t| t.len() > HTTP_EMBED_MAX_CHARS)
316 .count();
317 if truncated > 0 {
318 tracing::debug!(
319 truncated,
320 budget_chars = HTTP_EMBED_MAX_CHARS,
321 "http embedder truncated oversized input(s) to the model window"
322 );
323 }
324
325 let concurrency = self.concurrency.min(prepared.len());
332 if concurrency <= 1 {
333 let vectors = self.embed_chunk(&url, &prepared)?;
334 if vectors.len() != texts.len() {
335 return Err(CoreError::Memory(format!(
336 "Embedder returned {} vectors for {} inputs",
337 vectors.len(),
338 texts.len()
339 )));
340 }
341 return Ok(vectors);
342 }
343
344 let chunk_size = prepared.len().div_ceil(concurrency);
345 let mut results: Vec<Result<Vec<Vec<f32>>>> = Vec::with_capacity(concurrency);
346 std::thread::scope(|scope| {
347 let handles: Vec<_> = prepared
348 .chunks(chunk_size)
349 .map(|chunk| {
350 let url = &url;
351 scope.spawn(move || self.embed_chunk(url, chunk))
352 })
353 .collect();
354 for handle in handles {
355 results.push(handle.join().unwrap_or_else(|_| {
356 Err(CoreError::Memory("embedder fan-out thread panicked".into()))
357 }));
358 }
359 });
360
361 let mut vectors = Vec::with_capacity(texts.len());
362 for result in results {
363 vectors.extend(result?);
364 }
365 if vectors.len() != texts.len() {
366 return Err(CoreError::Memory(format!(
367 "Embedder returned {} vectors for {} inputs",
368 vectors.len(),
369 texts.len()
370 )));
371 }
372
373 Ok(vectors)
374 }
375
376 fn dimension(&self) -> usize {
377 self.config.dimension
378 }
379
380 fn is_available(&self) -> bool {
381 self.available
382 }
383
384 fn backend_name(&self) -> &'static str {
385 "http"
386 }
387
388 fn cache_namespace(&self) -> String {
389 format!(
395 "http:{}:{}:{}",
396 self.config.endpoint, self.config.model, self.config.dimension
397 )
398 }
399}
400
401#[derive(Debug, Serialize)]
403struct EmbeddingsRequest<'a> {
404 model: &'a str,
405 input: &'a [&'a str],
406}
407
408#[derive(Debug, Deserialize)]
410struct EmbeddingsResponse {
411 data: Vec<EmbeddingData>,
412}
413
414#[derive(Debug, Deserialize)]
415struct EmbeddingData {
416 embedding: Vec<f32>,
417}
418
419pub struct StubEmbedder {
425 dimension: usize,
426}
427
428impl StubEmbedder {
429 #[must_use]
431 pub const fn new(dimension: usize) -> Self {
432 Self { dimension }
433 }
434}
435
436impl Default for StubEmbedder {
437 fn default() -> Self {
438 Self::new(384)
439 }
440}
441
442impl Embedder for StubEmbedder {
443 fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
444 use sha2::{Digest, Sha256};
445
446 let mut results = Vec::with_capacity(texts.len());
447 for text in texts {
448 let mut hasher = Sha256::new();
449 hasher.update(text.as_bytes());
450 let hash = hasher.finalize();
451
452 let mut embedding = Vec::with_capacity(self.dimension);
454 for i in 0..self.dimension {
455 let byte = f32::from(hash[i % hash.len()]);
456 embedding.push(byte.mul_add(2.0 / 255.0, -1.0)); }
458 results.push(embedding);
459 }
460 Ok(results)
461 }
462
463 fn dimension(&self) -> usize {
464 self.dimension
465 }
466
467 fn is_available(&self) -> bool {
468 true
469 }
470
471 fn backend_name(&self) -> &'static str {
472 "stub"
473 }
474}
475
476#[cfg(feature = "onnx")]
496pub struct OrtEmbedder {
497 shards: Vec<std::sync::Mutex<Option<fastembed::TextEmbedding>>>,
503 model_name: String,
504 cache_dir: Option<std::path::PathBuf>,
505 threads: usize,
506 dimension: usize,
507 available: std::sync::atomic::AtomicBool,
508 batch_size: usize,
510}
511
512#[cfg(feature = "onnx")]
522fn default_intra_threads() -> usize {
523 std::thread::available_parallelism()
524 .map(std::num::NonZero::get)
525 .unwrap_or(4)
526 .min(4)
527}
528
529#[cfg(feature = "onnx")]
530impl OrtEmbedder {
531 #[must_use]
533 pub fn new(
534 model_name: &str,
535 cache_dir: Option<std::path::PathBuf>,
536 threads: usize,
537 dimension: usize,
538 ) -> Self {
539 let threads = threads.max(1);
540 let shard_count = (threads / 2).clamp(1, 4);
550 let shards = (0..shard_count)
551 .map(|_| std::sync::Mutex::new(None))
552 .collect();
553 Self {
554 shards,
555 model_name: model_name.to_string(),
556 cache_dir,
557 threads,
558 dimension,
559 available: std::sync::atomic::AtomicBool::new(false),
560 batch_size: 32,
561 }
562 }
563
564 #[must_use]
568 pub fn from_env() -> Option<Self> {
569 let model_name = std::env::var("WM_EMBEDDER_ORT_MODEL")
570 .unwrap_or_else(|_| "BAAI/bge-small-en-v1.5".into());
571 let cache_dir = std::env::var("WM_EMBEDDER_CACHE_DIR")
572 .ok()
573 .map(std::path::PathBuf::from);
574 let threads = std::env::var("WM_EMBEDDER_ORT_THREADS")
575 .ok()
576 .and_then(|v| v.parse::<usize>().ok())
577 .unwrap_or_else(default_intra_threads);
578 let dimension = std::env::var("WM_EMBEDDER_DIM")
579 .ok()
580 .and_then(|v| v.parse::<usize>().ok())
581 .unwrap_or(384);
582
583 Some(Self::new(&model_name, cache_dir, threads, dimension))
584 }
585
586 #[must_use]
593 pub fn with_shard_override(mut self, shards: usize) -> Self {
594 self.shards = (0..shards.max(1))
595 .map(|_| std::sync::Mutex::new(None))
596 .collect();
597 self
598 }
599
600 fn resolve_model(&self) -> Option<fastembed::EmbeddingModel> {
608 match self.model_name.as_str() {
609 "BAAI/bge-small-en-v1.5" | "bge-small-en-v1.5" | "bge-small" => {
610 Some(fastembed::EmbeddingModel::BGESmallENV15)
611 }
612 "BAAI/bge-small-en-v1.5-q" | "bge-small-en-v1.5-q" | "bge-small-q" => {
613 Some(fastembed::EmbeddingModel::BGESmallENV15Q)
614 }
615 "BAAI/bge-base-en-v1.5" | "bge-base-en-v1.5" | "bge-base" => {
616 Some(fastembed::EmbeddingModel::BGEBaseENV15)
617 }
618 "BAAI/bge-base-en-v1.5-q" | "bge-base-en-v1.5-q" | "bge-base-q" => {
619 Some(fastembed::EmbeddingModel::BGEBaseENV15Q)
620 }
621 "BAAI/bge-large-en-v1.5" | "bge-large-en-v1.5" | "bge-large" => {
622 Some(fastembed::EmbeddingModel::BGELargeENV15)
623 }
624 "sentence-transformers/all-MiniLM-L6-v2" | "all-MiniLM-L6-v2" | "minilm" => {
625 Some(fastembed::EmbeddingModel::AllMiniLML6V2)
626 }
627 "sentence-transformers/all-MiniLM-L6-v2-q" | "all-MiniLM-L6-v2-q" | "minilm-q" => {
628 Some(fastembed::EmbeddingModel::AllMiniLML6V2Q)
629 }
630 "sentence-transformers/all-MiniLM-L12-v2" | "all-MiniLM-L12-v2" => {
631 Some(fastembed::EmbeddingModel::AllMiniLML12V2)
632 }
633 "nomic-ai/nomic-embed-text-v1.5" | "nomic-embed-text-v1.5" | "nomic" => {
634 Some(fastembed::EmbeddingModel::NomicEmbedTextV15)
635 }
636 _ => {
637 tracing::warn!(
638 "unknown embedder model '{}', falling back to bge-small-en-v1.5",
639 self.model_name
640 );
641 Some(fastembed::EmbeddingModel::BGESmallENV15)
642 }
643 }
644 }
645
646 #[cfg(feature = "onnx")]
649 #[must_use]
650 pub const fn threads(&self) -> usize {
651 self.threads
652 }
653
654 #[cfg(feature = "onnx")]
656 #[must_use]
657 pub fn shard_count(&self) -> usize {
658 self.shards.len()
659 }
660
661 #[cfg(feature = "onnx")]
663 #[must_use]
664 fn intra_per_shard(&self) -> usize {
665 (self.threads / self.shards.len().max(1)).max(1)
666 }
667
668 fn ensure_loaded(&self) -> bool {
671 if self.available.load(std::sync::atomic::Ordering::Relaxed) {
673 return true;
674 }
675
676 let intra = self.intra_per_shard();
677 let mut loaded = Vec::with_capacity(self.shards.len());
678 for _ in 0..self.shards.len() {
679 let Some(embedding_model) = self.resolve_model() else {
680 self.available
681 .store(false, std::sync::atomic::Ordering::Relaxed);
682 return false;
683 };
684 let mut options = fastembed::TextInitOptions::new(embedding_model)
685 .with_show_download_progress(false)
686 .with_intra_threads(intra);
687
688 if let Some(ref cache_dir) = self.cache_dir {
689 options = options.with_cache_dir(cache_dir.clone());
690 }
691
692 match fastembed::TextEmbedding::try_new(options) {
693 Ok(model) => loaded.push(Some(model)),
694 Err(e) => {
695 tracing::warn!("failed to load ONNX embedding model: {e}");
696 self.available
697 .store(false, std::sync::atomic::Ordering::Relaxed);
698 return false;
699 }
700 }
701 }
702
703 {
704 for (shard, model) in self.shards.iter().zip(loaded) {
705 let mut guard = shard.lock().expect("shard mutex poisoned");
706 *guard = model;
707 }
708 }
709 self.available
710 .store(true, std::sync::atomic::Ordering::Relaxed);
711 tracing::info!(
712 "ONNX embedding model loaded: {} ({} session shards × {} intra threads, dim={})",
713 self.model_name,
714 self.shards.len(),
715 intra,
716 self.dimension
717 );
718 true
719 }
720}
721
722#[cfg(feature = "onnx")]
723impl Embedder for OrtEmbedder {
724 fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
725 if texts.is_empty() {
726 return Ok(Vec::new());
727 }
728
729 if !self.ensure_loaded() {
730 return Err(CoreError::Memory(
731 "ONNX embedder model not available".into(),
732 ));
733 }
734
735 let owned_texts: Vec<String> = texts.iter().map(|t| (*t).to_string()).collect();
736
737 if owned_texts.len() == 1 {
739 let mut guard = self.shards[0].lock().expect("shard mutex poisoned");
740 let Some(ref mut model) = guard.as_mut() else {
741 return Err(CoreError::Memory("ONNX embedder model not loaded".into()));
742 };
743 let embeddings = model
744 .embed(owned_texts, Some(self.batch_size))
745 .map_err(|e| CoreError::Memory(format!("ONNX embedding error: {e}")))?;
746 return Ok(embeddings);
747 }
748
749 let shard_count = self.shards.len();
752 let mut split: Vec<Vec<String>> = vec![Vec::new(); shard_count];
753 for (i, text) in owned_texts.into_iter().enumerate() {
754 split[i % shard_count].push(text);
755 }
756
757 let results: Vec<Result<Vec<Vec<f32>>>> = std::thread::scope(|scope| {
758 let handles: Vec<_> = split
759 .into_iter()
760 .zip(&self.shards)
761 .map(|(subset, shard)| {
762 scope.spawn(move || {
763 let mut guard = shard.lock().expect("shard mutex poisoned");
764 let Some(ref mut model) = guard.as_mut() else {
765 return Err(CoreError::Memory("ONNX embedder model not loaded".into()));
766 };
767 model
768 .embed(subset, Some(self.batch_size))
769 .map_err(|e| CoreError::Memory(format!("ONNX embedding error: {e}")))
770 })
771 })
772 .collect();
773 handles
774 .into_iter()
775 .map(|h| h.join().expect("shard worker panicked"))
776 .collect()
777 });
778
779 let mut per_shard: Vec<std::collections::VecDeque<Vec<f32>>> = results
783 .into_iter()
784 .map(|r| {
785 r.map(|vectors| {
786 vectors
787 .into_iter()
788 .collect::<std::collections::VecDeque<_>>()
789 })
790 })
791 .collect::<Result<Vec<_>>>()?;
792 let mut out: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
793 for i in 0..texts.len() {
794 let shard_idx = i % shard_count;
795 let Some(vector) = per_shard[shard_idx].pop_front() else {
796 return Err(CoreError::Memory(format!(
797 "embed_batch shard {shard_idx} returned too few vectors"
798 )));
799 };
800 out.push(vector);
801 }
802
803 if out.len() != texts.len() {
804 return Err(CoreError::Memory(format!(
805 "embed_batch returned {} vectors for {} inputs",
806 out.len(),
807 texts.len()
808 )));
809 }
810 Ok(out)
811 }
812
813 fn dimension(&self) -> usize {
814 self.dimension
815 }
816
817 fn is_available(&self) -> bool {
818 self.ensure_loaded()
819 }
820
821 fn backend_name(&self) -> &'static str {
822 "onnx"
823 }
824
825 fn cache_namespace(&self) -> String {
826 format!("onnx:{}:{}", self.model_name, self.dimension)
829 }
830
831 fn preferred_max_batch_texts(&self) -> usize {
832 128
835 }
836}
837
838#[must_use]
849pub fn create_embedder() -> Box<dyn Embedder> {
850 #[cfg(feature = "onnx")]
851 {
852 let prefer_ort = std::env::var("WM_EMBEDDER_BACKEND")
853 .map(|v| v == "onnx" || v == "ort")
854 .unwrap_or(false);
855
856 if prefer_ort {
857 if let Some(mut ort) = OrtEmbedder::from_env() {
858 if let Ok(shards) = std::env::var("WM_EMBEDDER_ORT_SHARDS") {
859 if let Ok(n) = shards.parse::<usize>() {
860 ort = ort.with_shard_override(n);
861 }
862 }
863 tracing::info!(
864 "onnx embedder configured (dim={}, shards={})",
865 ort.dimension(),
866 ort.shard_count()
867 );
868 return Box::new(ort);
869 }
870 }
871 }
872
873 if let Some(http) = HttpEmbedder::from_env() {
874 tracing::info!("http embedder configured (dim={})", http.dimension());
875 return Box::new(http);
876 }
877
878 #[cfg(feature = "onnx")]
879 {
880 if let Some(ort) = OrtEmbedder::from_env() {
882 tracing::info!(
883 "onnx embedder configured as default (dim={})",
884 ort.dimension()
885 );
886 return Box::new(ort);
887 }
888 }
889
890 tracing::info!("no embedder endpoint configured, using stub embedder");
891 Box::new(StubEmbedder::default())
892}
893
894#[cfg(test)]
895mod tests {
896 use super::*;
897
898 #[test]
901 fn stub_embedder_dimension() {
902 let embedder = StubEmbedder::new(128);
903 assert_eq!(embedder.dimension(), 128);
904 }
905
906 #[test]
907 fn stub_embedder_single() {
908 let embedder = StubEmbedder::new(64);
909 let vec = embedder.embed("hello world").unwrap();
910 assert_eq!(vec.len(), 64);
911 for v in &vec {
913 assert!(*v >= -1.0 && *v <= 1.0);
914 }
915 }
916
917 #[test]
918 fn stub_embedder_batch() {
919 let embedder = StubEmbedder::new(32);
920 let texts = ["hello", "world", "test"];
921 let vectors = embedder.embed_batch(&texts).unwrap();
922 assert_eq!(vectors.len(), 3);
923 for v in &vectors {
924 assert_eq!(v.len(), 32);
925 }
926 }
927
928 #[test]
929 fn stub_embedder_deterministic() {
930 let embedder = StubEmbedder::new(64);
931 let v1 = embedder.embed("same text").unwrap();
932 let v2 = embedder.embed("same text").unwrap();
933 assert_eq!(v1, v2);
934 }
935
936 #[test]
937 fn stub_embedder_different_texts_differ() {
938 let embedder = StubEmbedder::new(64);
939 let v1 = embedder.embed("hello").unwrap();
940 let v2 = embedder.embed("world").unwrap();
941 assert_ne!(v1, v2);
942 }
943
944 #[test]
945 fn stub_embedder_empty_batch() {
946 let embedder = StubEmbedder::new(64);
947 let vectors = embedder.embed_batch(&[]).unwrap();
948 assert!(vectors.is_empty());
949 }
950
951 #[test]
952 fn stub_embedder_is_available() {
953 let embedder = StubEmbedder::new(64);
954 assert!(embedder.is_available());
955 }
956
957 #[test]
958 fn stub_embedder_backend_name() {
959 let embedder = StubEmbedder::new(64);
960 assert_eq!(embedder.backend_name(), "stub");
961 }
962
963 #[test]
964 fn stub_embedder_default_dimension() {
965 let embedder = StubEmbedder::default();
966 assert_eq!(embedder.dimension(), 384);
967 }
968
969 #[test]
972 fn http_embedder_config_from_env_absent() {
973 let config = EmbedderConfig {
975 endpoint: "http://localhost:8080".into(),
976 model: "local".into(),
977 dimension: 384,
978 timeout: Duration::from_secs(30),
979 };
980 assert_eq!(config.endpoint, "http://localhost:8080");
981 assert_eq!(config.model, "local");
982 assert_eq!(config.dimension, 384);
983 }
984
985 #[test]
986 fn http_embedder_embeddings_url() {
987 let config = EmbedderConfig {
988 endpoint: "http://localhost:8080".into(),
989 model: "local".into(),
990 dimension: 384,
991 timeout: Duration::from_secs(30),
992 };
993 let embedder = HttpEmbedder::new(config);
994 assert_eq!(
995 embedder.embeddings_url(),
996 "http://localhost:8080/v1/embeddings"
997 );
998 }
999
1000 #[test]
1001 fn http_embedder_embeddings_url_trailing_slash() {
1002 let config = EmbedderConfig {
1003 endpoint: "http://localhost:8080/".into(),
1004 model: "local".into(),
1005 dimension: 384,
1006 timeout: Duration::from_secs(30),
1007 };
1008 let embedder = HttpEmbedder::new(config);
1009 assert_eq!(
1010 embedder.embeddings_url(),
1011 "http://localhost:8080/v1/embeddings"
1012 );
1013 }
1014
1015 #[test]
1016 fn http_embedder_embeddings_url_full_path() {
1017 let config = EmbedderConfig {
1018 endpoint: "http://localhost:8080/v1/embeddings".into(),
1019 model: "local".into(),
1020 dimension: 384,
1021 timeout: Duration::from_secs(30),
1022 };
1023 let embedder = HttpEmbedder::new(config);
1024 assert_eq!(
1025 embedder.embeddings_url(),
1026 "http://localhost:8080/v1/embeddings"
1027 );
1028 }
1029
1030 #[test]
1031 fn http_embedder_dimension() {
1032 let config = EmbedderConfig {
1033 endpoint: "http://localhost:8080".into(),
1034 model: "local".into(),
1035 dimension: 768,
1036 timeout: Duration::from_secs(10),
1037 };
1038 let embedder = HttpEmbedder::new(config);
1039 assert_eq!(embedder.dimension(), 768);
1040 }
1041
1042 #[test]
1043 fn http_embedder_backend_name() {
1044 let config = EmbedderConfig {
1045 endpoint: "http://localhost:8080".into(),
1046 model: "local".into(),
1047 dimension: 384,
1048 timeout: Duration::from_secs(10),
1049 };
1050 let embedder = HttpEmbedder::new(config);
1051 assert_eq!(embedder.backend_name(), "http");
1052 }
1053
1054 #[test]
1055 fn http_embedder_is_available() {
1056 let config = EmbedderConfig {
1057 endpoint: "http://localhost:8080".into(),
1058 model: "local".into(),
1059 dimension: 384,
1060 timeout: Duration::from_secs(10),
1061 };
1062 let embedder = HttpEmbedder::new(config);
1063 assert!(embedder.is_available());
1064 }
1065
1066 #[test]
1067 fn http_embedder_fanout_preserves_order() {
1068 use std::io::{Read, Write};
1069 use std::net::TcpListener;
1070
1071 let texts = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"];
1072 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1073 let addr = listener.local_addr().unwrap();
1074
1075 let server = std::thread::spawn(move || {
1079 let mut served = 0usize;
1080 let deadline = std::time::Instant::now() + Duration::from_secs(10);
1081 while served < 3 && std::time::Instant::now() < deadline {
1082 listener.set_nonblocking(true).unwrap();
1083 let Ok((mut stream, _)) = listener.accept() else {
1084 std::thread::sleep(Duration::from_millis(5));
1085 continue;
1086 };
1087 stream.set_nonblocking(false).unwrap();
1088 stream
1089 .set_read_timeout(Some(Duration::from_secs(5)))
1090 .unwrap();
1091 let mut buf = Vec::new();
1092 let mut tmp = [0u8; 2048];
1093 let header_end = loop {
1094 let n = stream.read(&mut tmp).unwrap();
1095 buf.extend_from_slice(&tmp[..n]);
1096 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
1097 break pos + 4;
1098 }
1099 };
1100 let headers = String::from_utf8_lossy(&buf[..header_end]).to_ascii_lowercase();
1101 let content_length: usize = headers
1102 .lines()
1103 .find_map(|l| l.strip_prefix("content-length:"))
1104 .and_then(|v| v.trim().parse().ok())
1105 .unwrap_or(0);
1106 while buf.len() < header_end + content_length {
1107 let n = stream.read(&mut tmp).unwrap();
1108 buf.extend_from_slice(&tmp[..n]);
1109 }
1110 let req: serde_json::Value =
1111 serde_json::from_slice(&buf[header_end..header_end + content_length]).unwrap();
1112 let inputs = req["input"].as_array().unwrap();
1113 let data: Vec<_> = inputs
1114 .iter()
1115 .map(|v| {
1116 let s = v.as_str().unwrap();
1117 let idx = texts.iter().position(|t| *t == s).unwrap();
1118 serde_json::json!({"embedding": [idx as f32]})
1119 })
1120 .collect();
1121 let body = serde_json::json!({"data": data}).to_string();
1122 let response = format!(
1123 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
1124 body.len(),
1125 body
1126 );
1127 stream.write_all(response.as_bytes()).unwrap();
1128 served += 1;
1129 }
1130 served
1131 });
1132
1133 let embedder = HttpEmbedder::new(EmbedderConfig {
1134 endpoint: format!("http://{addr}"),
1135 model: "mock".into(),
1136 dimension: 1,
1137 timeout: Duration::from_secs(10),
1138 })
1139 .with_concurrency(3);
1140
1141 let vectors = embedder.embed_batch(&texts).unwrap();
1142 assert_eq!(vectors.len(), texts.len());
1143 for (i, v) in vectors.iter().enumerate() {
1144 assert_eq!(v, &vec![i as f32], "fan-out must preserve input order");
1145 }
1146 assert_eq!(
1147 server.join().unwrap(),
1148 3,
1149 "7 inputs at concurrency 3 must fan out into 3 requests"
1150 );
1151 }
1152
1153 #[test]
1154 fn http_embedder_cache_namespace_separates_models_and_dims() {
1155 let make = |model: &str, dim: usize| {
1156 HttpEmbedder::new(EmbedderConfig {
1157 endpoint: "http://localhost:8080".into(),
1158 model: model.into(),
1159 dimension: dim,
1160 timeout: Duration::from_secs(10),
1161 })
1162 };
1163 let base = make("bge-small", 384).cache_namespace();
1164 assert_eq!(base, "http:http://localhost:8080:bge-small:384");
1165 assert_ne!(base, make("nomic", 384).cache_namespace());
1166 assert_ne!(base, make("bge-small", 768).cache_namespace());
1167 }
1168
1169 #[test]
1170 fn truncate_for_embedding_respects_budget_and_utf8_boundaries() {
1171 let short = "short text";
1172 assert_eq!(truncate_for_embedding(short), short);
1173
1174 let ascii = "a".repeat(HTTP_EMBED_MAX_CHARS + 500);
1175 assert_eq!(truncate_for_embedding(&ascii).len(), HTTP_EMBED_MAX_CHARS);
1176
1177 let multibyte = "🦀".repeat(HTTP_EMBED_MAX_CHARS);
1179 let truncated = truncate_for_embedding(&multibyte);
1180 assert!(truncated.len() <= HTTP_EMBED_MAX_CHARS);
1181 assert!(truncated.chars().all(|c| c == '🦀'));
1182 assert!(multibyte.starts_with(truncated));
1183 }
1184
1185 #[test]
1188 fn create_embedder_falls_back_to_stub() {
1189 let embedder = create_embedder();
1191 let name = embedder.backend_name();
1192 assert!(name == "stub" || name == "http" || name == "onnx");
1193 }
1194
1195 #[test]
1198 fn embedder_trait_object() {
1199 let embedder: Box<dyn Embedder> = Box::new(StubEmbedder::new(128));
1200 assert_eq!(embedder.dimension(), 128);
1201 let vec = embedder.embed("test").unwrap();
1202 assert_eq!(vec.len(), 128);
1203 }
1204
1205 #[test]
1206 fn embedder_embed_query() {
1207 let embedder = StubEmbedder::new(64);
1208 let v1 = embedder.embed("hello").unwrap();
1209 let v2 = embedder.embed_query("hello").unwrap();
1210 assert_eq!(v1, v2);
1211 }
1212
1213 #[cfg(feature = "onnx")]
1216 #[test]
1217 fn ort_embedder_backend_name() {
1218 let embedder = OrtEmbedder::new("bge-small", None, 2, 384);
1219 assert_eq!(embedder.backend_name(), "onnx");
1220 }
1221
1222 #[cfg(feature = "onnx")]
1223 #[test]
1224 fn ort_embedder_resolves_quantized_models() {
1225 let q = OrtEmbedder::new("bge-small-q", None, 2, 384);
1228 assert!(matches!(
1229 q.resolve_model(),
1230 Some(fastembed::EmbeddingModel::BGESmallENV15Q)
1231 ));
1232 let q_full = OrtEmbedder::new("BAAI/bge-small-en-v1.5-q", None, 2, 384);
1233 assert!(matches!(
1234 q_full.resolve_model(),
1235 Some(fastembed::EmbeddingModel::BGESmallENV15Q)
1236 ));
1237 let minilm_q = OrtEmbedder::new("minilm-q", None, 2, 384);
1238 assert!(matches!(
1239 minilm_q.resolve_model(),
1240 Some(fastembed::EmbeddingModel::AllMiniLML6V2Q)
1241 ));
1242 let fp32 = OrtEmbedder::new("bge-small", None, 2, 384);
1244 assert!(matches!(
1245 fp32.resolve_model(),
1246 Some(fastembed::EmbeddingModel::BGESmallENV15)
1247 ));
1248 }
1249
1250 #[cfg(feature = "onnx")]
1251 #[test]
1252 fn ort_embedder_default_threads_are_capped() {
1253 assert!(
1257 default_intra_threads() <= 4,
1258 "default threads must be capped at 4, got {}",
1259 default_intra_threads()
1260 );
1261 }
1262
1263 #[cfg(feature = "onnx")]
1264 #[test]
1265 fn ort_embedder_dimension() {
1266 let embedder = OrtEmbedder::new("bge-small", None, 2, 384);
1267 assert_eq!(embedder.dimension(), 384);
1268 }
1269
1270 #[cfg(feature = "onnx")]
1271 #[test]
1272 fn ort_embedder_dimension_custom() {
1273 let embedder = OrtEmbedder::new("minilm", None, 1, 256);
1274 assert_eq!(embedder.dimension(), 256);
1275 }
1276
1277 #[cfg(feature = "onnx")]
1278 #[test]
1279 fn ort_embedder_pool_shards_the_thread_budget() {
1280 for threads in [1usize, 2, 4, 8] {
1284 let embedder = OrtEmbedder::new("bge-small", None, threads, 384);
1285 assert_eq!(
1286 embedder.threads(),
1287 threads,
1288 "total budget must be preserved"
1289 );
1290 let shards = embedder.shard_count();
1291 assert!(shards >= 1, "at least one session");
1292 assert!(
1293 shards <= threads,
1294 "shards ({shards}) must not exceed the total budget ({threads})"
1295 );
1296 }
1297 let single = OrtEmbedder::new("bge-small", None, 1, 384);
1298 assert_eq!(single.shard_count(), 1, "threads=1 is the legacy shape");
1299 }
1300
1301 #[cfg(feature = "onnx")]
1302 #[test]
1303 fn ort_embedder_namespace_carries_model_and_dimension() {
1304 let small = OrtEmbedder::new("bge-small", None, 2, 384);
1306 let large = OrtEmbedder::new("bge-large", None, 2, 1024);
1307 assert_ne!(
1308 small.cache_namespace(),
1309 large.cache_namespace(),
1310 "different models must not share cache entries"
1311 );
1312 assert!(small.cache_namespace().contains("bge-small"));
1313 }
1314
1315 #[cfg(feature = "onnx")]
1320 #[test]
1321 #[ignore = "loads the real ONNX model; run explicitly for pool tuning"]
1322 fn ort_pool_shape_microbench() {
1323 let texts: Vec<String> = (0..128)
1324 .map(|i| {
1325 format!(
1326 "Session {i} of the deployment retrospective covered the rollout \
1327 schedule for the telemetry agent, the budget review outcomes, and \
1328 the follow-up decisions about the quarterly report timeline {i}."
1329 )
1330 })
1331 .collect();
1332 let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
1333
1334 let configs: Vec<(usize, Option<usize>)> = vec![
1336 (1, Some(1)),
1337 (2, Some(1)),
1338 (4, Some(1)),
1339 (4, None),
1340 (4, Some(2)),
1341 (8, None),
1342 (8, Some(4)),
1343 ];
1344 for (threads, shards) in configs {
1345 let mut embedder = OrtEmbedder::new("bge-small-q", None, threads, 384);
1346 if let Some(n) = shards {
1347 embedder = embedder.with_shard_override(n);
1348 }
1349 let warm = embedder.embed_batch(&refs[..1]).unwrap();
1351 assert_eq!(warm.len(), 1);
1352 let t0 = std::time::Instant::now();
1353 let out = embedder.embed_batch(&refs).unwrap();
1354 let dt = t0.elapsed();
1355 assert_eq!(out.len(), refs.len());
1356 println!(
1357 "pool shape: threads={threads} shards={} intra={} → {} texts in {:.2?} ({:.1} texts/s)",
1358 embedder.shard_count(),
1359 embedder.intra_per_shard(),
1360 refs.len(),
1361 dt,
1362 refs.len() as f64 / dt.as_secs_f64()
1363 );
1364
1365 let t1 = std::time::Instant::now();
1368 let mut total = 0usize;
1369 for chunk in refs.chunks(30) {
1370 total += embedder.embed_batch(chunk).unwrap().len();
1371 }
1372 let dt1 = t1.elapsed();
1373 assert_eq!(total, refs.len());
1374 println!(
1375 " ingest shape (30-text chunks): {:.2?} ({:.1} texts/s)",
1376 dt1,
1377 refs.len() as f64 / dt1.as_secs_f64()
1378 );
1379 }
1380 }
1381
1382 #[cfg(feature = "onnx")]
1386 #[test]
1387 #[ignore = "loads the real ONNX model; run explicitly as a pool-identity gate"]
1388 fn ort_pool_shapes_produce_identical_vectors() {
1389 let text = "determinism probe across session pool shapes";
1390 let single = OrtEmbedder::new("bge-small-q", None, 1, 384).with_shard_override(1);
1391 let pooled = OrtEmbedder::new("bge-small-q", None, 4, 384);
1392 let v1 = single.embed(text).unwrap();
1393 let v2 = pooled.embed(text).unwrap();
1394 assert_eq!(v1.len(), v2.len());
1395 let max_diff = v1
1396 .iter()
1397 .zip(v2.iter())
1398 .map(|(a, b)| (a - b).abs())
1399 .fold(0.0f32, f32::max);
1400 assert!(
1401 max_diff < 1e-6,
1402 "pool shape changed the vector math (max diff {max_diff})"
1403 );
1404 }
1405
1406 #[cfg(feature = "onnx")]
1407 #[test]
1408 fn ort_embedder_empty_batch() {
1409 let embedder = OrtEmbedder::new("bge-small", None, 2, 384);
1410 let result = embedder.embed_batch(&[]).unwrap();
1411 assert!(result.is_empty());
1412 }
1413
1414 #[cfg(feature = "onnx")]
1415 #[test]
1416 fn ort_embedder_trait_object() {
1417 let embedder: Box<dyn Embedder> = Box::new(OrtEmbedder::new("bge-small", None, 2, 384));
1418 assert_eq!(embedder.dimension(), 384);
1419 assert_eq!(embedder.backend_name(), "onnx");
1420 }
1421
1422 #[cfg(feature = "onnx")]
1423 #[test]
1424 fn ort_embedder_resolve_model_known() {
1425 let embedder = OrtEmbedder::new("bge-small-en-v1.5", None, 2, 384);
1426 assert!(embedder.resolve_model().is_some());
1427 }
1428
1429 #[cfg(feature = "onnx")]
1430 #[test]
1431 fn ort_embedder_resolve_model_unknown_falls_back() {
1432 let embedder = OrtEmbedder::new("some-unknown-model", None, 2, 384);
1433 assert!(embedder.resolve_model().is_some());
1435 }
1436
1437 #[test]
1440 fn endpoint_safe_allows_localhost() {
1441 assert!(is_endpoint_safe("http://localhost:8080"));
1442 assert!(is_endpoint_safe("http://127.0.0.1:8080"));
1443 assert!(is_endpoint_safe("http://localhost:11434/v1/embeddings"));
1444 }
1445
1446 #[test]
1447 fn endpoint_safe_allows_private_ip() {
1448 assert!(is_endpoint_safe("http://10.0.0.2:8080"));
1449 assert!(is_endpoint_safe("http://192.168.1.100:8080"));
1450 assert!(is_endpoint_safe("http://172.16.0.5:8080"));
1451 }
1452
1453 #[test]
1454 fn endpoint_safe_blocks_non_http_schemes() {
1455 assert!(!is_endpoint_safe("file:///etc/passwd"));
1456 assert!(!is_endpoint_safe("gopher://localhost:8080"));
1457 assert!(!is_endpoint_safe("ftp://example.com"));
1458 assert!(!is_endpoint_safe("javascript:alert(1)"));
1459 assert!(!is_endpoint_safe("data:text/plain,hello"));
1460 }
1461
1462 #[test]
1463 fn endpoint_safe_blocks_metadata_endpoints() {
1464 assert!(!is_endpoint_safe("http://169.254.169.254/latest/meta-data"));
1465 assert!(!is_endpoint_safe("http://169.254.170.2/v2/metadata"));
1466 assert!(!is_endpoint_safe(
1467 "http://metadata.google.internal/computeMetadata"
1468 ));
1469 assert!(!is_endpoint_safe("http://metadata.aws.internal"));
1470 assert!(!is_endpoint_safe("http://metadata"));
1471 }
1472
1473 #[test]
1474 fn endpoint_safe_blocks_empty_host() {
1475 assert!(!is_endpoint_safe("http://"));
1476 assert!(!is_endpoint_safe("http:///path"));
1477 }
1478
1479 #[test]
1480 fn endpoint_safe_blocks_malformed_ipv6() {
1481 assert!(!is_endpoint_safe("http://[::1:8080"));
1482 }
1483
1484 #[test]
1485 fn endpoint_safe_allows_https() {
1486 assert!(is_endpoint_safe("https://localhost:8080"));
1487 assert!(is_endpoint_safe("https://example.com/api"));
1488 }
1489
1490 #[test]
1491 fn endpoint_safe_allows_ipv6_loopback() {
1492 assert!(is_endpoint_safe("http://[::1]:8080"));
1493 }
1494}