Skip to main content

wm_memory/
embedder.rs

1//! Local text embedder — generates vector embeddings for semantic search.
2//!
3//! Ported from v2's `local_embedder.py` (313 lines). Provides a trait-based
4//! embedder abstraction with two implementations:
5//!
6//! - `HttpEmbedder`: Calls llama-server's `/v1/embeddings` endpoint (preferred,
7//!   no model download needed — uses the already-running llama-server)
8//! - `StubEmbedder`: Hash-based pseudo-embeddings for testing/fallback
9//!
10//! Future: An `ort` (ONNX Runtime) embedder can implement the same trait for
11//! fully local embeddings without a server dependency.
12//!
13//! # Environment Variables
14//!
15//! | Variable | Default | Description |
16//! |----------|---------|-------------|
17//! | `WM_EMBEDDER_ENDPOINT` | — | llama-server HTTP URL (e.g. `http://localhost:8080`) |
18//! | `WM_EMBEDDER_MODEL` | `local` | Model name for the embeddings API |
19//! | `WM_EMBEDDER_DIM` | `384` | Expected embedding dimensionality |
20//! | `WM_EMBEDDER_TIMEOUT_MS` | `30000` | Request timeout in milliseconds |
21
22#![allow(clippy::cast_possible_wrap)]
23
24use serde::{Deserialize, Serialize};
25use std::time::Duration;
26use wm_core::{CoreError, Result};
27
28/// Trait for text embedding providers.
29///
30/// Implementations:
31/// - `HttpEmbedder` — llama-server `/v1/embeddings` endpoint
32/// - `StubEmbedder` — hash-based pseudo-embeddings (for testing)
33pub trait Embedder: Send + Sync {
34    /// Embed a batch of texts into f32 vectors.
35    ///
36    /// Returns one vector per input text. All vectors have the same dimensionality.
37    fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>;
38
39    /// Embed a single text.
40    fn embed(&self, text: &str) -> Result<Vec<f32>> {
41        self.embed_batch(&[text])?
42            .into_iter()
43            .next()
44            .ok_or_else(|| CoreError::Memory("embedder returned empty result".into()))
45    }
46
47    /// Embed a single query (alias for `embed`).
48    fn embed_query(&self, query: &str) -> Result<Vec<f32>> {
49        self.embed(query)
50    }
51
52    /// Get the embedding dimensionality.
53    fn dimension(&self) -> usize;
54
55    /// Whether this embedder is available (model loaded, server reachable).
56    fn is_available(&self) -> bool;
57
58    /// Name of this embedder backend.
59    fn backend_name(&self) -> &'static str;
60
61    /// Identity used to namespace the persistent embedding cache.
62    ///
63    /// Vectors for identical text differ across models, quantization, and
64    /// endpoints, so the cache key must carry this — switching models must
65    /// never serve stale vectors. Default is the backend name.
66    fn cache_namespace(&self) -> String {
67        self.backend_name().to_string()
68    }
69
70    /// Preferred batch granularity in TEXTS for batch write paths.
71    ///
72    /// The write path's char-chunking exists for HTTP token limits; local
73    /// engines have no such limit and want bigger batches so session
74    /// pools fan out efficiently (4-7 texts per call starves each shard).
75    /// `usize::MAX` = char-chunking only (the HTTP shape).
76    fn preferred_max_batch_texts(&self) -> usize {
77        usize::MAX
78    }
79}
80
81/// Configuration for the HTTP-based embedder.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct EmbedderConfig {
84    /// llama-server HTTP API URL (e.g. `http://localhost:8080`).
85    pub endpoint: String,
86    /// Model name for the embeddings API.
87    pub model: String,
88    /// Expected embedding dimensionality.
89    pub dimension: usize,
90    /// Request timeout.
91    pub timeout: Duration,
92}
93
94impl EmbedderConfig {
95    /// Create a config from environment variables.
96    ///
97    /// Returns `None` if `WM_EMBEDDER_ENDPOINT` is not set or if the endpoint
98    /// fails SSRF validation (non-HTTP scheme, metadata endpoint, etc.).
99    #[must_use]
100    pub fn from_env() -> Option<Self> {
101        let endpoint = std::env::var("WM_EMBEDDER_ENDPOINT").ok()?;
102        if !is_endpoint_safe(&endpoint) {
103            tracing::warn!(
104                "embedder endpoint rejected by SSRF validation: {}",
105                endpoint
106            );
107            return None;
108        }
109        let model = std::env::var("WM_EMBEDDER_MODEL").unwrap_or_else(|_| "local".into());
110        let dimension = std::env::var("WM_EMBEDDER_DIM")
111            .ok()
112            .and_then(|v| v.parse::<usize>().ok())
113            .unwrap_or(384);
114        let timeout_ms = std::env::var("WM_EMBEDDER_TIMEOUT_MS")
115            .ok()
116            .and_then(|v| v.parse::<u64>().ok())
117            .unwrap_or(30_000);
118
119        Some(Self {
120            endpoint,
121            model,
122            dimension,
123            timeout: Duration::from_millis(timeout_ms),
124        })
125    }
126}
127
128/// Validate an embedder endpoint URL for SSRF safety.
129///
130/// Unlike `wm_core::security::is_url_safe`, this allows localhost and private
131/// IPs because the embedder is typically a local llama-server. It blocks:
132/// - Non-HTTP(S) schemes (file://, gopher://, ftp://, etc.)
133/// - Cloud metadata endpoints (169.254.169.254, metadata.google.internal)
134/// - Malformed URLs
135#[must_use]
136pub fn is_endpoint_safe(endpoint: &str) -> bool {
137    // Must start with http:// or https://
138    if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
139        return false;
140    }
141
142    // Extract host
143    let without_scheme = endpoint
144        .strip_prefix("http://")
145        .or_else(|| endpoint.strip_prefix("https://"))
146        .unwrap_or(endpoint);
147
148    let host_end = without_scheme
149        .find(['/', '?', '#'])
150        .unwrap_or(without_scheme.len());
151    let host_port = &without_scheme[..host_end];
152
153    // Handle IPv6 bracket notation
154    let host = if host_port.starts_with('[') {
155        if let Some(end) = host_port.find(']') {
156            &host_port[1..end]
157        } else {
158            return false; // Malformed IPv6
159        }
160    } else {
161        host_port.rsplit_once(':').map_or(host_port, |(h, _)| h)
162    };
163
164    if host.is_empty() {
165        return false;
166    }
167
168    // Block cloud metadata endpoints
169    let lower = host.to_ascii_lowercase();
170    if matches!(
171        lower.as_str(),
172        "metadata.google.internal"
173            | "metadata.aws.internal"
174            | "metadata"
175            | "169.254.169.254"
176            | "169.254.170.2"
177    ) {
178        return false;
179    }
180
181    true
182}
183
184/// HTTP-based embedder using llama-server's `/v1/embeddings` endpoint.
185///
186/// Requires llama-server started with `--embeddings` flag.
187/// Dimension depends on the loaded GGUF model (e.g. 384 for bge-small,
188/// 768 for bge-base, 1024 for bge-large).
189pub struct HttpEmbedder {
190    config: EmbedderConfig,
191    agent: ureq::Agent,
192    available: bool,
193}
194
195impl HttpEmbedder {
196    /// Create a new HTTP embedder with the given config.
197    #[must_use]
198    pub fn new(config: EmbedderConfig) -> Self {
199        let agent = ureq::config::Config::builder()
200            .timeout_global(Some(config.timeout))
201            .build()
202            .new_agent();
203        Self {
204            config,
205            agent,
206            available: true,
207        }
208    }
209
210    /// Create from environment variables, if configured.
211    #[must_use]
212    pub fn from_env() -> Option<Self> {
213        EmbedderConfig::from_env().map(Self::new)
214    }
215
216    /// Build the embeddings endpoint URL.
217    fn embeddings_url(&self) -> String {
218        if self.config.endpoint.ends_with("/v1/embeddings") {
219            self.config.endpoint.clone()
220        } else if self.config.endpoint.ends_with('/') {
221            format!("{}v1/embeddings", self.config.endpoint)
222        } else {
223            format!("{}/v1/embeddings", self.config.endpoint)
224        }
225    }
226}
227
228/// Conservative character budget per HTTP-embedder input.
229///
230/// The served model family (bge-small) has a 512-token window; dense text
231/// can tokenize at ~2 chars/token, so 1024 chars stays inside the window
232/// without a client-side tokenizer. Only the embedding input is truncated —
233/// stored content and the BM25 index keep the full text.
234const HTTP_EMBED_MAX_CHARS: usize = 1024;
235
236/// Truncate an embedding input to the character budget on a UTF-8 boundary.
237fn truncate_for_embedding(text: &str) -> &str {
238    if text.len() <= HTTP_EMBED_MAX_CHARS {
239        return text;
240    }
241    let mut end = HTTP_EMBED_MAX_CHARS;
242    while !text.is_char_boundary(end) {
243        end -= 1;
244    }
245    &text[..end]
246}
247
248impl Embedder for HttpEmbedder {
249    fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
250        if texts.is_empty() {
251            return Ok(Vec::new());
252        }
253
254        let url = self.embeddings_url();
255
256        // Oversized inputs are rejected by the server (HTTP 400, live-caught
257        // 2026-09-12: a 2033-char memory failed memory.reembed). Truncate to
258        // the model window instead of failing the whole batch.
259        let prepared: Vec<&str> = texts.iter().map(|t| truncate_for_embedding(t)).collect();
260        let truncated = texts
261            .iter()
262            .filter(|t| t.len() > HTTP_EMBED_MAX_CHARS)
263            .count();
264        if truncated > 0 {
265            tracing::debug!(
266                truncated,
267                budget_chars = HTTP_EMBED_MAX_CHARS,
268                "http embedder truncated oversized input(s) to the model window"
269            );
270        }
271
272        // OpenAI-compatible embeddings request
273        let request = EmbeddingsRequest {
274            model: &self.config.model,
275            input: &prepared,
276        };
277
278        let response = self
279            .agent
280            .post(&url)
281            .header("Content-Type", "application/json")
282            .send_json(&request)
283            .map_err(|e| CoreError::Memory(format!("Embedder HTTP error: {e}")))?;
284
285        let embed_resp: EmbeddingsResponse = response
286            .into_body()
287            .read_json()
288            .map_err(|e| CoreError::Memory(format!("Embedder response parse error: {e}")))?;
289
290        let vectors: Vec<Vec<f32>> = embed_resp.data.into_iter().map(|d| d.embedding).collect();
291
292        if vectors.len() != texts.len() {
293            return Err(CoreError::Memory(format!(
294                "Embedder returned {} vectors for {} inputs",
295                vectors.len(),
296                texts.len()
297            )));
298        }
299
300        Ok(vectors)
301    }
302
303    fn dimension(&self) -> usize {
304        self.config.dimension
305    }
306
307    fn is_available(&self) -> bool {
308        self.available
309    }
310
311    fn backend_name(&self) -> &'static str {
312        "http"
313    }
314
315    fn cache_namespace(&self) -> String {
316        // Endpoint + model alias + dimension. A different model served under
317        // the same alias remains undetectable without a fingerprint probe
318        // (llama.cpp `/props`); that alias-swap hazard is an operator
319        // responsibility, documented here so the namespace claim stays
320        // honest. Dimension is cheap extra separation, matching ONNX.
321        format!(
322            "http:{}:{}:{}",
323            self.config.endpoint, self.config.model, self.config.dimension
324        )
325    }
326}
327
328/// OpenAI-compatible embeddings request.
329#[derive(Debug, Serialize)]
330struct EmbeddingsRequest<'a> {
331    model: &'a str,
332    input: &'a [&'a str],
333}
334
335/// OpenAI-compatible embeddings response.
336#[derive(Debug, Deserialize)]
337struct EmbeddingsResponse {
338    data: Vec<EmbeddingData>,
339}
340
341#[derive(Debug, Deserialize)]
342struct EmbeddingData {
343    embedding: Vec<f32>,
344}
345
346/// Stub embedder — hash-based pseudo-embeddings for testing/fallback.
347///
348/// Generates deterministic embeddings from text content using SHA-256 hashing.
349/// Not useful for real semantic search, but provides a fallback when no
350/// embedder is available and allows tests to run without a server.
351pub struct StubEmbedder {
352    dimension: usize,
353}
354
355impl StubEmbedder {
356    /// Create a new stub embedder with the given dimensionality.
357    #[must_use]
358    pub const fn new(dimension: usize) -> Self {
359        Self { dimension }
360    }
361}
362
363impl Default for StubEmbedder {
364    fn default() -> Self {
365        Self::new(384)
366    }
367}
368
369impl Embedder for StubEmbedder {
370    fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
371        use sha2::{Digest, Sha256};
372
373        let mut results = Vec::with_capacity(texts.len());
374        for text in texts {
375            let mut hasher = Sha256::new();
376            hasher.update(text.as_bytes());
377            let hash = hasher.finalize();
378
379            // Expand hash to fill the desired dimension
380            let mut embedding = Vec::with_capacity(self.dimension);
381            for i in 0..self.dimension {
382                let byte = f32::from(hash[i % hash.len()]);
383                embedding.push(byte.mul_add(2.0 / 255.0, -1.0)); // Normalize to [-1, 1]
384            }
385            results.push(embedding);
386        }
387        Ok(results)
388    }
389
390    fn dimension(&self) -> usize {
391        self.dimension
392    }
393
394    fn is_available(&self) -> bool {
395        true
396    }
397
398    fn backend_name(&self) -> &'static str {
399        "stub"
400    }
401}
402
403/// ONNX Runtime-based embedder using `fastembed-rs`.
404///
405/// Provides fully local embeddings without a server dependency.
406/// Downloads model files on first use (cached thereafter).
407///
408/// Requires the `onnx` feature to be enabled:
409/// ```toml
410/// wm-memory = { features = ["onnx"] }
411/// ```
412///
413/// Default model: BAAI/bge-small-en-v1.5 (384 dimensions, ~130MB).
414///
415/// # Environment Variables
416///
417/// | Variable | Default | Description |
418/// |----------|---------|-------------|
419/// | `WM_EMBEDDER_ORT_MODEL` | `BAAI/bge-small-en-v1.5` | Model name (`-q` suffixes select INT8-quantized variants, e.g. `bge-small-q`) |
420/// | `WM_EMBEDDER_CACHE_DIR` | — | Cache directory for model files |
421/// | `WM_EMBEDDER_ORT_THREADS` | min(logical cores, 4) | Number of intra-op threads |
422#[cfg(feature = "onnx")]
423pub struct OrtEmbedder {
424    /// Sharded session pool (v26 `parallel/pools.py` idea, finally wired):
425    /// one ORT session per shard, mutex per shard — a batch fans out
426    /// across shards concurrently instead of serializing on one session.
427    /// Ingest-scale workloads are the target; single-text calls use
428    /// shard 0 directly.
429    shards: Vec<std::sync::Mutex<Option<fastembed::TextEmbedding>>>,
430    model_name: String,
431    cache_dir: Option<std::path::PathBuf>,
432    threads: usize,
433    dimension: usize,
434    available: std::sync::atomic::AtomicBool,
435    /// Pinned fastembed batch_size (32–64 band per the ship list).
436    batch_size: usize,
437}
438
439/// Default ORT intra-op thread count.
440///
441/// Defaulting to every logical core (hyperthreads included) saturates small
442/// machines — on a 4C/8T laptop the FP32 embedder at 8 threads drove the
443/// box into swap thrash and OOM (see `docs/POLYGLOT_SIMD_MEMORY_STRATEGY.md`).
444/// Physical-core count is not exposed by std; `min(logical, 4)` approximates
445/// it and stays conservative on both small and large machines. Explicit
446/// `WM_EMBEDDER_ORT_THREADS` always wins over this default (values above 4
447/// are honored explicitly — they raise the TOTAL intra-op budget).
448#[cfg(feature = "onnx")]
449fn default_intra_threads() -> usize {
450    std::thread::available_parallelism()
451        .map(std::num::NonZero::get)
452        .unwrap_or(4)
453        .min(4)
454}
455
456#[cfg(feature = "onnx")]
457impl OrtEmbedder {
458    /// Create a new ONNX embedder with the given configuration.
459    #[must_use]
460    pub fn new(
461        model_name: &str,
462        cache_dir: Option<std::path::PathBuf>,
463        threads: usize,
464        dimension: usize,
465    ) -> Self {
466        let threads = threads.max(1);
467        // Total intra-op budget = `threads`, distributed across the pool.
468        // MEASURED on the reference machine (2026-09-01, ingest probe +
469        // pool-shape microbench): in-server, 2 shards × intra/2 ≈ parity
470        // with the legacy single session, while full fan-out (4×1) LOSES
471        // ~11% to tokio-worker contention, and 1 shard pays the cache's
472        // small tax. So the default halves the budget into shards,
473        // capped at 4; `WM_EMBEDDER_ORT_SHARDS` overrides explicitly
474        // (1 = legacy single-session shape, higher = fuller fan-out for
475        // batch-heavy hosts).
476        let shard_count = (threads / 2).clamp(1, 4);
477        let shards = (0..shard_count)
478            .map(|_| std::sync::Mutex::new(None))
479            .collect();
480        Self {
481            shards,
482            model_name: model_name.to_string(),
483            cache_dir,
484            threads,
485            dimension,
486            available: std::sync::atomic::AtomicBool::new(false),
487            batch_size: 32,
488        }
489    }
490
491    /// Create from environment variables.
492    ///
493    /// Returns `None` if the `onnx` feature is not enabled or model cannot be loaded.
494    #[must_use]
495    pub fn from_env() -> Option<Self> {
496        let model_name = std::env::var("WM_EMBEDDER_ORT_MODEL")
497            .unwrap_or_else(|_| "BAAI/bge-small-en-v1.5".into());
498        let cache_dir = std::env::var("WM_EMBEDDER_CACHE_DIR")
499            .ok()
500            .map(std::path::PathBuf::from);
501        let threads = std::env::var("WM_EMBEDDER_ORT_THREADS")
502            .ok()
503            .and_then(|v| v.parse::<usize>().ok())
504            .unwrap_or_else(default_intra_threads);
505        let dimension = std::env::var("WM_EMBEDDER_DIM")
506            .ok()
507            .and_then(|v| v.parse::<usize>().ok())
508            .unwrap_or(384);
509
510        Some(Self::new(&model_name, cache_dir, threads, dimension))
511    }
512
513    /// Override the derived shard count (`WM_EMBEDDER_ORT_SHARDS`).
514    ///
515    /// The derived count (min(threads, logical cores)) is a reasonable
516    /// default, but ORT scaling is model- and workload-dependent — this
517    /// explicit override exists so the pool shape can be tuned per
518    /// machine without recompiling (1 = legacy single-session shape).
519    #[must_use]
520    pub fn with_shard_override(mut self, shards: usize) -> Self {
521        self.shards = (0..shards.max(1))
522            .map(|_| std::sync::Mutex::new(None))
523            .collect();
524        self
525    }
526
527    /// Map model name string to fastembed EmbeddingModel enum.
528    ///
529    /// Names ending in `-q` select INT8-quantized variants: ~75% smaller
530    /// weights and lower memory bandwidth than FP32, executing via CPU INT8
531    /// SIMD. Prefer them on modest hardware (see
532    /// `docs/POLYGLOT_SIMD_MEMORY_STRATEGY.md` — FP32 + unbounded threads
533    /// OOM-crashed a 16GB machine during benchmark runs).
534    fn resolve_model(&self) -> Option<fastembed::EmbeddingModel> {
535        match self.model_name.as_str() {
536            "BAAI/bge-small-en-v1.5" | "bge-small-en-v1.5" | "bge-small" => {
537                Some(fastembed::EmbeddingModel::BGESmallENV15)
538            }
539            "BAAI/bge-small-en-v1.5-q" | "bge-small-en-v1.5-q" | "bge-small-q" => {
540                Some(fastembed::EmbeddingModel::BGESmallENV15Q)
541            }
542            "BAAI/bge-base-en-v1.5" | "bge-base-en-v1.5" | "bge-base" => {
543                Some(fastembed::EmbeddingModel::BGEBaseENV15)
544            }
545            "BAAI/bge-base-en-v1.5-q" | "bge-base-en-v1.5-q" | "bge-base-q" => {
546                Some(fastembed::EmbeddingModel::BGEBaseENV15Q)
547            }
548            "BAAI/bge-large-en-v1.5" | "bge-large-en-v1.5" | "bge-large" => {
549                Some(fastembed::EmbeddingModel::BGELargeENV15)
550            }
551            "sentence-transformers/all-MiniLM-L6-v2" | "all-MiniLM-L6-v2" | "minilm" => {
552                Some(fastembed::EmbeddingModel::AllMiniLML6V2)
553            }
554            "sentence-transformers/all-MiniLM-L6-v2-q" | "all-MiniLM-L6-v2-q" | "minilm-q" => {
555                Some(fastembed::EmbeddingModel::AllMiniLML6V2Q)
556            }
557            "sentence-transformers/all-MiniLM-L12-v2" | "all-MiniLM-L12-v2" => {
558                Some(fastembed::EmbeddingModel::AllMiniLML12V2)
559            }
560            "nomic-ai/nomic-embed-text-v1.5" | "nomic-embed-text-v1.5" | "nomic" => {
561                Some(fastembed::EmbeddingModel::NomicEmbedTextV15)
562            }
563            _ => {
564                tracing::warn!(
565                    "unknown embedder model '{}', falling back to bge-small-en-v1.5",
566                    self.model_name
567                );
568                Some(fastembed::EmbeddingModel::BGESmallENV15)
569            }
570        }
571    }
572
573    /// Number of intra-op threads configured for inference (the TOTAL
574    /// budget across the session pool).
575    #[cfg(feature = "onnx")]
576    #[must_use]
577    pub const fn threads(&self) -> usize {
578        self.threads
579    }
580
581    /// Number of ORT sessions in the pool.
582    #[cfg(feature = "onnx")]
583    #[must_use]
584    pub fn shard_count(&self) -> usize {
585        self.shards.len()
586    }
587
588    /// Intra-op threads per session (the budget divided across the pool).
589    #[cfg(feature = "onnx")]
590    #[must_use]
591    fn intra_per_shard(&self) -> usize {
592        (self.threads / self.shards.len().max(1)).max(1)
593    }
594
595    /// Lazy-load the pool on first use: every shard gets its own session
596    /// over the shared on-disk model files.
597    fn ensure_loaded(&self) -> bool {
598        // Fast path: already loaded
599        if self.available.load(std::sync::atomic::Ordering::Relaxed) {
600            return true;
601        }
602
603        let intra = self.intra_per_shard();
604        let mut loaded = Vec::with_capacity(self.shards.len());
605        for _ in 0..self.shards.len() {
606            let Some(embedding_model) = self.resolve_model() else {
607                self.available
608                    .store(false, std::sync::atomic::Ordering::Relaxed);
609                return false;
610            };
611            let mut options = fastembed::TextInitOptions::new(embedding_model)
612                .with_show_download_progress(false)
613                .with_intra_threads(intra);
614
615            if let Some(ref cache_dir) = self.cache_dir {
616                options = options.with_cache_dir(cache_dir.clone());
617            }
618
619            match fastembed::TextEmbedding::try_new(options) {
620                Ok(model) => loaded.push(Some(model)),
621                Err(e) => {
622                    tracing::warn!("failed to load ONNX embedding model: {e}");
623                    self.available
624                        .store(false, std::sync::atomic::Ordering::Relaxed);
625                    return false;
626                }
627            }
628        }
629
630        {
631            for (shard, model) in self.shards.iter().zip(loaded) {
632                let mut guard = shard.lock().expect("shard mutex poisoned");
633                *guard = model;
634            }
635        }
636        self.available
637            .store(true, std::sync::atomic::Ordering::Relaxed);
638        tracing::info!(
639            "ONNX embedding model loaded: {} ({} session shards × {} intra threads, dim={})",
640            self.model_name,
641            self.shards.len(),
642            intra,
643            self.dimension
644        );
645        true
646    }
647}
648
649#[cfg(feature = "onnx")]
650impl Embedder for OrtEmbedder {
651    fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
652        if texts.is_empty() {
653            return Ok(Vec::new());
654        }
655
656        if !self.ensure_loaded() {
657            return Err(CoreError::Memory(
658                "ONNX embedder model not available".into(),
659            ));
660        }
661
662        let owned_texts: Vec<String> = texts.iter().map(|t| (*t).to_string()).collect();
663
664        // Single text (the query path): no fan-out machinery, shard 0.
665        if owned_texts.len() == 1 {
666            let mut guard = self.shards[0].lock().expect("shard mutex poisoned");
667            let Some(ref mut model) = guard.as_mut() else {
668                return Err(CoreError::Memory("ONNX embedder model not loaded".into()));
669            };
670            let embeddings = model
671                .embed(owned_texts, Some(self.batch_size))
672                .map_err(|e| CoreError::Memory(format!("ONNX embedding error: {e}")))?;
673            return Ok(embeddings);
674        }
675
676        // Fan out: round-robin split across shards, each embedding its
677        // subset concurrently on its own session (no cross-shard locks).
678        let shard_count = self.shards.len();
679        let mut split: Vec<Vec<String>> = vec![Vec::new(); shard_count];
680        for (i, text) in owned_texts.into_iter().enumerate() {
681            split[i % shard_count].push(text);
682        }
683
684        let results: Vec<Result<Vec<Vec<f32>>>> = std::thread::scope(|scope| {
685            let handles: Vec<_> = split
686                .into_iter()
687                .zip(&self.shards)
688                .map(|(subset, shard)| {
689                    scope.spawn(move || {
690                        let mut guard = shard.lock().expect("shard mutex poisoned");
691                        let Some(ref mut model) = guard.as_mut() else {
692                            return Err(CoreError::Memory("ONNX embedder model not loaded".into()));
693                        };
694                        model
695                            .embed(subset, Some(self.batch_size))
696                            .map_err(|e| CoreError::Memory(format!("ONNX embedding error: {e}")))
697                    })
698                })
699                .collect();
700            handles
701                .into_iter()
702                .map(|h| h.join().expect("shard worker panicked"))
703                .collect()
704        });
705
706        // Reassemble in the original order: position i came from shard
707        // i % shard_count (round-robin split is deterministic, so the
708        // interleave is too).
709        let mut per_shard: Vec<std::collections::VecDeque<Vec<f32>>> = results
710            .into_iter()
711            .map(|r| {
712                r.map(|vectors| {
713                    vectors
714                        .into_iter()
715                        .collect::<std::collections::VecDeque<_>>()
716                })
717            })
718            .collect::<Result<Vec<_>>>()?;
719        let mut out: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
720        for i in 0..texts.len() {
721            let shard_idx = i % shard_count;
722            let Some(vector) = per_shard[shard_idx].pop_front() else {
723                return Err(CoreError::Memory(format!(
724                    "embed_batch shard {shard_idx} returned too few vectors"
725                )));
726            };
727            out.push(vector);
728        }
729
730        if out.len() != texts.len() {
731            return Err(CoreError::Memory(format!(
732                "embed_batch returned {} vectors for {} inputs",
733                out.len(),
734                texts.len()
735            )));
736        }
737        Ok(out)
738    }
739
740    fn dimension(&self) -> usize {
741        self.dimension
742    }
743
744    fn is_available(&self) -> bool {
745        self.ensure_loaded()
746    }
747
748    fn backend_name(&self) -> &'static str {
749        "onnx"
750    }
751
752    fn cache_namespace(&self) -> String {
753        // Dimension included: quantization or an unknown-name fallback can
754        // change the effective model under the same requested name.
755        format!("onnx:{}:{}", self.model_name, self.dimension)
756    }
757
758    fn preferred_max_batch_texts(&self) -> usize {
759        // Big enough that each session shard sees a real batch (32+ texts)
760        // after the round-robin split.
761        128
762    }
763}
764
765/// Create an embedder from environment configuration, with fallback chain.
766///
767/// Priority (when `onnx` feature is enabled):
768/// 1. `OrtEmbedder` — if `WM_EMBEDDER_ORT_MODEL` is set or `onnx` feature is on
769/// 2. `HttpEmbedder` — if `WM_EMBEDDER_ENDPOINT` is set
770/// 3. `StubEmbedder` — always available fallback
771///
772/// Without `onnx` feature:
773/// 1. `HttpEmbedder` — if `WM_EMBEDDER_ENDPOINT` is set
774/// 2. `StubEmbedder` — fallback
775#[must_use]
776pub fn create_embedder() -> Box<dyn Embedder> {
777    #[cfg(feature = "onnx")]
778    {
779        let prefer_ort = std::env::var("WM_EMBEDDER_BACKEND")
780            .map(|v| v == "onnx" || v == "ort")
781            .unwrap_or(false);
782
783        if prefer_ort {
784            if let Some(mut ort) = OrtEmbedder::from_env() {
785                if let Ok(shards) = std::env::var("WM_EMBEDDER_ORT_SHARDS") {
786                    if let Ok(n) = shards.parse::<usize>() {
787                        ort = ort.with_shard_override(n);
788                    }
789                }
790                tracing::info!(
791                    "onnx embedder configured (dim={}, shards={})",
792                    ort.dimension(),
793                    ort.shard_count()
794                );
795                return Box::new(ort);
796            }
797        }
798    }
799
800    if let Some(http) = HttpEmbedder::from_env() {
801        tracing::info!("http embedder configured (dim={})", http.dimension());
802        return Box::new(http);
803    }
804
805    #[cfg(feature = "onnx")]
806    {
807        // Try ONNX as default when feature is enabled and no HTTP endpoint
808        if let Some(ort) = OrtEmbedder::from_env() {
809            tracing::info!(
810                "onnx embedder configured as default (dim={})",
811                ort.dimension()
812            );
813            return Box::new(ort);
814        }
815    }
816
817    tracing::info!("no embedder endpoint configured, using stub embedder");
818    Box::new(StubEmbedder::default())
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    // --- StubEmbedder tests ---
826
827    #[test]
828    fn stub_embedder_dimension() {
829        let embedder = StubEmbedder::new(128);
830        assert_eq!(embedder.dimension(), 128);
831    }
832
833    #[test]
834    fn stub_embedder_single() {
835        let embedder = StubEmbedder::new(64);
836        let vec = embedder.embed("hello world").unwrap();
837        assert_eq!(vec.len(), 64);
838        // Values should be in [-1, 1]
839        for v in &vec {
840            assert!(*v >= -1.0 && *v <= 1.0);
841        }
842    }
843
844    #[test]
845    fn stub_embedder_batch() {
846        let embedder = StubEmbedder::new(32);
847        let texts = ["hello", "world", "test"];
848        let vectors = embedder.embed_batch(&texts).unwrap();
849        assert_eq!(vectors.len(), 3);
850        for v in &vectors {
851            assert_eq!(v.len(), 32);
852        }
853    }
854
855    #[test]
856    fn stub_embedder_deterministic() {
857        let embedder = StubEmbedder::new(64);
858        let v1 = embedder.embed("same text").unwrap();
859        let v2 = embedder.embed("same text").unwrap();
860        assert_eq!(v1, v2);
861    }
862
863    #[test]
864    fn stub_embedder_different_texts_differ() {
865        let embedder = StubEmbedder::new(64);
866        let v1 = embedder.embed("hello").unwrap();
867        let v2 = embedder.embed("world").unwrap();
868        assert_ne!(v1, v2);
869    }
870
871    #[test]
872    fn stub_embedder_empty_batch() {
873        let embedder = StubEmbedder::new(64);
874        let vectors = embedder.embed_batch(&[]).unwrap();
875        assert!(vectors.is_empty());
876    }
877
878    #[test]
879    fn stub_embedder_is_available() {
880        let embedder = StubEmbedder::new(64);
881        assert!(embedder.is_available());
882    }
883
884    #[test]
885    fn stub_embedder_backend_name() {
886        let embedder = StubEmbedder::new(64);
887        assert_eq!(embedder.backend_name(), "stub");
888    }
889
890    #[test]
891    fn stub_embedder_default_dimension() {
892        let embedder = StubEmbedder::default();
893        assert_eq!(embedder.dimension(), 384);
894    }
895
896    // --- HttpEmbedder tests ---
897
898    #[test]
899    fn http_embedder_config_from_env_absent() {
900        // Test the config struct directly
901        let config = EmbedderConfig {
902            endpoint: "http://localhost:8080".into(),
903            model: "local".into(),
904            dimension: 384,
905            timeout: Duration::from_secs(30),
906        };
907        assert_eq!(config.endpoint, "http://localhost:8080");
908        assert_eq!(config.model, "local");
909        assert_eq!(config.dimension, 384);
910    }
911
912    #[test]
913    fn http_embedder_embeddings_url() {
914        let config = EmbedderConfig {
915            endpoint: "http://localhost:8080".into(),
916            model: "local".into(),
917            dimension: 384,
918            timeout: Duration::from_secs(30),
919        };
920        let embedder = HttpEmbedder::new(config);
921        assert_eq!(
922            embedder.embeddings_url(),
923            "http://localhost:8080/v1/embeddings"
924        );
925    }
926
927    #[test]
928    fn http_embedder_embeddings_url_trailing_slash() {
929        let config = EmbedderConfig {
930            endpoint: "http://localhost:8080/".into(),
931            model: "local".into(),
932            dimension: 384,
933            timeout: Duration::from_secs(30),
934        };
935        let embedder = HttpEmbedder::new(config);
936        assert_eq!(
937            embedder.embeddings_url(),
938            "http://localhost:8080/v1/embeddings"
939        );
940    }
941
942    #[test]
943    fn http_embedder_embeddings_url_full_path() {
944        let config = EmbedderConfig {
945            endpoint: "http://localhost:8080/v1/embeddings".into(),
946            model: "local".into(),
947            dimension: 384,
948            timeout: Duration::from_secs(30),
949        };
950        let embedder = HttpEmbedder::new(config);
951        assert_eq!(
952            embedder.embeddings_url(),
953            "http://localhost:8080/v1/embeddings"
954        );
955    }
956
957    #[test]
958    fn http_embedder_dimension() {
959        let config = EmbedderConfig {
960            endpoint: "http://localhost:8080".into(),
961            model: "local".into(),
962            dimension: 768,
963            timeout: Duration::from_secs(10),
964        };
965        let embedder = HttpEmbedder::new(config);
966        assert_eq!(embedder.dimension(), 768);
967    }
968
969    #[test]
970    fn http_embedder_backend_name() {
971        let config = EmbedderConfig {
972            endpoint: "http://localhost:8080".into(),
973            model: "local".into(),
974            dimension: 384,
975            timeout: Duration::from_secs(10),
976        };
977        let embedder = HttpEmbedder::new(config);
978        assert_eq!(embedder.backend_name(), "http");
979    }
980
981    #[test]
982    fn http_embedder_is_available() {
983        let config = EmbedderConfig {
984            endpoint: "http://localhost:8080".into(),
985            model: "local".into(),
986            dimension: 384,
987            timeout: Duration::from_secs(10),
988        };
989        let embedder = HttpEmbedder::new(config);
990        assert!(embedder.is_available());
991    }
992
993    #[test]
994    fn http_embedder_cache_namespace_separates_models_and_dims() {
995        let make = |model: &str, dim: usize| {
996            HttpEmbedder::new(EmbedderConfig {
997                endpoint: "http://localhost:8080".into(),
998                model: model.into(),
999                dimension: dim,
1000                timeout: Duration::from_secs(10),
1001            })
1002        };
1003        let base = make("bge-small", 384).cache_namespace();
1004        assert_eq!(base, "http:http://localhost:8080:bge-small:384");
1005        assert_ne!(base, make("nomic", 384).cache_namespace());
1006        assert_ne!(base, make("bge-small", 768).cache_namespace());
1007    }
1008
1009    #[test]
1010    fn truncate_for_embedding_respects_budget_and_utf8_boundaries() {
1011        let short = "short text";
1012        assert_eq!(truncate_for_embedding(short), short);
1013
1014        let ascii = "a".repeat(HTTP_EMBED_MAX_CHARS + 500);
1015        assert_eq!(truncate_for_embedding(&ascii).len(), HTTP_EMBED_MAX_CHARS);
1016
1017        // 4-byte chars: the cut must land on a char boundary, never panic.
1018        let multibyte = "🦀".repeat(HTTP_EMBED_MAX_CHARS);
1019        let truncated = truncate_for_embedding(&multibyte);
1020        assert!(truncated.len() <= HTTP_EMBED_MAX_CHARS);
1021        assert!(truncated.chars().all(|c| c == '🦀'));
1022        assert!(multibyte.starts_with(truncated));
1023    }
1024
1025    // --- create_embedder tests ---
1026
1027    #[test]
1028    fn create_embedder_falls_back_to_stub() {
1029        // Without WM_EMBEDDER_ENDPOINT set, should return stub (or onnx if feature enabled)
1030        let embedder = create_embedder();
1031        let name = embedder.backend_name();
1032        assert!(name == "stub" || name == "http" || name == "onnx");
1033    }
1034
1035    // --- Embedder trait tests ---
1036
1037    #[test]
1038    fn embedder_trait_object() {
1039        let embedder: Box<dyn Embedder> = Box::new(StubEmbedder::new(128));
1040        assert_eq!(embedder.dimension(), 128);
1041        let vec = embedder.embed("test").unwrap();
1042        assert_eq!(vec.len(), 128);
1043    }
1044
1045    #[test]
1046    fn embedder_embed_query() {
1047        let embedder = StubEmbedder::new(64);
1048        let v1 = embedder.embed("hello").unwrap();
1049        let v2 = embedder.embed_query("hello").unwrap();
1050        assert_eq!(v1, v2);
1051    }
1052
1053    // --- OrtEmbedder tests (only when onnx feature is enabled) ---
1054
1055    #[cfg(feature = "onnx")]
1056    #[test]
1057    fn ort_embedder_backend_name() {
1058        let embedder = OrtEmbedder::new("bge-small", None, 2, 384);
1059        assert_eq!(embedder.backend_name(), "onnx");
1060    }
1061
1062    #[cfg(feature = "onnx")]
1063    #[test]
1064    fn ort_embedder_resolves_quantized_models() {
1065        // INT8 variants must resolve to their Q enum counterparts, not
1066        // silently fall back to FP32 bge-small.
1067        let q = OrtEmbedder::new("bge-small-q", None, 2, 384);
1068        assert!(matches!(
1069            q.resolve_model(),
1070            Some(fastembed::EmbeddingModel::BGESmallENV15Q)
1071        ));
1072        let q_full = OrtEmbedder::new("BAAI/bge-small-en-v1.5-q", None, 2, 384);
1073        assert!(matches!(
1074            q_full.resolve_model(),
1075            Some(fastembed::EmbeddingModel::BGESmallENV15Q)
1076        ));
1077        let minilm_q = OrtEmbedder::new("minilm-q", None, 2, 384);
1078        assert!(matches!(
1079            minilm_q.resolve_model(),
1080            Some(fastembed::EmbeddingModel::AllMiniLML6V2Q)
1081        ));
1082        // FP32 names keep resolving to the FP32 variants.
1083        let fp32 = OrtEmbedder::new("bge-small", None, 2, 384);
1084        assert!(matches!(
1085            fp32.resolve_model(),
1086            Some(fastembed::EmbeddingModel::BGESmallENV15)
1087        ));
1088    }
1089
1090    #[cfg(feature = "onnx")]
1091    #[test]
1092    fn ort_embedder_default_threads_are_capped() {
1093        // The default must never exceed 4 threads regardless of logical
1094        // core count — unbounded ORT threads OOM-crash small machines
1095        // (docs/POLYGLOT_SIMD_MEMORY_STRATEGY.md).
1096        assert!(
1097            default_intra_threads() <= 4,
1098            "default threads must be capped at 4, got {}",
1099            default_intra_threads()
1100        );
1101    }
1102
1103    #[cfg(feature = "onnx")]
1104    #[test]
1105    fn ort_embedder_dimension() {
1106        let embedder = OrtEmbedder::new("bge-small", None, 2, 384);
1107        assert_eq!(embedder.dimension(), 384);
1108    }
1109
1110    #[cfg(feature = "onnx")]
1111    #[test]
1112    fn ort_embedder_dimension_custom() {
1113        let embedder = OrtEmbedder::new("minilm", None, 1, 256);
1114        assert_eq!(embedder.dimension(), 256);
1115    }
1116
1117    #[cfg(feature = "onnx")]
1118    #[test]
1119    fn ort_embedder_pool_shards_the_thread_budget() {
1120        // V8 ship list #3: the pool distributes the TOTAL intra-op budget
1121        // across session shards. Threads=1 keeps the legacy single-session
1122        // shape; larger budgets fan out without exceeding the total.
1123        for threads in [1usize, 2, 4, 8] {
1124            let embedder = OrtEmbedder::new("bge-small", None, threads, 384);
1125            assert_eq!(
1126                embedder.threads(),
1127                threads,
1128                "total budget must be preserved"
1129            );
1130            let shards = embedder.shard_count();
1131            assert!(shards >= 1, "at least one session");
1132            assert!(
1133                shards <= threads,
1134                "shards ({shards}) must not exceed the total budget ({threads})"
1135            );
1136        }
1137        let single = OrtEmbedder::new("bge-small", None, 1, 384);
1138        assert_eq!(single.shard_count(), 1, "threads=1 is the legacy shape");
1139    }
1140
1141    #[cfg(feature = "onnx")]
1142    #[test]
1143    fn ort_embedder_namespace_carries_model_and_dimension() {
1144        // Cache keys must change when the effective model changes.
1145        let small = OrtEmbedder::new("bge-small", None, 2, 384);
1146        let large = OrtEmbedder::new("bge-large", None, 2, 1024);
1147        assert_ne!(
1148            small.cache_namespace(),
1149            large.cache_namespace(),
1150            "different models must not share cache entries"
1151        );
1152        assert!(small.cache_namespace().contains("bge-small"));
1153    }
1154
1155    /// Pool-shape microbench: run explicitly, it loads the real model
1156    /// (`cargo test -p wm-memory --features onnx ort_pool_shape_microbench
1157    /// -- --ignored --nocapture`). Measures embed_batch wall time for one
1158    /// ingest-shaped call per (threads, shards) config on realistic text.
1159    #[cfg(feature = "onnx")]
1160    #[test]
1161    #[ignore = "loads the real ONNX model; run explicitly for pool tuning"]
1162    fn ort_pool_shape_microbench() {
1163        let texts: Vec<String> = (0..128)
1164            .map(|i| {
1165                format!(
1166                    "Session {i} of the deployment retrospective covered the rollout \
1167                     schedule for the telemetry agent, the budget review outcomes, and \
1168                     the follow-up decisions about the quarterly report timeline {i}."
1169                )
1170            })
1171            .collect();
1172        let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
1173
1174        // (threads, shard_override) — shard_override None = derived.
1175        let configs: Vec<(usize, Option<usize>)> = vec![
1176            (1, Some(1)),
1177            (2, Some(1)),
1178            (4, Some(1)),
1179            (4, None),
1180            (4, Some(2)),
1181            (8, None),
1182            (8, Some(4)),
1183        ];
1184        for (threads, shards) in configs {
1185            let mut embedder = OrtEmbedder::new("bge-small-q", None, threads, 384);
1186            if let Some(n) = shards {
1187                embedder = embedder.with_shard_override(n);
1188            }
1189            // Warm the pool (model load) before timing.
1190            let warm = embedder.embed_batch(&refs[..1]).unwrap();
1191            assert_eq!(warm.len(), 1);
1192            let t0 = std::time::Instant::now();
1193            let out = embedder.embed_batch(&refs).unwrap();
1194            let dt = t0.elapsed();
1195            assert_eq!(out.len(), refs.len());
1196            println!(
1197                "pool shape: threads={threads} shards={} intra={} → {} texts in {:.2?} ({:.1} texts/s)",
1198                embedder.shard_count(),
1199                embedder.intra_per_shard(),
1200                refs.len(),
1201                dt,
1202                refs.len() as f64 / dt.as_secs_f64()
1203            );
1204
1205            // Ingest shape: ~30-text chunks, many calls (the write path
1206            // chunks by chars, so embed_batch sees small slices).
1207            let t1 = std::time::Instant::now();
1208            let mut total = 0usize;
1209            for chunk in refs.chunks(30) {
1210                total += embedder.embed_batch(chunk).unwrap().len();
1211            }
1212            let dt1 = t1.elapsed();
1213            assert_eq!(total, refs.len());
1214            println!(
1215                "  ingest shape (30-text chunks): {:.2?} ({:.1} texts/s)",
1216                dt1,
1217                refs.len() as f64 / dt1.as_secs_f64()
1218            );
1219        }
1220    }
1221
1222    /// Determinism across pool shapes: the same text must produce the same
1223    /// vector whether embedded on the single-session shape or the fanned
1224    /// out pool (intra/shard parallelism must not change the math).
1225    #[cfg(feature = "onnx")]
1226    #[test]
1227    #[ignore = "loads the real ONNX model; run explicitly as a pool-identity gate"]
1228    fn ort_pool_shapes_produce_identical_vectors() {
1229        let text = "determinism probe across session pool shapes";
1230        let single = OrtEmbedder::new("bge-small-q", None, 1, 384).with_shard_override(1);
1231        let pooled = OrtEmbedder::new("bge-small-q", None, 4, 384);
1232        let v1 = single.embed(text).unwrap();
1233        let v2 = pooled.embed(text).unwrap();
1234        assert_eq!(v1.len(), v2.len());
1235        let max_diff = v1
1236            .iter()
1237            .zip(v2.iter())
1238            .map(|(a, b)| (a - b).abs())
1239            .fold(0.0f32, f32::max);
1240        assert!(
1241            max_diff < 1e-6,
1242            "pool shape changed the vector math (max diff {max_diff})"
1243        );
1244    }
1245
1246    #[cfg(feature = "onnx")]
1247    #[test]
1248    fn ort_embedder_empty_batch() {
1249        let embedder = OrtEmbedder::new("bge-small", None, 2, 384);
1250        let result = embedder.embed_batch(&[]).unwrap();
1251        assert!(result.is_empty());
1252    }
1253
1254    #[cfg(feature = "onnx")]
1255    #[test]
1256    fn ort_embedder_trait_object() {
1257        let embedder: Box<dyn Embedder> = Box::new(OrtEmbedder::new("bge-small", None, 2, 384));
1258        assert_eq!(embedder.dimension(), 384);
1259        assert_eq!(embedder.backend_name(), "onnx");
1260    }
1261
1262    #[cfg(feature = "onnx")]
1263    #[test]
1264    fn ort_embedder_resolve_model_known() {
1265        let embedder = OrtEmbedder::new("bge-small-en-v1.5", None, 2, 384);
1266        assert!(embedder.resolve_model().is_some());
1267    }
1268
1269    #[cfg(feature = "onnx")]
1270    #[test]
1271    fn ort_embedder_resolve_model_unknown_falls_back() {
1272        let embedder = OrtEmbedder::new("some-unknown-model", None, 2, 384);
1273        // Should fall back to bge-small
1274        assert!(embedder.resolve_model().is_some());
1275    }
1276
1277    // --- SSRF validation tests ---
1278
1279    #[test]
1280    fn endpoint_safe_allows_localhost() {
1281        assert!(is_endpoint_safe("http://localhost:8080"));
1282        assert!(is_endpoint_safe("http://127.0.0.1:8080"));
1283        assert!(is_endpoint_safe("http://localhost:11434/v1/embeddings"));
1284    }
1285
1286    #[test]
1287    fn endpoint_safe_allows_private_ip() {
1288        assert!(is_endpoint_safe("http://10.0.0.2:8080"));
1289        assert!(is_endpoint_safe("http://192.168.1.100:8080"));
1290        assert!(is_endpoint_safe("http://172.16.0.5:8080"));
1291    }
1292
1293    #[test]
1294    fn endpoint_safe_blocks_non_http_schemes() {
1295        assert!(!is_endpoint_safe("file:///etc/passwd"));
1296        assert!(!is_endpoint_safe("gopher://localhost:8080"));
1297        assert!(!is_endpoint_safe("ftp://example.com"));
1298        assert!(!is_endpoint_safe("javascript:alert(1)"));
1299        assert!(!is_endpoint_safe("data:text/plain,hello"));
1300    }
1301
1302    #[test]
1303    fn endpoint_safe_blocks_metadata_endpoints() {
1304        assert!(!is_endpoint_safe("http://169.254.169.254/latest/meta-data"));
1305        assert!(!is_endpoint_safe("http://169.254.170.2/v2/metadata"));
1306        assert!(!is_endpoint_safe(
1307            "http://metadata.google.internal/computeMetadata"
1308        ));
1309        assert!(!is_endpoint_safe("http://metadata.aws.internal"));
1310        assert!(!is_endpoint_safe("http://metadata"));
1311    }
1312
1313    #[test]
1314    fn endpoint_safe_blocks_empty_host() {
1315        assert!(!is_endpoint_safe("http://"));
1316        assert!(!is_endpoint_safe("http:///path"));
1317    }
1318
1319    #[test]
1320    fn endpoint_safe_blocks_malformed_ipv6() {
1321        assert!(!is_endpoint_safe("http://[::1:8080"));
1322    }
1323
1324    #[test]
1325    fn endpoint_safe_allows_https() {
1326        assert!(is_endpoint_safe("https://localhost:8080"));
1327        assert!(is_endpoint_safe("https://example.com/api"));
1328    }
1329
1330    #[test]
1331    fn endpoint_safe_allows_ipv6_loopback() {
1332        assert!(is_endpoint_safe("http://[::1]:8080"));
1333    }
1334}