Skip to main content

remote_embedding_plugin/
lib.rs

1//! Remote embedding provider for the graph-storage gear.
2//!
3//! ADR-0004 names two real providers: the in-process ONNX default and *"a
4//! second plugin [that] calls a remote inference endpoint"*. This is the
5//! second one. It speaks the `OpenAI`-compatible `POST /embeddings` protocol,
6//! which is what `OpenAI`, Azure `OpenAI`, Groq, Together, Ollama, vLLM and
7//! most self-hosted inference servers expose, so one plugin covers the
8//! deployments that cannot run a model in the gear's own process — a memory
9//! ceiling, a CPU budget, or a platform that already pays for an inference
10//! service.
11//!
12//! # What the identity can and cannot promise
13//!
14//! The ONNX plugin names its embedding space by the SHA-256 of the bytes it
15//! loaded. A remote endpoint offers no bytes to hash: the only identity it
16//! has is *which model, at which endpoint, at which width*, and that is what
17//! this plugin declares. Two deployments pointing one model name at one host
18//! agree on the space; the same name at a different host, or a different
19//! requested width, do not. What no remote identity can catch is a vendor
20//! silently changing the weights behind a stable model name — ADR-0004 puts
21//! that under model governance rather than under the plugin, and it is the
22//! reason the ADR calls remote embedding *governed data egress* rather than an
23//! ordinary plugin call.
24//!
25//! # Vectors are normalized here
26//!
27//! The gear's index serves cosine similarity, and not every compatible
28//! endpoint returns unit vectors (`OpenAI` does, several self-hosted servers
29//! do not). Normalizing on this side makes the stored vectors comparable
30//! whatever the endpoint's habit, and is part of the declared identity.
31
32use std::time::Duration;
33
34use async_trait::async_trait;
35use graph_storage_sdk::models::EmbeddingSpaceId;
36use graph_storage_sdk::plugin_api::{
37    EmbedRequest, EmbedResponse, EmbeddingProviderError, EmbeddingProviderV1,
38};
39use secrecy::{ExposeSecret, SecretString};
40use serde::{Deserialize, Serialize};
41use thiserror::Error;
42use tracing::{debug, warn};
43use url::Url;
44
45/// What a deployment declares about its endpoint.
46#[derive(Clone, Debug)]
47pub struct RemoteProviderConfig {
48    /// API root the `/embeddings` path is appended to, e.g.
49    /// `https://api.openai.com/v1` or `http://ollama:11434/v1`.
50    pub base_url: String,
51    /// Model name as the endpoint knows it, e.g. `text-embedding-3-small`.
52    pub model: String,
53    /// Bearer credential. `None` for an endpoint that takes no credential
54    /// (a self-hosted server on a private network).
55    pub api_key: Option<SecretString>,
56    /// Vector width the deployment's column was migrated with. Every vector
57    /// the endpoint returns is checked against it.
58    pub dimension: u32,
59    /// Send the `dimensions` request field. Models that support Matryoshka
60    /// truncation (`text-embedding-3-*`) then return exactly `dimension`
61    /// lanes; a model of a fixed width ignores or rejects the field, and a
62    /// deployment on such a model turns this off and sets `dimension` to the
63    /// model's native width.
64    pub request_dimensions: bool,
65    /// L2-normalize every vector before it is stored.
66    pub normalize: bool,
67    /// Inputs per request. The endpoint's own limit is the ceiling; 64 is
68    /// well under every known one.
69    pub batch_size: usize,
70    /// Per-request timeout. The caller's budget shortens it, never lengthens.
71    pub timeout: Duration,
72    /// How many times a chunk is re-sent after a *transient* refusal — a
73    /// rate limit or a gateway that is briefly unwell.
74    ///
75    /// One 503 otherwise drops a whole ingest batch's vectors, and the nodes
76    /// stay unembedded until something touches them again. Zero turns retries
77    /// off. The caller's remaining budget is the real ceiling: a retry that
78    /// would start after the deadline is not attempted, so this can never
79    /// make a request outlive the request that asked for it.
80    pub max_retries: u32,
81}
82
83impl RemoteProviderConfig {
84    /// The `text-embedding-3-small` shape at the gear's default width: 384
85    /// lanes requested through the `dimensions` field, normalized.
86    #[must_use]
87    pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
88        Self {
89            base_url: base_url.into(),
90            model: model.into(),
91            api_key: None,
92            dimension: 384,
93            request_dimensions: true,
94            normalize: true,
95            batch_size: 64,
96            timeout: Duration::from_mins(1),
97            max_retries: 2,
98        }
99    }
100
101    /// Attach the bearer credential.
102    #[must_use]
103    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
104        self.api_key = Some(SecretString::from(api_key.into()));
105        self
106    }
107}
108
109#[derive(Debug, Error)]
110#[non_exhaustive]
111pub enum RemoteConfigError {
112    #[error("base_url {0:?} is not an absolute http(s) URL")]
113    BaseUrl(String),
114    #[error(
115        "base_url carries credentials in its userinfo; put the key in the environment variable \
116         `embedding_remote_api_key_env` names instead, where it is not part of a URL that gets \
117         logged, echoed in an error, or copied into a bug report"
118    )]
119    CredentialsInUrl,
120    #[error(
121        "an api_key is configured and base_url is plain http to {host}, which puts the bearer \
122         token on the wire in clear for every proxy and log in between; use https, or a \
123         loopback host if this is a local endpoint"
124    )]
125    CredentialOverPlainHttp { host: String },
126    #[error("model must not be empty")]
127    Model,
128    #[error("model must be at most {max} characters, and this one is {got}")]
129    ModelTooLong { max: usize, got: usize },
130    #[error(
131        "model must not carry control characters: it becomes part of the embedding-space \
132         identity, which is compared for equality and written to the operator log"
133    )]
134    ModelControlCharacters,
135    #[error("dimension must be positive")]
136    Dimension,
137    #[error("batch_size must be positive")]
138    BatchSize,
139    #[error("the HTTP client could not be built: {0}")]
140    Client(String),
141}
142
143/// Whether a URL's host is the local machine.
144///
145/// A literal `127.0.0.0/8` or `::1` address, or the name `localhost`. The name
146/// is included because that is how a local endpoint is usually written, and
147/// resolving it to check would make configuration validation depend on DNS --
148/// which would also be a lie, since resolution can change after boot.
149fn is_loopback(url: &Url) -> bool {
150    match url.host() {
151        Some(url::Host::Ipv4(address)) => address.is_loopback(),
152        Some(url::Host::Ipv6(address)) => address.is_loopback(),
153        Some(url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
154        None => false,
155    }
156}
157
158/// The longest a model name may be, in characters.
159///
160/// Generous next to any real one -- `text-embedding-3-small` is 22 -- because
161/// the point is to have a bound at all rather than to guess a vendor's naming.
162/// An identity this string is part of gets compared and logged, so it cannot be
163/// unbounded.
164const MAX_MODEL_LEN: usize = 200;
165
166/// What an endpoint that cannot embed an empty string is asked to embed
167/// instead. The coordinator composes a node's input from its name and
168/// declared payload paths, so an empty input is a node with neither — rare,
169/// but the contract requires it to get a vector aligned with its position.
170const EMPTY_INPUT_PLACEHOLDER: &str = "(empty)";
171
172/// An `OpenAI`-compatible `/embeddings` endpoint, as the gear's provider.
173pub struct RemoteEmbeddingProvider {
174    http: reqwest::Client,
175    /// The full `/embeddings` URL, resolved once.
176    endpoint: Url,
177    config: RemoteProviderConfig,
178    space: EmbeddingSpaceId,
179}
180
181impl RemoteEmbeddingProvider {
182    /// Validate the configuration and build the client.
183    ///
184    /// Nothing is sent: the identity is declarative, and a deployment whose
185    /// endpoint is down at boot should still start and report the arm as
186    /// unavailable when asked, not refuse to boot.
187    ///
188    /// # Errors
189    ///
190    /// A base URL that is not absolute `http(s)`, an empty model, a zero
191    /// width or batch size, or a client that cannot be constructed.
192    pub fn new(config: RemoteProviderConfig) -> Result<Self, RemoteConfigError> {
193        let base = Url::parse(config.base_url.trim_end_matches('/'))
194            .ok()
195            .filter(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some())
196            .ok_or_else(|| RemoteConfigError::BaseUrl(config.base_url.clone()))?;
197        // `https://user:key@host/v1` parses, and the userinfo then rides on
198        // the endpoint this provider hands out and prints. A credential
199        // belongs in the environment variable the configuration names, which
200        // is the one place this plugin already reads one from.
201        if !base.username().is_empty() || base.password().is_some() {
202            return Err(RemoteConfigError::CredentialsInUrl);
203        }
204        // A bearer token is a header, and a header on plain `http` is on the
205        // wire in clear: every proxy, NAT, TLS-inspecting middlebox and load
206        // balancer log between here and the endpoint reads it. The scheme
207        // check above allows `http` because a local endpoint is a real thing
208        // to run against; what it cannot allow is `http` to somewhere else
209        // while a credential is attached, and `embed_chunk` attaches it
210        // whenever one is configured, without consulting the scheme.
211        //
212        // Loopback stays allowed with a key. There the traffic does not leave
213        // the machine, and refusing it would push a developer towards putting
214        // the credential somewhere worse.
215        if config.api_key.is_some() && base.scheme() == "http" && !is_loopback(&base) {
216            return Err(RemoteConfigError::CredentialOverPlainHttp {
217                host: base.host_str().unwrap_or_default().to_owned(),
218            });
219        }
220        // The model is not a free-text field. It is trimmed into the
221        // embedding-space identity, which is compared for equality to decide
222        // whether a stored vector is still valid, it is written to the
223        // operator log at boot, and it is sent in the body of every request.
224        // So it is bounded and shaped here, where `base_url` and the two
225        // counts are already checked -- refusing at configuration time rather
226        // than discovering it in an identity comparison or a log line.
227        let model = config.model.trim();
228        if model.is_empty() {
229            return Err(RemoteConfigError::Model);
230        }
231        if model.chars().count() > MAX_MODEL_LEN {
232            return Err(RemoteConfigError::ModelTooLong {
233                max: MAX_MODEL_LEN,
234                got: model.chars().count(),
235            });
236        }
237        if model.chars().any(char::is_control) {
238            return Err(RemoteConfigError::ModelControlCharacters);
239        }
240        if config.dimension == 0 {
241            return Err(RemoteConfigError::Dimension);
242        }
243        if config.batch_size == 0 {
244            return Err(RemoteConfigError::BatchSize);
245        }
246
247        let mut endpoint = base.clone();
248        endpoint.set_path(&format!("{}/embeddings", base.path().trim_end_matches('/')));
249        endpoint.set_query(None);
250
251        let http = reqwest::Client::builder()
252            .timeout(config.timeout)
253            .build()
254            .map_err(|error| RemoteConfigError::Client(error.to_string()))?;
255
256        // The endpoint's origin and path are the "artifact"; the query string
257        // and a trailing slash are not part of which model answers, so they
258        // are not part of the identity either.
259        let space = EmbeddingSpaceId::new(
260            format!("{}@{}", config.model.trim(), endpoint_name(&endpoint)),
261            "provider-managed",
262            serde_json::json!({
263                "protocol": "openai-embeddings-v1",
264                "requested_dimensions": config.request_dimensions.then_some(config.dimension),
265                "empty_input": EMPTY_INPUT_PLACEHOLDER,
266            }),
267            serde_json::json!({ "strategy": "provider" }),
268            serde_json::json!({ "l2": config.normalize }),
269            config.dimension,
270        );
271
272        Ok(Self {
273            http,
274            endpoint,
275            config,
276            space,
277        })
278    }
279
280    /// The resolved `/embeddings` URL, for logs and diagnostics.
281    #[must_use]
282    pub fn endpoint(&self) -> &Url {
283        &self.endpoint
284    }
285}
286
287/// `host[:port]/path` — what distinguishes one endpoint from another.
288fn endpoint_name(endpoint: &Url) -> String {
289    // The scheme is part of the identity, not decoration: `http://host/v1`
290    // and `https://host/v1` are different endpoints, and without it a
291    // downgrade or a routing change that keeps the host and path would reuse
292    // vectors from what is, as far as this gear can tell, another provider.
293    let scheme = endpoint.scheme();
294    let host = endpoint.host_str().unwrap_or("unknown-host");
295    match endpoint.port() {
296        Some(port) => format!("{scheme}://{host}:{port}{}", endpoint.path()),
297        None => format!("{scheme}://{host}{}", endpoint.path()),
298    }
299}
300
301#[derive(Serialize)]
302struct EmbeddingsRequest<'a> {
303    model: &'a str,
304    input: Vec<&'a str>,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    dimensions: Option<u32>,
307}
308
309#[derive(Deserialize)]
310struct EmbeddingsResponse {
311    #[serde(default)]
312    data: Vec<EmbeddingDatum>,
313}
314
315#[derive(Deserialize)]
316struct EmbeddingDatum {
317    index: usize,
318    embedding: Vec<f32>,
319}
320
321#[async_trait]
322impl EmbeddingProviderV1 for RemoteEmbeddingProvider {
323    fn embedding_space(&self) -> &EmbeddingSpaceId {
324        &self.space
325    }
326
327    fn dimension(&self) -> u32 {
328        self.config.dimension
329    }
330
331    async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, EmbeddingProviderError> {
332        if req.cancel.is_cancelled() {
333            return Err(EmbeddingProviderError::Cancelled);
334        }
335        if req.budget.is_exhausted() {
336            return Err(EmbeddingProviderError::Deadline);
337        }
338
339        let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(req.inputs.len());
340        for chunk in req.inputs.chunks(self.config.batch_size) {
341            // Checked per round trip: a batch that outlives its deadline in
342            // the middle should not spend another request on the remainder.
343            if req.cancel.is_cancelled() {
344                return Err(EmbeddingProviderError::Cancelled);
345            }
346            let remaining = req.budget.remaining();
347            if remaining.is_zero() {
348                return Err(EmbeddingProviderError::Deadline);
349            }
350            let batch = self.embed_chunk_with_retries(chunk, &req).await?;
351            vectors.extend(batch);
352        }
353
354        Ok(EmbedResponse {
355            vectors,
356            space: self.space.clone(),
357        })
358    }
359
360    /// One short request. The endpoint has no cheaper probe that every
361    /// compatible server implements, so readiness costs one embedding.
362    async fn health(&self) -> Result<(), EmbeddingProviderError> {
363        self.embed_chunk(
364            &["health".to_owned()],
365            // No caller is waiting on a budget here, so a timeout is this
366            // provider's own and says the endpoint is unreachable.
367            AttemptWindow::provider_bound(self.config.timeout.min(Duration::from_secs(10))),
368        )
369        .await
370        .map(drop)
371        .map_err(|refusal| refusal.error)
372    }
373}
374
375/// How long one attempt may take, and which clock said so.
376///
377/// The per-attempt timeout is the smaller of the caller's remaining budget
378/// and this provider's own configured one, and a timeout means opposite
379/// things depending on which of the two bound it. When the caller's budget
380/// ran out there is nothing left to retry into and the answer is `Deadline`.
381/// When the provider's own cap fired first the caller may still have minutes
382/// of budget left, and a slow-but-healthy endpoint is exactly the transient
383/// condition the retry loop exists for — classifying that as the caller's
384/// deadline abandons the batch on the first slow response. `min` alone does
385/// not remember which argument won, so the answer is carried alongside it.
386#[derive(Clone, Copy)]
387struct AttemptWindow {
388    timeout: Duration,
389    caller_bound: bool,
390}
391
392impl AttemptWindow {
393    fn for_attempt(remaining: Duration, configured: Duration) -> Self {
394        Self {
395            timeout: remaining.min(configured),
396            caller_bound: remaining <= configured,
397        }
398    }
399
400    /// An attempt no caller is waiting on, so its timeout can only be ours.
401    fn provider_bound(timeout: Duration) -> Self {
402        Self {
403            timeout,
404            caller_bound: false,
405        }
406    }
407}
408
409impl RemoteEmbeddingProvider {
410    /// One chunk, re-sent while the refusal is transient and the budget has
411    /// room for another attempt.
412    ///
413    /// Without this a single 503 or rate limit drops the vectors of a whole
414    /// ingest batch, and those nodes stay unembedded until something touches
415    /// them again — a quiet loss of recall rather than a visible failure.
416    /// Only the refusals a retry can fix are retried, and the caller's
417    /// remaining budget bounds the whole sequence: an attempt that would
418    /// start after the deadline is not made, so a retry can never make a
419    /// request outlive the one that asked for it.
420    async fn embed_chunk_with_retries(
421        &self,
422        chunk: &[String],
423        req: &EmbedRequest,
424    ) -> Result<Vec<Vec<f32>>, EmbeddingProviderError> {
425        let mut backoff = Duration::from_millis(200);
426        for attempt in 0..=self.config.max_retries {
427            let remaining = req.budget.remaining();
428            if remaining.is_zero() {
429                return Err(EmbeddingProviderError::Deadline);
430            }
431            let call = self.embed_chunk(
432                chunk,
433                AttemptWindow::for_attempt(remaining, self.config.timeout),
434            );
435            let outcome = tokio::select! {
436                () = req.cancel.cancelled() => return Err(EmbeddingProviderError::Cancelled),
437                result = call => result,
438            };
439            let refusal = match outcome {
440                Ok(vectors) => return Ok(vectors),
441                Err(refusal) => refusal,
442            };
443            // A credential the endpoint refuses, a malformed answer or a
444            // width that does not match are not going to be different next
445            // time; a deadline is the caller's, not the endpoint's. Each said
446            // so at the point it was classified.
447            if attempt == self.config.max_retries || !refusal.retryable {
448                return Err(refusal.error);
449            }
450            let wait = backoff.min(req.budget.remaining());
451            if wait.is_zero() {
452                return Err(refusal.error);
453            }
454            warn!(
455                endpoint = %endpoint_name(&self.endpoint),
456                attempt = attempt + 1,
457                wait_ms = wait.as_millis(),
458                "the embeddings endpoint answered transiently; retrying"
459            );
460            tokio::select! {
461                () = req.cancel.cancelled() => return Err(EmbeddingProviderError::Cancelled),
462                () = tokio::time::sleep(wait) => {}
463            }
464            backoff = backoff.saturating_mul(2);
465        }
466        // `0..=max_retries` always runs at least once, so this is unreachable
467        // — stated rather than `unwrap`ped.
468        Err(EmbeddingProviderError::Internal(
469            "the retry loop ended without an attempt".to_owned(),
470        ))
471    }
472
473    /// One request for one chunk, aligned to the inputs by the response's
474    /// `index` field.
475    async fn embed_chunk(
476        &self,
477        inputs: &[String],
478        window: AttemptWindow,
479    ) -> Result<Vec<Vec<f32>>, Refusal> {
480        let body = EmbeddingsRequest {
481            model: self.config.model.trim(),
482            input: inputs
483                .iter()
484                .map(|text| {
485                    if text.trim().is_empty() {
486                        EMPTY_INPUT_PLACEHOLDER
487                    } else {
488                        text.as_str()
489                    }
490                })
491                .collect(),
492            dimensions: self
493                .config
494                .request_dimensions
495                .then_some(self.config.dimension),
496        };
497
498        let mut request = self
499            .http
500            .post(self.endpoint.clone())
501            .timeout(window.timeout)
502            .json(&body);
503        if let Some(key) = &self.config.api_key {
504            request = request.bearer_auth(key.expose_secret());
505        }
506
507        let response = request.send().await.map_err(|error| {
508            if error.is_timeout() && window.caller_bound {
509                // The caller's deadline, not the endpoint's refusal: nothing
510                // is left to retry into.
511                Refusal::permanent(EmbeddingProviderError::Deadline)
512            } else {
513                Refusal::transient(EmbeddingProviderError::Unavailable {
514                    reason: format!("{}: {error}", endpoint_name(&self.endpoint)),
515                })
516            }
517        })?;
518
519        let status = response.status();
520        if !status.is_success() {
521            // The status is the diagnosis; the body is the vendor's and may
522            // echo the request, so only its size is logged (telemetry
523            // contract: no provider response bodies in logs).
524            let body_bytes = response.bytes().await.map_or(0, |b| b.len());
525            warn!(
526                endpoint = %endpoint_name(&self.endpoint),
527                status = status.as_u16(),
528                body_bytes,
529                "the embeddings endpoint refused the request"
530            );
531            return Err(classify_status(status));
532        }
533
534        let parsed: EmbeddingsResponse = response.json().await.map_err(|error| {
535            Refusal::permanent(EmbeddingProviderError::Internal(format!(
536                "unparseable embeddings response: {error}"
537            )))
538        })?;
539        self.align(inputs.len(), parsed.data)
540            .map_err(Refusal::permanent)
541    }
542
543    /// Place each returned vector at its declared index, and refuse a
544    /// response that does not fill every slot exactly once.
545    fn align(
546        &self,
547        expected: usize,
548        data: Vec<EmbeddingDatum>,
549    ) -> Result<Vec<Vec<f32>>, EmbeddingProviderError> {
550        let width = self.config.dimension as usize;
551        let mut slots: Vec<Option<Vec<f32>>> = vec![None; expected];
552        for datum in data {
553            let Some(slot) = slots.get_mut(datum.index) else {
554                return Err(EmbeddingProviderError::Internal(format!(
555                    "the endpoint returned index {} for a batch of {expected}",
556                    datum.index
557                )));
558            };
559            if slot.is_some() {
560                return Err(EmbeddingProviderError::Internal(format!(
561                    "the endpoint returned index {} twice",
562                    datum.index
563                )));
564            }
565            if datum.embedding.len() != width {
566                debug!(
567                    got = datum.embedding.len(),
568                    want = width,
569                    "the endpoint returned a vector of another width"
570                );
571                return Err(EmbeddingProviderError::SpaceMismatch);
572            }
573            if !datum.embedding.iter().all(|lane| lane.is_finite()) {
574                return Err(EmbeddingProviderError::Internal(format!(
575                    "vector {} carries a non-finite lane",
576                    datum.index
577                )));
578            }
579            *slot = Some(if self.config.normalize {
580                normalize(datum.embedding)
581            } else {
582                datum.embedding
583            });
584        }
585        slots
586            .into_iter()
587            .enumerate()
588            .map(|(index, slot)| {
589                slot.ok_or_else(|| {
590                    EmbeddingProviderError::Internal(format!(
591                        "the endpoint returned no vector for input {index} of {expected}"
592                    ))
593                })
594            })
595            .collect()
596    }
597}
598
599/// A refusal, plus the one thing the port's error type cannot carry: whether
600/// another identical request could answer differently.
601///
602/// The retry loop used to re-derive that from the error variant, which cannot
603/// work. A refused credential and an overloaded endpoint are both `Unavailable`
604/// to the gear, and rightly so -- either way the vector arm is down and the
605/// caller can repair neither by changing the batch. The difference between
606/// them is only visible here, where the status code still exists, so it is
607/// decided here and carried rather than guessed at later.
608struct Refusal {
609    error: EmbeddingProviderError,
610    retryable: bool,
611}
612
613impl Refusal {
614    /// Another attempt could answer differently: a rate limit, a gateway
615    /// error, a connection that did not open.
616    const fn transient(error: EmbeddingProviderError) -> Self {
617        Self {
618            error,
619            retryable: true,
620        }
621    }
622
623    /// No repetition resolves this one (ADR-0004): a refused credential, a
624    /// malformed answer, a width that does not match -- or a deadline and a
625    /// cancellation, which are the caller's and not the endpoint's.
626    const fn permanent(error: EmbeddingProviderError) -> Self {
627        Self {
628            error,
629            retryable: false,
630        }
631    }
632}
633
634/// Map a refused status onto what the gear is told and whether to try again.
635///
636/// 401 and 403 stay `Unavailable`, because that is what they mean to the gear:
637/// the provider cannot serve, and the vector arm is down until an operator
638/// acts. They are not retried, because a credential the endpoint just refused
639/// will be refused again -- and a rotated key retried on every chunk of every
640/// batch is how a misconfiguration turns into provider-side rate limiting.
641fn classify_status(status: reqwest::StatusCode) -> Refusal {
642    match status.as_u16() {
643        401 | 403 => Refusal::permanent(EmbeddingProviderError::Unavailable {
644            reason: format!("the endpoint refused the credential (HTTP {status})"),
645        }),
646        // Listed rather than a `500..=599` range. 5xx is not one class: 500,
647        // 502, 503 and 504 say the server is having a moment, while 501 says
648        // it does not implement this and 505 says the two sides cannot agree
649        // on a protocol version. Neither of those changes on the next attempt,
650        // and ADR-0004 scopes the retry to "a rate limit, a gateway error, a
651        // connection that did not open" -- which is this list and not the
652        // range. A misconfigured endpoint answering 501 would otherwise spend
653        // three requests and the whole backoff per chunk of every batch.
654        408 | 429 | 500 | 502 | 503 | 504 => {
655            Refusal::transient(EmbeddingProviderError::Unavailable {
656                reason: format!("HTTP {status}"),
657            })
658        }
659        // Still `Unavailable` to the gear -- the vector arm is down either way
660        // -- but there is nothing a repeat can fix.
661        501 | 505 => Refusal::permanent(EmbeddingProviderError::Unavailable {
662            reason: format!("the endpoint cannot serve this request at all (HTTP {status})"),
663        }),
664        _ => Refusal::permanent(EmbeddingProviderError::Internal(format!(
665            "the endpoint answered HTTP {status}"
666        ))),
667    }
668}
669
670/// Unit length, in f64 so a long vector's squared sum does not lose lanes on
671/// the way. A zero vector stays zero rather than becoming NaN.
672#[expect(
673    clippy::cast_possible_truncation,
674    reason = "narrowing to f32 is the destination: pgvector stores single precision"
675)]
676fn normalize(vector: Vec<f32>) -> Vec<f32> {
677    let norm = vector
678        .iter()
679        .map(|lane| f64::from(*lane).powi(2))
680        .sum::<f64>()
681        .sqrt();
682    if norm == 0.0 {
683        return vector;
684    }
685    vector
686        .into_iter()
687        .map(|lane| (f64::from(lane) / norm) as f32)
688        .collect()
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    fn provider(base_url: &str, model: &str) -> RemoteEmbeddingProvider {
696        RemoteEmbeddingProvider::new(RemoteProviderConfig::new(base_url, model))
697            .unwrap_or_else(|error| panic!("a valid configuration must build: {error}"))
698    }
699
700    #[test]
701    fn the_endpoint_is_the_base_url_plus_embeddings() {
702        assert_eq!(
703            provider("https://api.openai.com/v1", "m")
704                .endpoint()
705                .as_str(),
706            "https://api.openai.com/v1/embeddings"
707        );
708        assert_eq!(
709            provider("https://api.openai.com/v1/", "m")
710                .endpoint()
711                .as_str(),
712            "https://api.openai.com/v1/embeddings"
713        );
714        assert_eq!(
715            provider("http://ollama:11434/v1?x=1", "m")
716                .endpoint()
717                .as_str(),
718            "http://ollama:11434/v1/embeddings"
719        );
720    }
721
722    #[test]
723    fn a_trailing_slash_or_query_does_not_change_the_identity() {
724        let one = provider("https://api.openai.com/v1", "text-embedding-3-small");
725        let other = provider(
726            "https://api.openai.com/v1/?trace=1",
727            "text-embedding-3-small",
728        );
729        assert_eq!(
730            one.embedding_space().identity_hash,
731            other.embedding_space().identity_hash
732        );
733    }
734
735    #[test]
736    fn model_endpoint_and_width_are_each_part_of_the_identity() {
737        let base = provider("https://api.openai.com/v1", "text-embedding-3-small");
738        let other_model = provider("https://api.openai.com/v1", "text-embedding-3-large");
739        let other_host = provider("https://eu.api.example.com/v1", "text-embedding-3-small");
740        let mut narrow =
741            RemoteProviderConfig::new("https://api.openai.com/v1", "text-embedding-3-small");
742        narrow.dimension = 256;
743        let narrow = RemoteEmbeddingProvider::new(narrow)
744            .unwrap_or_else(|error| panic!("a valid configuration must build: {error}"));
745
746        let hash = |p: &RemoteEmbeddingProvider| p.embedding_space().identity_hash.clone();
747        assert_ne!(hash(&base), hash(&other_model));
748        assert_ne!(hash(&base), hash(&other_host));
749        assert_ne!(hash(&base), hash(&narrow));
750    }
751
752    /// A credential in the URL is refused, not carried.
753    ///
754    /// `https://user:key@host/v1` parses and the userinfo then rides on the
755    /// endpoint this provider hands out and prints — into logs, error
756    /// messages and bug reports. The key belongs in the environment variable
757    /// the configuration already names.
758    #[test]
759    fn a_base_url_carrying_credentials_is_refused() {
760        for url in [
761            "https://user:secret@embeddings.test/v1",
762            "https://tokenonly@embeddings.test/v1",
763        ] {
764            let error = RemoteEmbeddingProvider::new(RemoteProviderConfig::new(url, "m"))
765                .err()
766                .expect("a URL with userinfo is refused");
767            assert!(
768                matches!(error, RemoteConfigError::CredentialsInUrl),
769                "{url}: {error}"
770            );
771        }
772        // The same host without them is fine.
773        let ok = provider("https://embeddings.test/v1", "m");
774        assert_eq!(ok.endpoint().username(), "");
775    }
776
777    /// Two endpoints that differ only by transport are two endpoints.
778    ///
779    /// The identity is what decides whether stored vectors may be ranked
780    /// against a new query. Without the scheme, a downgrade to `http` — or a
781    /// routing change that keeps host and path — would silently reuse vectors
782    /// from what is, as far as this gear can tell, a different provider.
783    #[test]
784    fn the_transport_is_part_of_the_identity() {
785        let secure = provider("https://embeddings.test/v1", "m");
786        let plain = provider("http://embeddings.test/v1", "m");
787        assert_ne!(
788            secure.embedding_space().identity_hash,
789            plain.embedding_space().identity_hash,
790            "http and https must not share an embedding space"
791        );
792    }
793
794    #[test]
795    fn a_relative_or_non_http_base_url_is_refused() {
796        for bad in ["api.openai.com/v1", "ftp://x/v1", "", "https://"] {
797            let error = RemoteEmbeddingProvider::new(RemoteProviderConfig::new(bad, "m"))
798                .err()
799                .unwrap_or_else(|| panic!("{bad:?} must be refused"));
800            assert!(
801                matches!(error, RemoteConfigError::BaseUrl(_)),
802                "{bad:?}: {error}"
803            );
804        }
805    }
806
807    #[test]
808    fn the_credential_does_not_render_in_debug() {
809        let config = RemoteProviderConfig::new("https://api.openai.com/v1", "m")
810            .with_api_key("sk-this-must-not-leak");
811        let rendered = format!("{config:?}");
812        assert!(!rendered.contains("sk-this-must-not-leak"), "{rendered}");
813    }
814
815    #[test]
816    fn normalization_yields_unit_length_and_leaves_zero_alone() {
817        let unit = normalize(vec![3.0, 4.0]);
818        let norm: f64 = unit
819            .iter()
820            .map(|x| f64::from(*x).powi(2))
821            .sum::<f64>()
822            .sqrt();
823        assert!((norm - 1.0).abs() < 1e-6, "{norm}");
824        assert_eq!(normalize(vec![0.0, 0.0]), vec![0.0, 0.0]);
825    }
826
827    fn classify(code: u16) -> Refusal {
828        classify_status(reqwest::StatusCode::from_u16(code).unwrap_or_default())
829    }
830
831    /// A bearer token on plain `http` is on the wire in clear. The scheme
832    /// check allows `http` because a local endpoint is a real thing to run
833    /// against, and `embed_chunk` attaches the credential whenever one is
834    /// configured without looking at the scheme, so the combination is what
835    /// has to be refused -- at configuration time, before the first request
836    /// carries the token past a proxy.
837    #[test]
838    fn a_credential_is_not_sent_in_clear_to_another_host() {
839        let build = |url: &str, key: Option<&str>| {
840            let mut config = RemoteProviderConfig::new(url, "text-embedding-3-small");
841            if let Some(key) = key {
842                config = config.with_api_key(key);
843            }
844            RemoteEmbeddingProvider::new(config)
845        };
846
847        for url in [
848            "http://embeddings.internal/v1",
849            "http://10.0.0.7:8080/v1",
850            "http://example.test/v1",
851        ] {
852            let refused = build(url, Some("sk-secret"))
853                .err()
854                .expect("a credential over plain http to another host must be refused");
855            assert!(
856                matches!(refused, RemoteConfigError::CredentialOverPlainHttp { .. }),
857                "{url} must be refused for the scheme, got {refused}"
858            );
859            assert!(
860                !refused.to_string().contains("sk-secret"),
861                "and the refusal must not quote the credential: {refused}"
862            );
863        }
864
865        // The same hosts are fine without a credential: there is nothing to
866        // expose, and the plugin is not in the business of banning `http`.
867        for url in ["http://embeddings.internal/v1", "http://10.0.0.7:8080/v1"] {
868            assert!(
869                build(url, None).is_ok(),
870                "{url} carries no credential and stays allowed"
871            );
872        }
873
874        // Loopback keeps its credential: the traffic never leaves the machine,
875        // and refusing it would push the key somewhere worse.
876        for url in [
877            "http://127.0.0.1:11434/v1",
878            "http://localhost:11434/v1",
879            "http://[::1]:11434/v1",
880        ] {
881            assert!(
882                build(url, Some("sk-secret")).is_ok(),
883                "{url} is the local machine and stays allowed"
884            );
885        }
886
887        // And https is the ordinary case.
888        assert!(
889            build("https://api.openai.com/v1", Some("sk-secret")).is_ok(),
890            "https with a credential is the point of the feature"
891        );
892    }
893
894    /// The model is checked as hard as its neighbours in the same constructor.
895    /// It is not free text: it is trimmed into the embedding-space identity,
896    /// which decides whether a stored vector is still valid, and it is written
897    /// to the operator log at boot.
898    #[test]
899    fn a_model_that_is_not_a_model_name_is_refused() {
900        // `.err()` rather than `expect_err`: the provider is not `Debug`, and
901        // giving it one would print a configuration that carries a credential.
902        let refused = |model: &str| {
903            RemoteEmbeddingProvider::new(RemoteProviderConfig::new(
904                "https://example.test/v1",
905                model,
906            ))
907            .err()
908            .expect("this model must be refused")
909        };
910
911        assert!(
912            matches!(refused("  "), RemoteConfigError::Model),
913            "a blank model keeps its own error"
914        );
915        assert!(
916            matches!(
917                refused(&"m".repeat(MAX_MODEL_LEN + 1)),
918                RemoteConfigError::ModelTooLong { .. }
919            ),
920            "a model past the bound must be refused"
921        );
922        for model in ["text-embedding\n3-small", "text\u{0}embedding", "a\tb"] {
923            assert!(
924                matches!(refused(model), RemoteConfigError::ModelControlCharacters),
925                "{model:?} must be refused"
926            );
927        }
928    }
929
930    /// And the shapes a real vendor uses still pass, including the bound
931    /// exactly.
932    #[test]
933    fn a_real_model_name_is_accepted() {
934        for model in [
935            "text-embedding-3-small",
936            "  text-embedding-3-small  ",
937            "sentence-transformers/all-MiniLM-L6-v2",
938            &"m".repeat(MAX_MODEL_LEN),
939        ] {
940            assert!(
941                RemoteEmbeddingProvider::new(RemoteProviderConfig::new(
942                    "https://example.test/v1",
943                    model,
944                ))
945                .is_ok(),
946                "{model:?} must be accepted"
947            );
948        }
949    }
950
951    #[test]
952    fn statuses_split_into_unavailable_and_internal() {
953        let unavailable = |code: u16| {
954            matches!(
955                classify(code).error,
956                EmbeddingProviderError::Unavailable { .. }
957            )
958        };
959        assert!(unavailable(401));
960        assert!(unavailable(429));
961        assert!(unavailable(503));
962        assert!(!unavailable(400));
963        assert!(!unavailable(404));
964    }
965
966    /// ADR-0004: "A refused credential, a malformed answer or a width that
967    /// does not match are not retried, because no repetition resolves them."
968    /// A refused credential reads as `Unavailable` to the gear -- the vector
969    /// arm is down either way -- so the variant cannot carry this and the
970    /// classification has to.
971    #[test]
972    fn a_refused_credential_is_unavailable_but_not_retried() {
973        for code in [401, 403] {
974            let refusal = classify(code);
975            assert!(
976                matches!(refusal.error, EmbeddingProviderError::Unavailable { .. }),
977                "HTTP {code} is still an unavailable provider"
978            );
979            assert!(
980                !refusal.retryable,
981                "HTTP {code} must not be retried: the credential will be refused again, and \
982                 retrying every chunk of every batch is how a rotated key becomes a rate limit"
983            );
984        }
985    }
986
987    /// 5xx is not one class. A server having a moment and a server that does
988    /// not implement the endpoint answer in the same hundred, and only one of
989    /// them is worth asking again.
990    #[test]
991    fn a_permanent_5xx_is_not_retried() {
992        for code in [501, 505] {
993            let refusal = classify(code);
994            assert!(
995                !refusal.retryable,
996                "HTTP {code} describes a fixed capability, so repeating the request repeats \
997                 the answer"
998            );
999            assert!(
1000                matches!(refusal.error, EmbeddingProviderError::Unavailable { .. }),
1001                "HTTP {code} still leaves the vector arm down, so it is still unavailable"
1002            );
1003        }
1004    }
1005
1006    #[test]
1007    fn the_statuses_a_retry_can_fix_are_still_retried() {
1008        for code in [408, 429, 500, 502, 503, 504] {
1009            assert!(
1010                classify(code).retryable,
1011                "HTTP {code} is the endpoint saying `not now`, which is what retrying is for"
1012            );
1013        }
1014        for code in [400, 404, 422] {
1015            assert!(
1016                !classify(code).retryable,
1017                "HTTP {code} is about the request, and repeating it repeats the request"
1018            );
1019        }
1020    }
1021}