1use std::collections::HashMap;
40use std::path::{Path, PathBuf};
41use std::sync::{Arc, Mutex, OnceLock};
42use std::time::{Duration, Instant};
43
44use candle_core::{DType, Device, IndexOp, Tensor};
45use candle_nn::VarBuilder;
46use candle_transformers::models::bert::{BertModel, Config};
47use hf_hub::api::sync::{ApiBuilder, ApiRepo};
48use hf_hub::{Repo, RepoType};
49use serde::Deserialize;
50use tokenizers::{Tokenizer, TruncationDirection, TruncationParams, TruncationStrategy};
51
52use crate::embedding_config::{
53 DEFAULT_REPO, DEFAULT_REVISION, EmbeddingModel, OLLAMA_DEFAULT_URL, Pooling,
54 endpoint_fingerprint, fingerprint_suffix, huggingface_fingerprint, local_fingerprint,
55};
56use crate::trace::{EmbedderLoadStatus, TraceEvent, TraceSink};
57
58const ENDPOINT_TIMEOUT_SECS: u64 = 30;
61
62const ENDPOINT_BATCH_SIZE: usize = 64;
64
65const ENDPOINT_RESPONSE_LIMIT_BYTES: u64 = 64 * 1024 * 1024;
67
68const DEFAULT_SLOW_LOAD_MS: u64 = 5_000;
71
72const SLOW_LOAD_REASON: &str = "embedding model load was slow — this machine may be underpowered \
74 for in-process CPU inference; expect slow embedding builds and queries";
75
76#[derive(Debug, Clone)]
80pub enum EmbedderError {
81 Download {
84 model: String,
86 source: String,
88 },
89 CacheUnwritable {
92 source: String,
94 },
95 Load {
98 model: String,
100 source: String,
102 },
103 Inference {
105 source: String,
107 },
108 EmbeddingsNotBuilt,
112 DimensionMismatch {
117 expected: usize,
119 got: usize,
121 model: String,
123 },
124 ModelMismatch {
128 built: String,
130 active: String,
132 },
133 Config {
137 message: String,
139 },
140 NotCached {
144 model: String,
146 revision: Option<String>,
148 },
149}
150
151impl EmbedderError {
152 fn hint(&self) -> &'static str {
154 match self {
155 EmbedderError::Download { .. } => {
156 "check source availability, connectivity, and the configured model identifier"
157 }
158 EmbedderError::CacheUnwritable { .. } => {
159 "check ~/.cache/huggingface permissions and free disk space (or set HF_HOME)"
160 }
161 EmbedderError::Load { .. } => {
162 "check that model files and configuration are present, readable, and compatible"
163 }
164 EmbedderError::Inference { .. } => {
165 "check model input/configuration or the endpoint response and retry"
166 }
167 EmbedderError::EmbeddingsNotBuilt => {
168 "embed the corpus before running a semantic/hybrid search"
169 }
170 EmbedderError::DimensionMismatch { .. } => {
171 "the configured model changed; re-embed the corpus with the new model"
172 }
173 EmbedderError::ModelMismatch { .. } => {
174 "the configured model changed; re-embed the corpus with the new model"
175 }
176 EmbedderError::Config { .. } => {
177 "give exactly one embedding source (a model id / path / url) with its required fields"
178 }
179 EmbedderError::NotCached { .. } => {
180 "Ratel auto-downloads only the default model; pre-download this one, pass download=true, or use a local path / endpoint"
181 }
182 }
183 }
184}
185
186impl std::fmt::Display for EmbedderError {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 let hint = self.hint();
189 match self {
190 EmbedderError::Download { model, source } => write!(
191 f,
192 "failed to download embedding model {model}: {source} (hint: {hint})"
193 ),
194 EmbedderError::CacheUnwritable { source } => write!(
195 f,
196 "embedding model cache is not writable: {source} (hint: {hint})"
197 ),
198 EmbedderError::Load { model, source } => write!(
199 f,
200 "failed to load embedding model {model}: {source} (hint: {hint})"
201 ),
202 EmbedderError::Inference { source } => {
203 write!(f, "embedding failed: {source} (hint: {hint})")
204 }
205 EmbedderError::DimensionMismatch {
206 expected,
207 got,
208 model,
209 } => write!(
210 f,
211 "embedding dimension mismatch for {model}: expected {expected}, got {got} (hint: {hint})"
212 ),
213 EmbedderError::ModelMismatch { built, active } => write!(
214 f,
215 "embedding model mismatch: cache was built with {built}, active model is {active} (hint: {hint})"
216 ),
217 EmbedderError::Config { message } => write!(f, "{message} (hint: {hint})"),
218 EmbedderError::NotCached { model, revision } => {
219 let rev = revision
220 .as_deref()
221 .map(|r| format!(" --revision {r}"))
222 .unwrap_or_default();
223 write!(
224 f,
225 "embedding model {model} is not in the local HuggingFace cache — download it \
226 first: `huggingface-cli download {model}{rev}` (hint: {hint})"
227 )
228 }
229 EmbedderError::EmbeddingsNotBuilt => {
230 write!(
231 f,
232 "embeddings are not computed for semantic search (hint: {hint})"
233 )
234 }
235 }
236 }
237}
238
239impl std::error::Error for EmbedderError {}
240
241pub(crate) struct Embedded<T> {
245 pub(crate) value: T,
246 pub(crate) fingerprint: String,
247}
248
249pub(crate) trait Embedder: Send + Sync {
253 fn embed_doc(&self, text: &str) -> Result<Vec<f32>, EmbedderError>;
254 fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedderError>;
255
256 fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
260 texts.iter().map(|t| self.embed_doc(t)).collect()
261 }
262
263 fn embed_query_with_identity(&self, text: &str) -> Result<Embedded<Vec<f32>>, EmbedderError> {
267 Ok(Embedded {
268 value: self.embed_query(text)?,
269 fingerprint: self.fingerprint(),
270 })
271 }
272
273 fn embed_batch_with_identity(
276 &self,
277 texts: &[String],
278 ) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
279 Ok(Embedded {
280 value: self.embed_batch(texts)?,
281 fingerprint: self.fingerprint(),
282 })
283 }
284
285 fn fingerprint(&self) -> String {
290 "unknown".to_string()
291 }
292}
293
294struct DownloadNotice {
298 model: String,
299 bytes: u64,
300}
301
302#[derive(Default)]
304struct LoadNotices {
305 download: Option<DownloadNotice>,
307 pooling_assumed: Option<String>,
310}
311
312type ResolvedEmbedder = (Arc<dyn Embedder>, Option<u64>, LoadNotices);
323
324fn embedder_for(model: &EmbeddingModel) -> Result<ResolvedEmbedder, EmbedderError> {
325 static CELL: OnceLock<Mutex<HashMap<String, LoadSlot<dyn Embedder>>>> = OnceLock::new();
326 let cache = CELL.get_or_init(|| Mutex::new(HashMap::new()));
327 let mut notices = LoadNotices::default();
328 let (emb, load_ms) = get_or_load_keyed(cache, &model.embedder_cache_key(), || {
329 let (emb, n) = build_embedder(model)?;
330 notices = n;
331 Ok(emb)
332 })?;
333 Ok((emb, load_ms, notices))
334}
335
336fn build_embedder(
339 model: &EmbeddingModel,
340) -> Result<(Arc<dyn Embedder>, LoadNotices), EmbedderError> {
341 let query_prefix = model.query_prefix();
342 let doc_prefix = model.doc_prefix();
343 let pooling = model.pooling_override();
344 match model {
345 EmbeddingModel::Default => {
346 let (e, n) = CandleEmbedder::load_hf(
348 DEFAULT_REPO,
349 DEFAULT_REVISION,
350 query_prefix,
351 doc_prefix,
352 pooling,
353 true,
354 )?;
355 Ok((Arc::new(e), n))
356 }
357 EmbeddingModel::HuggingFace {
358 repo,
359 revision,
360 download,
361 ..
362 } => {
363 let (e, n) = CandleEmbedder::load_hf(
364 repo,
365 revision.as_deref().unwrap_or("main"),
366 query_prefix,
367 doc_prefix,
368 pooling,
369 *download,
370 )?;
371 Ok((Arc::new(e), n))
372 }
373 EmbeddingModel::Local { path, .. } => {
374 let (e, n) = CandleEmbedder::load_path(path, query_prefix, doc_prefix, pooling)?;
375 Ok((Arc::new(e), n))
376 }
377 EmbeddingModel::Endpoint {
378 url,
379 model,
380 api_key_env,
381 ..
382 } => {
383 let e = EndpointEmbedder::new(
384 url.clone(),
385 model.clone(),
386 api_key_env.clone(),
387 query_prefix.into(),
388 doc_prefix.into(),
389 )?;
390 Ok((Arc::new(e), LoadNotices::default()))
391 }
392 }
393}
394
395type LoadSlot<T> = Arc<Mutex<Option<Arc<T>>>>;
399
400fn get_or_load_keyed<T: ?Sized>(
411 cache: &Mutex<HashMap<String, LoadSlot<T>>>,
412 key: &str,
413 load: impl FnOnce() -> Result<Arc<T>, EmbedderError>,
414) -> Result<(Arc<T>, Option<u64>), EmbedderError> {
415 let slot = {
417 let mut guard = cache.lock().expect("embedder cache mutex poisoned");
418 Arc::clone(guard.entry(key.to_string()).or_default())
419 };
420 let mut slot = slot.lock().expect("embedder cache slot mutex poisoned");
422 if let Some(existing) = slot.as_ref() {
423 return Ok((existing.clone(), None));
424 }
425 let started = Instant::now();
426 let loaded = load()?; let took_ms = started.elapsed().as_millis() as u64;
428 *slot = Some(loaded.clone());
429 Ok((loaded, Some(took_ms)))
430}
431
432pub(crate) fn embedder_with_telemetry(
437 model: &EmbeddingModel,
438 sink: &dyn TraceSink,
439) -> Result<Arc<dyn Embedder>, EmbedderError> {
440 let display = model.display_name();
441 let (result, load_ms, notices) = match embedder_for(model) {
442 Ok((emb, ms, notices)) => (Ok(emb), ms, notices),
443 Err(e) => (Err(e), None, LoadNotices::default()),
444 };
445 if let Some(DownloadNotice { model, bytes }) = notices.download {
446 let mb = bytes as f64 / 1_048_576.0;
447 eprintln!("ratel: downloaded embedding model {model} ({mb:.0} MB, one-time)");
448 sink.record(TraceEvent::EmbedderDownload { model, bytes });
449 }
450 if let Some(model) = notices.pooling_assumed {
451 eprintln!(
452 "ratel: pooling not detected for {model}, assuming mean; \
453 set pooling=\"cls\"|\"mean\" to override"
454 );
455 sink.record(TraceEvent::EmbedderPoolingAssumed {
456 model,
457 pooling: "mean".to_string(),
458 });
459 }
460 if let Some(event) = embedder_load_event(&display, load_ms, result.as_ref().err()) {
461 if let TraceEvent::EmbedderLoad {
462 status,
463 took_ms,
464 reason,
465 ..
466 } = &event
467 && !matches!(status, EmbedderLoadStatus::Ok)
468 {
469 eprintln!(
470 "ratel: embedding model load {status:?} ({took_ms}ms): {}",
471 reason.as_deref().unwrap_or("")
472 );
473 }
474 sink.record(event);
475 }
476 result
477}
478
479fn embedder_load_event(
483 model: &str,
484 load_ms: Option<u64>,
485 error: Option<&EmbedderError>,
486) -> Option<TraceEvent> {
487 match (load_ms, error) {
488 (_, Some(err)) => Some(TraceEvent::EmbedderLoad {
489 model: model.to_string(),
490 status: EmbedderLoadStatus::Failed,
491 took_ms: load_ms.unwrap_or(0),
492 reason: Some(err.to_string()),
493 }),
494 (Some(ms), None) => {
495 let slow = ms > slow_load_ms();
496 Some(TraceEvent::EmbedderLoad {
497 model: model.to_string(),
498 status: if slow {
499 EmbedderLoadStatus::Slow
500 } else {
501 EmbedderLoadStatus::Ok
502 },
503 took_ms: ms,
504 reason: slow.then(|| SLOW_LOAD_REASON.to_string()),
505 })
506 }
507 (None, None) => None,
508 }
509}
510
511fn slow_load_ms() -> u64 {
512 std::env::var("RATEL_EMBED_SLOW_MS")
513 .ok()
514 .and_then(|v| v.parse().ok())
515 .unwrap_or(DEFAULT_SLOW_LOAD_MS)
516}
517
518const EMBED_BATCH_CHUNK: usize = 32;
526
527pub(crate) struct CandleEmbedder {
528 model: BertModel,
529 tokenizer: Tokenizer,
530 device: Device,
531 pooling: Pooling,
532 query_prefix: String,
533 doc_prefix: String,
534 fingerprint: String,
535}
536
537struct Loaded {
539 config: PathBuf,
540 tokenizer: PathBuf,
541 weights: PathBuf,
542 pooling: Pooling,
543}
544
545impl CandleEmbedder {
546 fn load_hf(
554 repo_id: &str,
555 revision: &str,
556 query_prefix: &str,
557 doc_prefix: &str,
558 pooling_override: Option<Pooling>,
559 allow_download: bool,
560 ) -> Result<(Self, LoadNotices), EmbedderError> {
561 let device = Device::Cpu;
562 let repo_spec =
563 Repo::with_revision(repo_id.to_string(), RepoType::Model, revision.to_string());
564 let cache_repo = hf_hub::Cache::from_env().repo(repo_spec.clone());
565
566 let (config_path, tokenizer_path, weights_path, pooling_file, download) = if allow_download
567 {
568 let was_cached = cache_repo.get("model.safetensors").is_some()
572 || cache_repo.get("pytorch_model.bin").is_some();
573 if !was_cached {
574 eprintln!(
575 "ratel: downloading embedding model {repo_id} (one-time; this may take a moment)…"
576 );
577 }
578 let api = ApiBuilder::from_env()
579 .build()
580 .map_err(|e| EmbedderError::Download {
581 model: repo_id.to_string(),
582 source: e.to_string(),
583 })?;
584 let repo = api.repo(repo_spec);
585 let config = fetch_cached(&repo, "config.json", repo_id)?;
586 let tokenizer = fetch_cached(&repo, "tokenizer.json", repo_id)?;
587 let weights = match fetch_cached(&repo, "model.safetensors", repo_id) {
589 Ok(p) => p,
590 Err(EmbedderError::Download { source, .. }) if is_not_found(&source) => {
591 fetch_cached(&repo, "pytorch_model.bin", repo_id)?
592 }
593 Err(e) => return Err(e),
594 };
595 let pooling_file = fetch_optional(&repo, "1_Pooling/config.json");
596 let notice = (!was_cached).then(|| DownloadNotice {
597 model: repo_id.to_string(),
598 bytes: [&config, &tokenizer, &weights]
599 .iter()
600 .filter_map(|p| std::fs::metadata(p).ok().map(|m| m.len()))
601 .sum(),
602 });
603 (config, tokenizer, weights, pooling_file, notice)
604 } else {
605 let not_cached = || EmbedderError::NotCached {
608 model: repo_id.to_string(),
609 revision: (revision != "main").then(|| revision.to_string()),
610 };
611 let config = cache_repo.get("config.json").ok_or_else(not_cached)?;
612 let tokenizer = cache_repo.get("tokenizer.json").ok_or_else(not_cached)?;
613 let weights = cache_repo
614 .get("model.safetensors")
615 .or_else(|| cache_repo.get("pytorch_model.bin"))
616 .ok_or_else(not_cached)?;
617 let pooling_file = cache_repo.get("1_Pooling/config.json");
618 (config, tokenizer, weights, pooling_file, None)
619 };
620
621 let detected =
623 pooling_override.or_else(|| pooling_file.and_then(|p| detect_pooling_file(&p)));
624 let (pooling, pooling_assumed) = resolve_pooling(detected);
625 let notices = LoadNotices {
626 download,
627 pooling_assumed: pooling_assumed.then(|| repo_id.to_string()),
628 };
629
630 let sha = snapshot_sha(&weights_path).unwrap_or_else(|| revision.to_string());
633 let loaded = Loaded {
634 config: config_path,
635 tokenizer: tokenizer_path,
636 weights: weights_path,
637 pooling,
638 };
639 let embedder = Self::build(
640 device,
641 &loaded,
642 query_prefix,
643 doc_prefix,
644 huggingface_fingerprint(repo_id, &sha),
645 repo_id,
646 )?;
647 Ok((embedder, notices))
648 }
649
650 fn load_path(
653 dir: &Path,
654 query_prefix: &str,
655 doc_prefix: &str,
656 pooling_override: Option<Pooling>,
657 ) -> Result<(Self, LoadNotices), EmbedderError> {
658 let device = Device::Cpu;
659 let name = dir.display().to_string();
660 let config_path = dir.join("config.json");
661 let tokenizer_path = dir.join("tokenizer.json");
662 let weights_path = [dir.join("model.safetensors"), dir.join("pytorch_model.bin")]
664 .into_iter()
665 .find(|p| p.exists())
666 .ok_or_else(|| EmbedderError::Load {
667 model: name.clone(),
668 source: format!("missing model.safetensors / pytorch_model.bin in {name}"),
669 })?;
670 for (p, f) in [
671 (&config_path, "config.json"),
672 (&tokenizer_path, "tokenizer.json"),
673 ] {
674 if !p.exists() {
675 return Err(EmbedderError::Load {
676 model: name.clone(),
677 source: format!(
678 "missing {f} in {name} — a fast tokenizer.json is required; run \
679 tokenizer.save_pretrained() upstream, or serve the model via an endpoint"
680 ),
681 });
682 }
683 }
684
685 let detected =
686 pooling_override.or_else(|| detect_pooling_file(&dir.join("1_Pooling/config.json")));
687 let (pooling, pooling_assumed) = resolve_pooling(detected);
688 let notices = LoadNotices {
689 download: None,
690 pooling_assumed: pooling_assumed.then(|| name.clone()),
691 };
692
693 let loaded = Loaded {
694 config: config_path,
695 tokenizer: tokenizer_path,
696 weights: weights_path,
697 pooling,
698 };
699 let embedder = Self::build(
700 device,
701 &loaded,
702 query_prefix,
703 doc_prefix,
704 local_fingerprint(&name),
705 &name,
706 )?;
707 Ok((embedder, notices))
708 }
709
710 fn build(
713 device: Device,
714 loaded: &Loaded,
715 query_prefix: &str,
716 doc_prefix: &str,
717 base_fingerprint: String,
718 model_name: &str,
719 ) -> Result<Self, EmbedderError> {
720 let load_err = |source: String| EmbedderError::Load {
721 model: model_name.to_string(),
722 source,
723 };
724
725 let config_bytes = std::fs::read(&loaded.config).map_err(|e| load_err(e.to_string()))?;
726 let config: Config =
727 serde_json::from_slice(&config_bytes).map_err(|e| load_err(e.to_string()))?;
728
729 let mut tokenizer =
730 Tokenizer::from_file(&loaded.tokenizer).map_err(|e| load_err(e.to_string()))?;
731 tokenizer
734 .with_truncation(Some(TruncationParams {
735 max_length: config.max_position_embeddings,
736 strategy: TruncationStrategy::LongestFirst,
737 direction: TruncationDirection::Right,
738 stride: 0,
739 }))
740 .map_err(|e| load_err(e.to_string()))?;
741
742 let is_safetensors =
745 loaded.weights.extension().and_then(|e| e.to_str()) == Some("safetensors");
746 let vb = if is_safetensors {
747 unsafe {
748 VarBuilder::from_mmaped_safetensors(&[&loaded.weights], DType::F32, &device)
749 .map_err(|e| load_err(e.to_string()))?
750 }
751 } else {
752 VarBuilder::from_pth(&loaded.weights, DType::F32, &device)
753 .map_err(|e| load_err(e.to_string()))?
754 };
755 let model = BertModel::load(vb, &config).map_err(|e| EmbedderError::Load {
756 model: model_name.to_string(),
757 source: format!(
758 "{e} — if this is not a BERT-family model it can't run in-process; \
759 serve it in a local model server and use {{\"ollama\": \"…\"}} or \
760 {{\"url\", \"model\"}} (e.g. Ollama at {OLLAMA_DEFAULT_URL})"
761 ),
762 })?;
763
764 let fingerprint = format!(
766 "{base_fingerprint}{}",
767 fingerprint_suffix(Some(loaded.pooling), query_prefix, doc_prefix)
768 );
769 Ok(Self {
770 model,
771 tokenizer,
772 device,
773 pooling: loaded.pooling,
774 query_prefix: query_prefix.to_string(),
775 doc_prefix: doc_prefix.to_string(),
776 fingerprint,
777 })
778 }
779
780 fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
781 self.embed_inner(text)
782 .map_err(|e| EmbedderError::Inference {
783 source: e.to_string(),
784 })
785 }
786
787 fn embed_inner(&self, text: &str) -> candle_core::Result<Vec<f32>> {
788 let encoding = self
789 .tokenizer
790 .encode(text, true)
791 .map_err(|e| candle_core::Error::Msg(e.to_string()))?;
792 let ids = encoding.get_ids();
793 let input_ids = Tensor::new(ids, &self.device)?.unsqueeze(0)?; let token_type_ids = input_ids.zeros_like()?;
795 let mask: Vec<u32> = encoding.get_attention_mask().to_vec();
796 let attention_mask = Tensor::new(mask.as_slice(), &self.device)?.unsqueeze(0)?;
797
798 let sequence_output =
800 self.model
801 .forward(&input_ids, &token_type_ids, Some(&attention_mask))?;
802 let pooled = match self.pooling {
803 Pooling::Cls => sequence_output.i((0, 0))?, Pooling::Mean => mean_pool(&sequence_output, &attention_mask)?,
807 };
808 let vec = pooled.to_vec1::<f32>()?;
809 Ok(l2_normalize(vec))
810 }
811
812 fn embed_batch_inner(&self, texts: &[String]) -> candle_core::Result<Vec<Vec<f32>>> {
819 let mut out = Vec::with_capacity(texts.len());
820 for chunk in texts.chunks(EMBED_BATCH_CHUNK) {
821 let inputs: Vec<String> = if self.doc_prefix.is_empty() {
823 chunk.to_vec()
824 } else {
825 chunk
826 .iter()
827 .map(|t| format!("{}{}", self.doc_prefix, t))
828 .collect()
829 };
830 let encodings = self
831 .tokenizer
832 .encode_batch(inputs, true)
833 .map_err(|e| candle_core::Error::Msg(e.to_string()))?;
834 let n = encodings.len();
835 let max_len = encodings
836 .iter()
837 .map(|e| e.get_ids().len())
838 .max()
839 .unwrap_or(0);
840 let mut ids = vec![0u32; n * max_len];
843 let mut mask = vec![0u32; n * max_len];
844 for (row, enc) in encodings.iter().enumerate() {
845 let e_ids = enc.get_ids();
846 let e_mask = enc.get_attention_mask();
847 let base = row * max_len;
848 ids[base..base + e_ids.len()].copy_from_slice(e_ids);
849 mask[base..base + e_mask.len()].copy_from_slice(e_mask);
850 }
851 let input_ids = Tensor::from_vec(ids, (n, max_len), &self.device)?;
852 let attention_mask = Tensor::from_vec(mask, (n, max_len), &self.device)?;
853 let token_type_ids = input_ids.zeros_like()?;
854
855 let sequence_output =
857 self.model
858 .forward(&input_ids, &token_type_ids, Some(&attention_mask))?;
859 let pooled = match self.pooling {
860 Pooling::Cls => sequence_output.narrow(1, 0, 1)?.squeeze(1)?, Pooling::Mean => mean_pool_batch(&sequence_output, &attention_mask)?,
864 };
865 for row in pooled.to_vec2::<f32>()? {
866 out.push(l2_normalize(row));
867 }
868 }
869 Ok(out)
870 }
871}
872
873fn mean_pool(sequence_output: &Tensor, attention_mask: &Tensor) -> candle_core::Result<Tensor> {
876 let mask = attention_mask.to_dtype(DType::F32)?.unsqueeze(2)?; let summed = sequence_output.broadcast_mul(&mask)?.sum(1)?; let counts = mask.sum(1)?; summed.broadcast_div(&counts)?.i(0) }
881
882fn mean_pool_batch(
886 sequence_output: &Tensor,
887 attention_mask: &Tensor,
888) -> candle_core::Result<Tensor> {
889 let mask = attention_mask.to_dtype(DType::F32)?.unsqueeze(2)?; let summed = sequence_output.broadcast_mul(&mask)?.sum(1)?; let counts = mask.sum(1)?; summed.broadcast_div(&counts) }
894
895impl Embedder for CandleEmbedder {
896 fn embed_doc(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
897 if self.doc_prefix.is_empty() {
898 self.embed(text)
899 } else {
900 self.embed(&format!("{}{}", self.doc_prefix, text))
901 }
902 }
903
904 fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
905 if self.query_prefix.is_empty() {
906 self.embed(text)
907 } else {
908 self.embed(&format!("{}{}", self.query_prefix, text))
909 }
910 }
911
912 fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
913 self.embed_batch_inner(texts)
914 .map_err(|e| EmbedderError::Inference {
915 source: e.to_string(),
916 })
917 }
918
919 fn fingerprint(&self) -> String {
920 self.fingerprint.clone()
921 }
922}
923
924#[derive(Deserialize)]
926struct PoolingConfig {
927 #[serde(default)]
928 pooling_mode_cls_token: bool,
929 #[serde(default)]
930 pooling_mode_mean_tokens: bool,
931}
932
933fn detect_pooling_file(path: &Path) -> Option<Pooling> {
936 let bytes = std::fs::read(path).ok()?;
937 parse_pooling_config(&bytes)
938}
939
940fn parse_pooling_config(bytes: &[u8]) -> Option<Pooling> {
942 let c: PoolingConfig = serde_json::from_slice(bytes).ok()?;
943 if c.pooling_mode_cls_token {
944 Some(Pooling::Cls)
945 } else if c.pooling_mode_mean_tokens {
946 Some(Pooling::Mean)
947 } else {
948 None
949 }
950}
951
952fn resolve_pooling(detected: Option<Pooling>) -> (Pooling, bool) {
955 match detected {
956 Some(p) => (p, false),
957 None => (Pooling::Mean, true),
958 }
959}
960
961fn fetch_optional(repo: &ApiRepo, file: &str) -> Option<PathBuf> {
964 repo.get(file).ok()
965}
966
967fn is_not_found(source: &str) -> bool {
970 let l = source.to_lowercase();
971 l.contains("404") || l.contains("not found") || l.contains("entry not found")
972}
973
974fn snapshot_sha(weights_path: &Path) -> Option<String> {
978 let name = weights_path.parent()?.file_name()?.to_str()?;
979 (name.len() == 40 && name.chars().all(|c| c.is_ascii_hexdigit())).then(|| name.to_string())
980}
981
982pub(crate) struct EndpointEmbedder {
987 url: String,
988 model: String,
989 api_key_env: Option<String>,
990 query_prefix: String,
991 doc_prefix: String,
992 agent: ureq::Agent,
993 fingerprint: String,
994}
995
996#[derive(Deserialize)]
998struct EmbeddingsResponse {
999 data: Vec<EmbeddingData>,
1000 #[serde(default)]
1001 model: Option<String>,
1002}
1003
1004#[derive(Deserialize)]
1005struct EmbeddingData {
1006 embedding: Vec<f32>,
1007 index: usize,
1008}
1009
1010struct ParsedEmbeddings {
1011 vectors: Vec<Vec<f32>>,
1012 model: Option<String>,
1013}
1014
1015impl EndpointEmbedder {
1016 fn new(
1017 url: String,
1018 model: String,
1019 api_key_env: Option<String>,
1020 query_prefix: String,
1021 doc_prefix: String,
1022 ) -> Result<Self, EmbedderError> {
1023 let agent: ureq::Agent = ureq::Agent::config_builder()
1024 .timeout_global(Some(Duration::from_secs(ENDPOINT_TIMEOUT_SECS)))
1025 .build()
1026 .into();
1027 let fingerprint = format!(
1029 "{}{}",
1030 endpoint_fingerprint(&url, &model),
1031 fingerprint_suffix(None, &query_prefix, &doc_prefix)
1032 );
1033 Ok(Self {
1034 url,
1035 model,
1036 api_key_env,
1037 query_prefix,
1038 doc_prefix,
1039 agent,
1040 fingerprint,
1041 })
1042 }
1043
1044 fn api_key(&self) -> Result<Option<String>, EmbedderError> {
1048 match &self.api_key_env {
1049 None => Ok(None),
1050 Some(var) => std::env::var(var)
1051 .map(Some)
1052 .map_err(|_| EmbedderError::Config {
1053 message: format!(
1054 "api_key_env=\"{var}\" but that environment variable is not set"
1055 ),
1056 }),
1057 }
1058 }
1059
1060 fn request_chunk(&self, inputs: &[String]) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
1061 let key = self.api_key()?;
1062 let body = serde_json::json!({ "model": self.model, "input": inputs });
1063 let mut req = self
1064 .agent
1065 .post(&self.url)
1066 .header("content-type", "application/json");
1067 if let Some(k) = key {
1068 req = req.header("authorization", &format!("Bearer {k}"));
1069 }
1070 let mut resp = req.send_json(&body).map_err(|e| self.classify(e))?;
1071 let parsed: EmbeddingsResponse = resp
1072 .body_mut()
1073 .with_config()
1074 .limit(ENDPOINT_RESPONSE_LIMIT_BYTES)
1075 .read_json()
1076 .map_err(|e| EmbedderError::Inference {
1077 source: format!("malformed or oversized endpoint response: {e}"),
1078 })?;
1079 let parsed = parse_embeddings(parsed, inputs.len())?;
1080 let resolved_model = parsed.model.as_deref().unwrap_or(&self.model);
1081 Ok(Embedded {
1082 value: parsed.vectors,
1083 fingerprint: self.fingerprint_for_model(resolved_model),
1084 })
1085 }
1086
1087 fn request(&self, inputs: &[String]) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
1088 if inputs.is_empty() {
1089 return Ok(Embedded {
1090 value: Vec::new(),
1091 fingerprint: self.fingerprint.clone(),
1092 });
1093 }
1094
1095 let mut vectors = Vec::with_capacity(inputs.len());
1096 let mut fingerprint: Option<String> = None;
1097 let mut dimension = None;
1098 for chunk in inputs.chunks(ENDPOINT_BATCH_SIZE) {
1099 let embedded = self.request_chunk(chunk)?;
1100 if let Some(first) = &fingerprint {
1101 if first != &embedded.fingerprint {
1102 return Err(EmbedderError::ModelMismatch {
1103 built: first.clone(),
1104 active: embedded.fingerprint,
1105 });
1106 }
1107 } else {
1108 fingerprint = Some(embedded.fingerprint.clone());
1109 }
1110 let chunk_dimension = embedded
1111 .value
1112 .first()
1113 .expect("non-empty request chunk has a non-empty response")
1114 .len();
1115 if let Some(expected) = dimension {
1116 if expected != chunk_dimension {
1117 return Err(EmbedderError::Inference {
1118 source: format!(
1119 "endpoint returned mixed embedding dimensions across chunks: expected {expected}, got {chunk_dimension}"
1120 ),
1121 });
1122 }
1123 } else {
1124 dimension = Some(chunk_dimension);
1125 }
1126 vectors.extend(embedded.value);
1127 }
1128 Ok(Embedded {
1129 value: vectors,
1130 fingerprint: fingerprint.expect("non-empty input produced at least one chunk"),
1131 })
1132 }
1133
1134 fn fingerprint_for_model(&self, model: &str) -> String {
1135 format!(
1136 "{}{}",
1137 endpoint_fingerprint(&self.url, model),
1138 fingerprint_suffix(None, &self.query_prefix, &self.doc_prefix)
1139 )
1140 }
1141
1142 fn classify(&self, e: ureq::Error) -> EmbedderError {
1145 let status = match &e {
1146 ureq::Error::StatusCode(code) => Some(*code),
1147 _ => None,
1148 };
1149 let is_local_ollama =
1150 self.url.contains("localhost:11434") || self.url.contains("127.0.0.1:11434");
1151 match status {
1152 Some(401) | Some(403) => EmbedderError::Config {
1153 message: format!(
1154 "endpoint rejected the request ({}); check api_key_env / the key",
1155 status.unwrap()
1156 ),
1157 },
1158 Some(404) => {
1160 let hint = if is_local_ollama {
1161 format!(" — run: ollama pull {}", self.model)
1162 } else {
1163 String::new()
1164 };
1165 EmbedderError::Download {
1166 model: self.model.clone(),
1167 source: format!("endpoint returned 404 for model '{}'{hint}", self.model),
1168 }
1169 }
1170 _ if is_local_ollama => EmbedderError::Download {
1173 model: self.model.clone(),
1174 source: format!(
1175 "could not reach Ollama at {} ({e}) — is it running? start it with \
1176 `ollama serve`, then `ollama pull {}`",
1177 self.url, self.model
1178 ),
1179 },
1180 _ => EmbedderError::Download {
1181 model: self.model.clone(),
1182 source: e.to_string(),
1183 },
1184 }
1185 }
1186}
1187
1188fn parse_embeddings(
1191 resp: EmbeddingsResponse,
1192 expected_len: usize,
1193) -> Result<ParsedEmbeddings, EmbedderError> {
1194 if resp.data.len() != expected_len {
1195 return Err(EmbedderError::Inference {
1196 source: format!(
1197 "endpoint returned {} embeddings for {expected_len} inputs",
1198 resp.data.len()
1199 ),
1200 });
1201 }
1202 if resp
1203 .model
1204 .as_deref()
1205 .is_some_and(|model| model.trim().is_empty())
1206 {
1207 return Err(EmbedderError::Inference {
1208 source: "endpoint returned a blank model identity".into(),
1209 });
1210 }
1211
1212 let mut ordered: Vec<Option<Vec<f32>>> = (0..expected_len).map(|_| None).collect();
1213 let mut dimension = None;
1214 for data in resp.data {
1215 if data.index >= expected_len {
1216 return Err(EmbedderError::Inference {
1217 source: format!(
1218 "endpoint returned out-of-range embedding index {} for {expected_len} inputs",
1219 data.index
1220 ),
1221 });
1222 }
1223 if ordered[data.index].is_some() {
1224 return Err(EmbedderError::Inference {
1225 source: format!("endpoint returned duplicate embedding index {}", data.index),
1226 });
1227 }
1228 let vector = normalize_endpoint_vector(data.embedding, &mut dimension)?;
1229 ordered[data.index] = Some(vector);
1230 }
1231
1232 let vectors = ordered
1233 .into_iter()
1234 .enumerate()
1235 .map(|(index, vector)| {
1236 vector.ok_or_else(|| EmbedderError::Inference {
1237 source: format!("endpoint response is missing embedding index {index}"),
1238 })
1239 })
1240 .collect::<Result<_, _>>()?;
1241 Ok(ParsedEmbeddings {
1242 vectors,
1243 model: resp.model,
1244 })
1245}
1246
1247fn normalize_endpoint_vector(
1248 mut vector: Vec<f32>,
1249 dimension: &mut Option<usize>,
1250) -> Result<Vec<f32>, EmbedderError> {
1251 if vector.is_empty() {
1252 return Err(EmbedderError::Inference {
1253 source: "endpoint returned an empty embedding vector".into(),
1254 });
1255 }
1256 if vector.iter().any(|value| !value.is_finite()) {
1257 return Err(EmbedderError::Inference {
1258 source: "endpoint returned a non-finite embedding value".into(),
1259 });
1260 }
1261 match *dimension {
1262 Some(expected) if vector.len() != expected => {
1263 return Err(EmbedderError::Inference {
1264 source: format!(
1265 "endpoint returned mixed embedding dimensions: expected {expected}, got {}",
1266 vector.len()
1267 ),
1268 });
1269 }
1270 None => *dimension = Some(vector.len()),
1271 Some(_) => {}
1272 }
1273
1274 let norm = vector
1275 .iter()
1276 .map(|value| f64::from(*value).powi(2))
1277 .sum::<f64>()
1278 .sqrt();
1279 if !norm.is_finite() || norm == 0.0 {
1280 return Err(EmbedderError::Inference {
1281 source: "endpoint returned a zero or non-normalizable embedding vector".into(),
1282 });
1283 }
1284 for value in &mut vector {
1285 *value = (f64::from(*value) / norm) as f32;
1286 }
1287 Ok(vector)
1288}
1289
1290impl Embedder for EndpointEmbedder {
1291 fn embed_doc(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
1292 self.embed_batch_with_identity(std::slice::from_ref(&text.to_string()))?
1293 .value
1294 .into_iter()
1295 .next()
1296 .ok_or_else(|| EmbedderError::Inference {
1297 source: "endpoint returned no embedding".into(),
1298 })
1299 }
1300
1301 fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
1302 Ok(self.embed_query_with_identity(text)?.value)
1303 }
1304
1305 fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
1306 Ok(self.embed_batch_with_identity(texts)?.value)
1307 }
1308
1309 fn embed_query_with_identity(&self, text: &str) -> Result<Embedded<Vec<f32>>, EmbedderError> {
1310 let q = if self.query_prefix.is_empty() {
1311 text.to_string()
1312 } else {
1313 format!("{}{}", self.query_prefix, text)
1314 };
1315 let embedded = self.request(&[q])?;
1316 let fingerprint = embedded.fingerprint;
1317 let value = embedded
1318 .value
1319 .into_iter()
1320 .next()
1321 .ok_or_else(|| EmbedderError::Inference {
1322 source: "endpoint returned no embedding".into(),
1323 })?;
1324 Ok(Embedded { value, fingerprint })
1325 }
1326
1327 fn embed_batch_with_identity(
1328 &self,
1329 texts: &[String],
1330 ) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
1331 if self.doc_prefix.is_empty() {
1332 self.request(texts)
1333 } else {
1334 let prefixed: Vec<String> = texts
1335 .iter()
1336 .map(|t| format!("{}{}", self.doc_prefix, t))
1337 .collect();
1338 self.request(&prefixed)
1339 }
1340 }
1341
1342 fn fingerprint(&self) -> String {
1343 self.fingerprint.clone()
1344 }
1345}
1346
1347fn fetch_cached(repo: &ApiRepo, file: &str, model: &str) -> Result<PathBuf, EmbedderError> {
1358 const MAX_ATTEMPTS: u32 = 30;
1361 const BACKOFF: Duration = Duration::from_secs(1);
1362
1363 let mut attempt = 1;
1364 loop {
1365 match repo.get(file) {
1366 Ok(path) => return Ok(path),
1367 Err(e) => {
1368 let msg = e.to_string();
1369 if attempt < MAX_ATTEMPTS && is_lock_contention(&msg) {
1370 attempt += 1;
1371 std::thread::sleep(BACKOFF);
1372 continue;
1373 }
1374 return Err(classify_fetch_error(model, &msg));
1375 }
1376 }
1377 }
1378}
1379
1380fn is_lock_contention(err: &str) -> bool {
1384 err.contains("Lock acquisition failed")
1385}
1386
1387fn classify_fetch_error(model: &str, msg: &str) -> EmbedderError {
1391 let lower = msg.to_lowercase();
1392 let unwritable = lower.contains("permission denied")
1393 || lower.contains("read-only")
1394 || lower.contains("no space")
1395 || lower.contains("os error 13") || lower.contains("os error 28") || lower.contains("os error 30"); if unwritable {
1399 EmbedderError::CacheUnwritable {
1400 source: msg.to_string(),
1401 }
1402 } else {
1403 EmbedderError::Download {
1404 model: model.to_string(),
1405 source: msg.to_string(),
1406 }
1407 }
1408}
1409
1410fn l2_normalize(mut v: Vec<f32>) -> Vec<f32> {
1413 let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
1414 if norm > 0.0 {
1415 for x in &mut v {
1416 *x /= norm;
1417 }
1418 }
1419 v
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424 use std::io::{Read, Write};
1425 use std::net::TcpListener;
1426 use std::sync::mpsc;
1427
1428 use super::*;
1429 use crate::{Origin, SearchMethod, Tool, ToolRegistry};
1430
1431 #[test]
1432 fn only_lock_contention_is_retried() {
1433 assert!(is_lock_contention(
1435 "Lock acquisition failed: /home/u/.cache/huggingface/hub/models--BAAI--bge-small-en-v1.5/blobs/abc.lock"
1436 ));
1437 assert!(!is_lock_contention("request error: connection refused"));
1439 assert!(!is_lock_contention("Http(reqwest::Error { status: 404 })"));
1440 assert!(!is_lock_contention(
1441 "No such file or directory (os error 2)"
1442 ));
1443 }
1444
1445 #[test]
1446 fn classifies_cache_permission_and_space_as_unwritable() {
1447 assert!(matches!(
1448 classify_fetch_error("m", "Permission denied (os error 13)"),
1449 EmbedderError::CacheUnwritable { .. }
1450 ));
1451 assert!(matches!(
1452 classify_fetch_error("m", "No space left on device (os error 28)"),
1453 EmbedderError::CacheUnwritable { .. }
1454 ));
1455 assert!(matches!(
1456 classify_fetch_error("m", "Read-only file system (os error 30)"),
1457 EmbedderError::CacheUnwritable { .. }
1458 ));
1459 }
1460
1461 #[test]
1462 fn classifies_network_and_http_as_download() {
1463 assert!(matches!(
1464 classify_fetch_error("m", "error sending request: dns error: failed to lookup"),
1465 EmbedderError::Download { .. }
1466 ));
1467 assert!(matches!(
1468 classify_fetch_error("m", "Http status client error (404 Not Found)"),
1469 EmbedderError::Download { .. }
1470 ));
1471 }
1472
1473 #[test]
1474 fn error_display_carries_source_and_hint() {
1475 let s = EmbedderError::Download {
1476 model: "embed-v1 @ https://embeddings.example.test".into(),
1477 source: "connection refused".into(),
1478 }
1479 .to_string();
1480 assert!(s.contains("connection refused"), "got: {s}");
1481 assert!(s.contains("hint:"), "got: {s}");
1482 assert!(!s.contains("revision"), "got: {s}");
1483
1484 let load = EmbedderError::Load {
1485 model: "/models/embed".into(),
1486 source: "missing config.json".into(),
1487 }
1488 .to_string();
1489 assert!(!load.contains("re-download"), "got: {load}");
1490
1491 let inference = EmbedderError::Inference {
1492 source: "endpoint returned duplicate index 0".into(),
1493 }
1494 .to_string();
1495 assert!(!inference.contains("underpowered"), "got: {inference}");
1496 }
1497
1498 #[test]
1499 fn get_or_load_keyed_does_not_cache_failure_and_reports_latency_once() {
1500 let cache: Mutex<HashMap<String, LoadSlot<i32>>> = Mutex::new(HashMap::new());
1501 let boom = || {
1502 Err::<Arc<i32>, _>(EmbedderError::Inference {
1503 source: "boom".into(),
1504 })
1505 };
1506 assert!(get_or_load_keyed(&cache, "k", boom).is_err());
1508 let (v, ms) =
1510 get_or_load_keyed(&cache, "k", || Ok::<_, EmbedderError>(Arc::new(7))).unwrap();
1511 assert_eq!(*v, 7);
1512 assert!(ms.is_some(), "the loading call reports latency");
1513 let (v2, ms2) =
1515 get_or_load_keyed(&cache, "k", || Ok::<_, EmbedderError>(Arc::new(999))).unwrap();
1516 assert_eq!(*v2, 7);
1517 assert!(ms2.is_none(), "warm reuse reports no load latency");
1518 }
1519
1520 #[test]
1521 fn distinct_key_loads_run_concurrently() {
1522 use std::sync::Barrier;
1523 use std::sync::atomic::{AtomicUsize, Ordering};
1524 use std::thread;
1525
1526 let cache: Arc<Mutex<HashMap<String, LoadSlot<i32>>>> =
1531 Arc::new(Mutex::new(HashMap::new()));
1532 let barrier = Arc::new(Barrier::new(2));
1533 let inflight = Arc::new(AtomicUsize::new(0));
1534 let (done_tx, done_rx) = mpsc::channel();
1535
1536 for (i, key) in ["a", "b"].into_iter().enumerate() {
1537 let cache = Arc::clone(&cache);
1538 let barrier = Arc::clone(&barrier);
1539 let inflight = Arc::clone(&inflight);
1540 let done_tx = done_tx.clone();
1541 thread::spawn(move || {
1542 let result = get_or_load_keyed(&cache, key, || {
1543 inflight.fetch_add(1, Ordering::SeqCst);
1544 barrier.wait(); Ok::<_, EmbedderError>(Arc::new(i as i32))
1546 });
1547 let _ = done_tx.send(result.map(|(v, _)| *v));
1548 });
1549 }
1550 drop(done_tx);
1551
1552 let mut got = Vec::new();
1553 for _ in 0..2 {
1554 let value = done_rx
1555 .recv_timeout(Duration::from_secs(5))
1556 .expect("distinct-key loads did not run concurrently (map lock held across load)");
1557 got.push(value.unwrap());
1558 }
1559 got.sort_unstable();
1560 assert_eq!(got, vec![0, 1]);
1561 assert_eq!(inflight.load(Ordering::SeqCst), 2);
1562 }
1563
1564 #[test]
1565 fn same_key_load_is_single_flight() {
1566 use std::sync::Barrier;
1567 use std::sync::atomic::{AtomicUsize, Ordering};
1568 use std::thread;
1569
1570 const THREADS: usize = 8;
1573 let cache: Arc<Mutex<HashMap<String, LoadSlot<i32>>>> =
1574 Arc::new(Mutex::new(HashMap::new()));
1575 let loads = Arc::new(AtomicUsize::new(0));
1576 let start = Arc::new(Barrier::new(THREADS));
1577 let (tx, rx) = mpsc::channel();
1578
1579 for _ in 0..THREADS {
1580 let cache = Arc::clone(&cache);
1581 let loads = Arc::clone(&loads);
1582 let start = Arc::clone(&start);
1583 let tx = tx.clone();
1584 thread::spawn(move || {
1585 start.wait(); let result = get_or_load_keyed(&cache, "shared", || {
1587 loads.fetch_add(1, Ordering::SeqCst);
1588 Ok::<_, EmbedderError>(Arc::new(42))
1589 });
1590 let (value, ms) = result.unwrap();
1591 let _ = tx.send((*value, ms.is_some()));
1592 });
1593 }
1594 drop(tx);
1595
1596 let mut reported = 0;
1597 for _ in 0..THREADS {
1598 let (value, loaded) = rx.recv_timeout(Duration::from_secs(5)).unwrap();
1599 assert_eq!(value, 42);
1600 if loaded {
1601 reported += 1;
1602 }
1603 }
1604 assert_eq!(
1605 loads.load(Ordering::SeqCst),
1606 1,
1607 "load must run exactly once per key"
1608 );
1609 assert_eq!(reported, 1, "exactly one caller reports the load latency");
1610 }
1611
1612 #[test]
1613 fn load_event_flags_slow_ok_failed_and_warm() {
1614 assert!(matches!(
1616 embedder_load_event("m", Some(10_000), None),
1617 Some(TraceEvent::EmbedderLoad {
1618 status: EmbedderLoadStatus::Slow,
1619 took_ms: 10_000,
1620 ..
1621 })
1622 ));
1623 assert!(matches!(
1625 embedder_load_event("m", Some(5), None),
1626 Some(TraceEvent::EmbedderLoad {
1627 status: EmbedderLoadStatus::Ok,
1628 ..
1629 })
1630 ));
1631 let err = EmbedderError::Inference { source: "x".into() };
1633 assert!(matches!(
1634 embedder_load_event("m", None, Some(&err)),
1635 Some(TraceEvent::EmbedderLoad {
1636 status: EmbedderLoadStatus::Failed,
1637 ..
1638 })
1639 ));
1640 assert!(embedder_load_event("m", None, None).is_none());
1642 }
1643
1644 #[test]
1645 fn endpoint_embeddings_are_normalized_and_ordered_by_index() {
1646 let resp = EmbeddingsResponse {
1649 model: Some("resolved-model".into()),
1650 data: vec![
1651 EmbeddingData {
1652 embedding: vec![0.0, 3.0], index: 1,
1654 },
1655 EmbeddingData {
1656 embedding: vec![4.0, 0.0], index: 0,
1658 },
1659 ],
1660 };
1661 let out = parse_embeddings(resp, 2).expect("parse");
1662 assert_eq!(out.vectors[0], vec![1.0, 0.0], "index 0 first, normalized");
1663 assert_eq!(out.vectors[1], vec![0.0, 1.0], "index 1 second, normalized");
1664 assert_eq!(out.model.as_deref(), Some("resolved-model"));
1665 }
1666
1667 #[test]
1668 fn endpoint_response_count_mismatch_errors() {
1669 let resp = EmbeddingsResponse {
1670 model: None,
1671 data: vec![EmbeddingData {
1672 embedding: vec![1.0],
1673 index: 0,
1674 }],
1675 };
1676 assert!(matches!(
1677 parse_embeddings(resp, 2),
1678 Err(EmbedderError::Inference { .. })
1679 ));
1680 }
1681
1682 fn response(vectors: &[(usize, Vec<f32>)]) -> EmbeddingsResponse {
1683 EmbeddingsResponse {
1684 model: None,
1685 data: vectors
1686 .iter()
1687 .cloned()
1688 .map(|(index, embedding)| EmbeddingData { embedding, index })
1689 .collect(),
1690 }
1691 }
1692
1693 #[test]
1694 fn endpoint_response_requires_an_exact_index_permutation() {
1695 assert!(
1696 serde_json::from_value::<EmbeddingsResponse>(serde_json::json!({
1697 "data": [{ "embedding": [1.0] }]
1698 }))
1699 .is_err(),
1700 "index is required"
1701 );
1702 for malformed in [
1703 response(&[(0, vec![1.0]), (0, vec![1.0])]),
1704 response(&[(0, vec![1.0]), (2, vec![1.0])]),
1705 ] {
1706 assert!(matches!(
1707 parse_embeddings(malformed, 2),
1708 Err(EmbedderError::Inference { .. })
1709 ));
1710 }
1711 }
1712
1713 #[test]
1714 fn endpoint_response_rejects_invalid_vectors() {
1715 for malformed in [
1716 response(&[(0, vec![])]),
1717 response(&[(0, vec![0.0, 0.0])]),
1718 response(&[(0, vec![f32::NAN, 1.0])]),
1719 response(&[(0, vec![f32::INFINITY, 1.0])]),
1720 response(&[(0, vec![1.0, 0.0]), (1, vec![1.0])]),
1721 ] {
1722 let expected_len = malformed.data.len();
1723 assert!(matches!(
1724 parse_embeddings(malformed, expected_len),
1725 Err(EmbedderError::Inference { .. })
1726 ));
1727 }
1728 }
1729
1730 #[test]
1731 fn endpoint_rejects_a_response_over_64_mib() {
1732 let (url, requests_rx, server) = mock_endpoint(vec![MockReply::Oversized]);
1733 let embedder = EndpointEmbedder::new(
1734 url,
1735 "requested-model".into(),
1736 None,
1737 String::new(),
1738 String::new(),
1739 )
1740 .unwrap();
1741
1742 let err = embedder
1743 .embed_batch(&["one".to_string()])
1744 .expect_err("oversized response must fail");
1745 let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1746 server.join().unwrap();
1747
1748 assert!(err.to_string().contains("oversized"), "got: {err}");
1749 assert_eq!(requests.len(), 1);
1750 }
1751
1752 #[test]
1753 fn endpoint_batches_65_inputs_as_64_plus_1_and_preserves_global_order() {
1754 let (url, requests_rx, server) = mock_endpoint(vec![
1755 MockReply::Embeddings("resolved-model"),
1756 MockReply::Embeddings("resolved-model"),
1757 ]);
1758
1759 let embedder = EndpointEmbedder::new(
1760 url,
1761 "requested-model".into(),
1762 None,
1763 String::new(),
1764 String::new(),
1765 )
1766 .unwrap();
1767 let inputs = (0..65).map(|index| index.to_string()).collect::<Vec<_>>();
1768 let embedded = embedder.embed_batch_with_identity(&inputs).unwrap();
1769 let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1770 server.join().unwrap();
1771
1772 assert_eq!(
1773 requests
1774 .iter()
1775 .map(|request| request.inputs.len())
1776 .collect::<Vec<_>>(),
1777 vec![64, 1]
1778 );
1779 assert_eq!(
1780 requests
1781 .into_iter()
1782 .flat_map(|request| request.inputs)
1783 .collect::<Vec<_>>(),
1784 inputs
1785 );
1786 assert_eq!(embedded.value.len(), 65);
1787 assert!(embedded.fingerprint.contains("resolved-model"));
1788 }
1789
1790 #[test]
1791 fn second_chunk_failure_commits_neither_chunk_and_retry_sends_all_inputs() {
1792 let (url, requests_rx, server) = mock_endpoint(vec![
1793 MockReply::Embeddings("resolved-model"),
1794 MockReply::Status(500),
1795 MockReply::Embeddings("resolved-model"),
1796 MockReply::Embeddings("resolved-model"),
1797 ]);
1798 let mut registry = ToolRegistry::with_embedding(EmbeddingModel::Endpoint {
1799 url,
1800 model: "requested-model".into(),
1801 api_key_env: None,
1802 query_prefix: None,
1803 doc_prefix: None,
1804 });
1805 for index in 0..65 {
1806 registry.register(tool_for_endpoint(index));
1807 }
1808
1809 assert!(registry.build_embeddings().is_err());
1810 registry.build_embeddings().unwrap();
1811 let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1812 server.join().unwrap();
1813
1814 assert_eq!(
1815 requests
1816 .iter()
1817 .map(|request| request.inputs.len())
1818 .collect::<Vec<_>>(),
1819 vec![64, 1, 64, 1]
1820 );
1821 }
1822
1823 #[test]
1824 fn endpoint_cache_separates_api_key_env_names_and_sends_each_bearer_token() {
1825 const KEY_A: &str = "RATEL_CORE_ENDPOINT_TEST_KEY_A";
1826 const KEY_B: &str = "RATEL_CORE_ENDPOINT_TEST_KEY_B";
1827 unsafe {
1829 std::env::set_var(KEY_A, "alpha-token");
1830 std::env::set_var(KEY_B, "beta-token");
1831 }
1832 let (url, requests_rx, server) = mock_endpoint(vec![
1833 MockReply::Embeddings("resolved-model"),
1834 MockReply::Embeddings("resolved-model"),
1835 ]);
1836 for (id, env_name) in [("a", KEY_A), ("b", KEY_B)] {
1837 let mut registry = ToolRegistry::with_embedding(EmbeddingModel::Endpoint {
1838 url: url.clone(),
1839 model: "requested-model".into(),
1840 api_key_env: Some(env_name.into()),
1841 query_prefix: None,
1842 doc_prefix: None,
1843 });
1844 registry.register(Tool {
1845 id: id.into(),
1846 name: id.into(),
1847 description: "endpoint auth test".into(),
1848 input_schema: serde_json::json!({}),
1849 output_schema: serde_json::json!({}),
1850 });
1851 registry.build_embeddings().unwrap();
1852 }
1853 let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1854 server.join().unwrap();
1855 unsafe {
1856 std::env::remove_var(KEY_A);
1857 std::env::remove_var(KEY_B);
1858 }
1859
1860 assert_eq!(
1861 requests
1862 .into_iter()
1863 .map(|request| request.authorization)
1864 .collect::<Vec<_>>(),
1865 vec![
1866 Some("Bearer alpha-token".into()),
1867 Some("Bearer beta-token".into())
1868 ]
1869 );
1870 }
1871
1872 #[test]
1873 fn response_model_drift_is_hard_and_rebuild_adopts_the_new_identity() {
1874 let (url, requests_rx, server) = mock_endpoint(vec![
1875 MockReply::Embeddings("model-a"),
1876 MockReply::Embeddings("model-b"),
1877 MockReply::Embeddings("model-b"),
1878 MockReply::Embeddings("model-b"),
1879 ]);
1880 let mut registry = ToolRegistry::with_embedding(EmbeddingModel::Endpoint {
1881 url,
1882 model: "requested-model".into(),
1883 api_key_env: None,
1884 query_prefix: None,
1885 doc_prefix: None,
1886 });
1887 registry.register(tool_for_endpoint(0));
1888 registry.build_embeddings().unwrap();
1889
1890 assert!(matches!(
1891 registry.search_with_method("tool", 1, Origin::Direct, SearchMethod::Semantic),
1892 Err(EmbedderError::ModelMismatch { .. })
1893 ));
1894 registry.rebuild_embeddings().unwrap();
1895 assert_eq!(
1896 registry
1897 .search_with_method("tool", 1, Origin::Direct, SearchMethod::Semantic)
1898 .unwrap()
1899 .len(),
1900 1
1901 );
1902 let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1903 server.join().unwrap();
1904 assert_eq!(requests.len(), 4);
1905 }
1906
1907 fn tool_for_endpoint(index: usize) -> Tool {
1908 Tool {
1909 id: format!("tool-{index}"),
1910 name: format!("tool-{index}"),
1911 description: format!("endpoint tool {index}"),
1912 input_schema: serde_json::json!({}),
1913 output_schema: serde_json::json!({}),
1914 }
1915 }
1916
1917 #[derive(Clone, Copy)]
1918 enum MockReply {
1919 Embeddings(&'static str),
1920 Status(u16),
1921 Oversized,
1922 }
1923
1924 struct MockRequest {
1925 inputs: Vec<String>,
1926 authorization: Option<String>,
1927 }
1928
1929 type MockServer = (
1930 String,
1931 mpsc::Receiver<Vec<MockRequest>>,
1932 std::thread::JoinHandle<()>,
1933 );
1934
1935 fn mock_endpoint(replies: Vec<MockReply>) -> MockServer {
1936 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1937 listener.set_nonblocking(true).unwrap();
1938 let url = format!("http://{}/v1/embeddings", listener.local_addr().unwrap());
1939 let (requests_tx, requests_rx) = mpsc::channel();
1940 let server = std::thread::spawn(move || {
1941 let deadline = Instant::now() + Duration::from_secs(5);
1942 let mut replies = std::collections::VecDeque::from(replies);
1943 let mut requests = Vec::new();
1944 while let Some(reply) = replies.front().copied() {
1945 match listener.accept() {
1946 Ok((mut stream, _)) => {
1947 stream.set_nonblocking(false).unwrap();
1948 let (body, authorization) = read_http_request(&mut stream);
1949 let inputs = body["input"]
1950 .as_array()
1951 .expect("input array")
1952 .iter()
1953 .map(|value| value.as_str().expect("string input").to_string())
1954 .collect::<Vec<_>>();
1955 write_mock_response(&mut stream, reply, inputs.len());
1956 requests.push(MockRequest {
1957 inputs,
1958 authorization,
1959 });
1960 replies.pop_front();
1961 }
1962 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
1963 if Instant::now() >= deadline {
1964 break;
1965 }
1966 std::thread::sleep(Duration::from_millis(5));
1967 }
1968 Err(error) => panic!("accept failed: {error}"),
1969 }
1970 }
1971 requests_tx.send(requests).unwrap();
1972 });
1973 (url, requests_rx, server)
1974 }
1975
1976 fn write_mock_response(stream: &mut std::net::TcpStream, reply: MockReply, input_len: usize) {
1977 let (status, response) = match reply {
1978 MockReply::Embeddings(model) => {
1979 let data = (0..input_len)
1980 .map(|index| {
1981 serde_json::json!({
1982 "index": index,
1983 "embedding": [1.0, 0.0]
1984 })
1985 })
1986 .collect::<Vec<_>>();
1987 (
1988 "200 OK",
1989 serde_json::json!({ "data": data, "model": model }).to_string(),
1990 )
1991 }
1992 MockReply::Status(code) => {
1993 ("500 Internal Server Error", format!("{{\"code\":{code}}}"))
1994 }
1995 MockReply::Oversized => {
1996 write_oversized_response(stream);
1997 return;
1998 }
1999 };
2000 write!(
2001 stream,
2002 "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
2003 response.len(),
2004 response
2005 )
2006 .unwrap();
2007 }
2008
2009 fn write_oversized_response(stream: &mut std::net::TcpStream) {
2010 const PREFIX: &[u8] = b"{\"data\":[],\"padding\":\"";
2011 const SUFFIX: &[u8] = b"\"}";
2012 const CHUNK: &[u8] = &[b'x'; 64 * 1024];
2013 let padding_len = ENDPOINT_RESPONSE_LIMIT_BYTES;
2014 let content_len = PREFIX.len() as u64 + padding_len + SUFFIX.len() as u64;
2015 write!(
2016 stream,
2017 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {content_len}\r\nconnection: close\r\n\r\n"
2018 )
2019 .unwrap();
2020 if stream.write_all(PREFIX).is_err() {
2021 return;
2022 }
2023 let mut remaining = padding_len;
2024 while remaining > 0 {
2025 let len = remaining.min(CHUNK.len() as u64) as usize;
2026 if stream.write_all(&CHUNK[..len]).is_err() {
2027 return;
2028 }
2029 remaining -= len as u64;
2030 }
2031 let _ = stream.write_all(SUFFIX);
2032 }
2033
2034 fn read_http_request(stream: &mut std::net::TcpStream) -> (serde_json::Value, Option<String>) {
2035 stream
2036 .set_read_timeout(Some(Duration::from_secs(2)))
2037 .unwrap();
2038 let mut request = Vec::new();
2039 let mut buffer = [0_u8; 4096];
2040 loop {
2041 let read = stream.read(&mut buffer).unwrap();
2042 assert!(read > 0, "connection closed before request body");
2043 request.extend_from_slice(&buffer[..read]);
2044 if let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") {
2045 let body_start = header_end + 4;
2046 let headers = std::str::from_utf8(&request[..header_end]).unwrap();
2047 let content_len = headers
2048 .lines()
2049 .find_map(|line| {
2050 let (name, value) = line.split_once(':')?;
2051 name.eq_ignore_ascii_case("content-length")
2052 .then(|| value.trim().parse::<usize>().unwrap())
2053 })
2054 .expect("content-length");
2055 if request.len() >= body_start + content_len {
2056 let authorization = headers.lines().find_map(|line| {
2057 let (name, value) = line.split_once(':')?;
2058 name.eq_ignore_ascii_case("authorization")
2059 .then(|| value.trim().to_string())
2060 });
2061 let body =
2062 serde_json::from_slice(&request[body_start..body_start + content_len])
2063 .unwrap();
2064 return (body, authorization);
2065 }
2066 }
2067 }
2068 }
2069
2070 #[test]
2071 fn mean_pool_averages_only_unmasked_tokens() {
2072 let dev = Device::Cpu;
2073 let seq = Tensor::new(&[[[1.0f32, 2.0], [3.0, 4.0]]], &dev).unwrap();
2075 let all = Tensor::new(&[[1u32, 1]], &dev).unwrap();
2077 assert_eq!(
2078 mean_pool(&seq, &all).unwrap().to_vec1::<f32>().unwrap(),
2079 vec![2.0, 3.0]
2080 );
2081 let first = Tensor::new(&[[1u32, 0]], &dev).unwrap();
2083 assert_eq!(
2084 mean_pool(&seq, &first).unwrap().to_vec1::<f32>().unwrap(),
2085 vec![1.0, 2.0]
2086 );
2087 }
2088
2089 #[test]
2090 fn mean_pool_batch_averages_each_row_over_its_own_unmasked_tokens() {
2091 let dev = Device::Cpu;
2092 let seq = Tensor::new(
2094 &[[[1.0f32, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]],
2095 &dev,
2096 )
2097 .unwrap();
2098 let mask = Tensor::new(&[[1u32, 1], [1, 0]], &dev).unwrap();
2100 assert_eq!(
2101 mean_pool_batch(&seq, &mask)
2102 .unwrap()
2103 .to_vec2::<f32>()
2104 .unwrap(),
2105 vec![vec![2.0, 3.0], vec![5.0, 6.0]]
2106 );
2107 }
2108
2109 #[test]
2110 fn parse_pooling_config_maps_cls_mean_and_none() {
2111 assert_eq!(
2112 parse_pooling_config(br#"{"pooling_mode_cls_token": true}"#),
2113 Some(Pooling::Cls)
2114 );
2115 assert_eq!(
2116 parse_pooling_config(br#"{"pooling_mode_mean_tokens": true}"#),
2117 Some(Pooling::Mean)
2118 );
2119 assert_eq!(
2121 parse_pooling_config(br#"{"pooling_mode_max_tokens": true}"#),
2122 None
2123 );
2124 assert_eq!(parse_pooling_config(b"not json"), None);
2125 }
2126
2127 #[test]
2128 fn resolve_pooling_assumes_mean_and_flags_it() {
2129 assert_eq!(resolve_pooling(Some(Pooling::Cls)), (Pooling::Cls, false));
2130 assert_eq!(resolve_pooling(Some(Pooling::Mean)), (Pooling::Mean, false));
2131 assert_eq!(resolve_pooling(None), (Pooling::Mean, true));
2132 }
2133
2134 #[test]
2135 fn is_not_found_distinguishes_missing_file_from_network_error() {
2136 assert!(is_not_found("Http status client error (404 Not Found)"));
2137 assert!(is_not_found("Entry Not Found"));
2138 assert!(!is_not_found("error sending request: connection refused"));
2139 }
2140
2141 #[test]
2142 fn endpoint_missing_api_key_env_is_a_config_error() {
2143 let e = EndpointEmbedder::new(
2144 "http://localhost:11434/v1/embeddings".into(),
2145 "nomic".into(),
2146 Some("RATEL_TEST_DEFINITELY_UNSET_KEY".into()),
2147 String::new(),
2148 String::new(),
2149 )
2150 .unwrap();
2151 let err = e.api_key().unwrap_err();
2152 assert!(matches!(err, EmbedderError::Config { .. }));
2153 assert!(err.to_string().contains("RATEL_TEST_DEFINITELY_UNSET_KEY"));
2154 }
2155
2156 #[test]
2157 #[ignore = "downloads the ~130 MB bge model; run with `cargo test -- --ignored`"]
2158 fn embeds_to_unit_norm_384_vectors_deterministically() {
2159 let e = embedder_for(&EmbeddingModel::Default)
2160 .expect("load embedder")
2161 .0;
2162 let a = e.embed_doc("read a file from disk").expect("embed");
2163 let b = e.embed_doc("read a file from disk").expect("embed");
2164 assert_eq!(a.len(), 384, "bge-small is 384-dim");
2165 assert_eq!(a, b, "same text must embed identically (determinism)");
2166 let norm = a.iter().map(|x| x * x).sum::<f32>().sqrt();
2167 assert!((norm - 1.0).abs() < 1e-3, "expected unit norm, got {norm}");
2168 }
2169
2170 #[test]
2171 #[ignore = "downloads the ~130 MB bge model; run with `cargo test -- --ignored`"]
2172 fn embed_batch_matches_looping_embed_doc_cls() {
2173 let e = embedder_for(&EmbeddingModel::Default)
2177 .expect("load embedder")
2178 .0;
2179 let docs: Vec<String> = (0..EMBED_BATCH_CHUNK + 5)
2180 .map(|i| format!("{}read a file from disk", "word ".repeat(i % 7)))
2181 .collect();
2182 let batched = e.embed_batch(&docs).expect("embed_batch");
2183 let looped: Vec<Vec<f32>> = docs
2184 .iter()
2185 .map(|d| e.embed_doc(d).expect("embed_doc"))
2186 .collect();
2187 assert_eq!(
2188 batched, looped,
2189 "batched embedding must be bit-for-bit identical to the per-doc path"
2190 );
2191 }
2192
2193 #[test]
2194 #[ignore = "downloads a mean-pooled model (gte-small); run with `cargo test -- --ignored`"]
2195 fn embed_batch_matches_looping_embed_doc_mean() {
2196 let model = EmbeddingModel::HuggingFace {
2198 repo: "thenlper/gte-small".into(),
2199 revision: None,
2200 query_prefix: None,
2201 doc_prefix: None,
2202 pooling: None, download: true, };
2205 let e = embedder_for(&model).expect("load gte-small").0;
2206 let docs: Vec<String> = (0..EMBED_BATCH_CHUNK + 3)
2207 .map(|i| format!("{}deploy the service", "x ".repeat(i % 5)))
2208 .collect();
2209 let batched = e.embed_batch(&docs).expect("embed_batch");
2210 let looped: Vec<Vec<f32>> = docs
2211 .iter()
2212 .map(|d| e.embed_doc(d).expect("embed_doc"))
2213 .collect();
2214 assert_eq!(
2215 batched, looped,
2216 "mean-pooled batched embedding must be bit-for-bit identical to the per-doc path"
2217 );
2218 }
2219
2220 #[test]
2221 #[ignore = "downloads the ~130 MB bge model; run with `cargo test -- --ignored`"]
2222 fn query_prefix_changes_the_embedding() {
2223 let e = embedder_for(&EmbeddingModel::Default)
2224 .expect("load embedder")
2225 .0;
2226 let doc = e.embed_doc("delete a file").expect("embed");
2227 let query = e.embed_query("delete a file").expect("embed");
2228 assert_ne!(doc, query, "query instruction prefix must shift the vector");
2229 }
2230
2231 #[test]
2232 #[ignore = "downloads the ~130 MB bge model; run with `cargo test -- --ignored`"]
2233 fn ranks_synonyms_above_lexically_unrelated_text() {
2234 let e = embedder_for(&EmbeddingModel::Default)
2236 .expect("load embedder")
2237 .0;
2238 let q = e.embed_query("remove a file").expect("embed");
2239 let delete = e
2240 .embed_doc("delete a path from the filesystem")
2241 .expect("embed");
2242 let weather = e
2243 .embed_doc("get the current weather forecast")
2244 .expect("embed");
2245 let dot = |a: &[f32], b: &[f32]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>();
2246 assert!(
2247 dot(&q, &delete) > dot(&q, &weather),
2248 "semantic match should beat an unrelated tool"
2249 );
2250 }
2251
2252 #[test]
2253 #[ignore = "downloads a mean-pooled model (gte-small); run with `cargo test -- --ignored`"]
2254 fn mean_pooled_model_ranks_synonyms_correctly() {
2255 let model = EmbeddingModel::HuggingFace {
2259 repo: "thenlper/gte-small".into(),
2260 revision: None,
2261 query_prefix: None,
2262 doc_prefix: None,
2263 pooling: None, download: true, };
2266 let e = embedder_for(&model).expect("load gte-small").0;
2267 let q = e.embed_query("remove a file").expect("embed");
2268 let delete = e
2269 .embed_doc("delete a path from the filesystem")
2270 .expect("embed");
2271 let weather = e
2272 .embed_doc("get the current weather forecast")
2273 .expect("embed");
2274 let dot = |a: &[f32], b: &[f32]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>();
2275 assert!(
2276 dot(&q, &delete) > dot(&q, &weather),
2277 "mean-pooled semantic match should beat an unrelated tool"
2278 );
2279 }
2280}