Skip to main content

sqlite_graphrag/
embedding_api.rs

1//! HTTP client for the OpenRouter embeddings API.
2//!
3//! Sends embedding requests to the OpenAI-compatible endpoint at
4//! `openrouter.ai/api/v1/embeddings` and returns dense `Vec<f32>`
5//! vectors. Handles retry with exponential backoff + jitter for
6//! transient failures (429, 5xx) and immediate abort for permanent
7//! errors (401, 400).
8
9use crate::errors::AppError;
10use crate::retry::AttemptOutcome;
11use secrecy::{ExposeSecret, SecretBox};
12use serde::{Deserialize, Serialize};
13use std::time::Duration;
14
15// Default lives in constants; production clients resolve via runtime_config.
16use crate::constants::DEFAULT_OPENROUTER_EMBEDDINGS_URL;
17const DEFAULT_TIMEOUT_SECS: u64 = 30;
18const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
19const MAX_BATCH_SIZE: usize = 32;
20
21#[derive(Serialize)]
22struct EmbeddingRequest<'a> {
23    model: &'a str,
24    input: EmbeddingInput<'a>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    dimensions: Option<usize>,
27    encoding_format: &'a str,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    input_type: Option<&'a str>,
30}
31
32#[derive(Serialize)]
33#[serde(untagged)]
34enum EmbeddingInput<'a> {
35    Single(&'a str),
36    Batch(Vec<&'a str>),
37}
38
39#[derive(Deserialize)]
40struct EmbeddingResponse {
41    data: Vec<EmbeddingData>,
42}
43
44#[derive(Deserialize)]
45struct EmbeddingData {
46    embedding: Vec<f32>,
47    index: usize,
48}
49
50/// Envelope that captures BOTH shapes the OpenRouter embeddings endpoint can
51/// return: the success payload (`data`) and the structured error object
52/// (`error`). OpenRouter sometimes returns the error object inside an HTTP 200
53/// body (e.g. token/context-length overflow); a direct parse to
54/// [`EmbeddingResponse`] would fail with a misleading missing-field error,
55/// masking the real cause. Both fields are optional so the branch is decided
56/// by inspection, not by a parse failure.
57#[derive(Deserialize)]
58struct EmbeddingEnvelope {
59    #[serde(default)]
60    data: Option<Vec<EmbeddingData>>,
61    #[serde(default)]
62    error: Option<ApiError>,
63}
64
65// ApiError and code_string() moved to `crate::openrouter_http` (GAP-SG-74):
66// this client and `crate::chat_api::OpenRouterChatClient` decode the exact
67// same structured error envelope, so the type is shared instead of
68// duplicated.
69use crate::openrouter_http::ApiError;
70
71/// [`OpenRouterClient::embed_single`] / [`OpenRouterClient::embed_batch`]
72/// failure (reauditor addendum, mirrors [`crate::chat_api::ChatError`]).
73///
74/// `retry_class` is the retry verdict computed AT THE ORIGIN (the exact HTTP
75/// status, or the provider's structured error `code`) via the same
76/// `openrouter_http::status_retry_class` /
77/// `openrouter_http::provider_error_retry_class` classifiers (private helpers)
78/// [`crate::chat_api::OpenRouterChatClient`] uses (GAP-SG-74 DRY) — never
79/// inferred downstream from `source.to_string()`. The enrich `re-embed`
80/// consumer reads this field directly instead of pattern-matching the
81/// formatted message.
82#[derive(Debug)]
83pub struct EmbedError {
84    /// Underlying cause, preserved via `source()` rather than restated.
85    pub source: AppError,
86    /// Typed retry verdict computed where the failure originated (HTTP
87    /// status / provider code), not by matching `source`'s message.
88    pub retry_class: AttemptOutcome,
89}
90
91impl EmbedError {
92    fn new(source: AppError, retry_class: AttemptOutcome) -> Self {
93        Self {
94            source,
95            retry_class,
96        }
97    }
98}
99
100impl std::fmt::Display for EmbedError {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        std::fmt::Display::fmt(&self.source, f)
103    }
104}
105
106impl std::error::Error for EmbedError {
107    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
108        Some(&self.source)
109    }
110}
111
112/// Converts a bare `AppError` into an `EmbedError` with `retry_class:
113/// HardFailure`. Used by the `?` operator on call sites that predate the
114/// origin-typed classification (the GAP-SG-02 oversized-input guard, the
115/// dimension-mismatch guard in `OpenRouterClient::truncate_embedding`, and
116/// the batch-size-mismatch check) — all of those are genuine permanent
117/// client/config errors, never transient. Every `EmbedError` constructed
118/// inside `execute_with_retry` uses `EmbedError::new` explicitly with a
119/// retry verdict computed at the exact HTTP status / provider code instead.
120impl From<AppError> for EmbedError {
121    fn from(source: AppError) -> Self {
122        Self::new(source, AttemptOutcome::HardFailure)
123    }
124}
125
126/// Unwraps `EmbedError` back down to its `source`, discarding `retry_class`.
127/// Lets the many pre-existing `?`-based callers of [`OpenRouterClient::embed_single`]
128/// / [`OpenRouterClient::embed_batch`] (in [`crate::embedder`]) keep compiling
129/// unchanged; callers that need the typed retry verdict (the enrich
130/// `re-embed` path) should match on `EmbedError` directly instead of relying
131/// on this conversion.
132impl From<EmbedError> for AppError {
133    fn from(err: EmbedError) -> Self {
134        err.source
135    }
136}
137
138pub struct OpenRouterClient {
139    client: reqwest::Client,
140    api_key: SecretBox<String>,
141    model: String,
142    dim: usize,
143    supports_mrl: bool,
144    default_input_type: Option<&'static str>,
145    /// Endpoint each request is POSTed to. Resolved from XDG/config at
146    /// construction (default: [`DEFAULT_OPENROUTER_EMBEDDINGS_URL`]).
147    base_url: String,
148}
149
150fn model_supports_mrl(model: &str) -> bool {
151    model.contains("qwen3-embedding")
152        || model.contains("text-embedding-3")
153        || model.contains("gemini-embedding")
154        || model.contains("llama-nemotron-embed")
155        || model.contains("bge-m3")
156}
157
158fn model_default_input_type(model: &str) -> Option<&'static str> {
159    if model.contains("llama-nemotron-embed") {
160        Some("passage")
161    } else if model.contains("mistral-embed") {
162        None
163    } else {
164        Some("search_document")
165    }
166}
167
168impl OpenRouterClient {
169    pub fn new(api_key: SecretBox<String>, model: String, dim: usize) -> Result<Self, AppError> {
170        let base_url = crate::runtime_config::openrouter_embeddings_url(
171            DEFAULT_OPENROUTER_EMBEDDINGS_URL,
172        );
173        Self::new_with_base_url(api_key, model, dim, base_url)
174    }
175
176    /// Build a client posting to an explicit `base_url` (XDG override, tests, gateways).
177    pub fn new_with_base_url(
178        api_key: SecretBox<String>,
179        model: String,
180        dim: usize,
181        base_url: String,
182    ) -> Result<Self, AppError> {
183        let client = reqwest::Client::builder()
184            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
185            .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
186            .user_agent(concat!("sqlite-graphrag/", env!("CARGO_PKG_VERSION")))
187            .build()
188            .map_err(|e| AppError::Embedding(format!("failed to build HTTP client: {e}")))?;
189
190        let supports_mrl = model_supports_mrl(&model);
191        let default_input_type = model_default_input_type(&model);
192
193        Ok(Self {
194            client,
195            api_key,
196            model,
197            dim,
198            supports_mrl,
199            default_input_type,
200            base_url,
201        })
202    }
203
204    /// Test-only constructor that POSTs to an arbitrary `base_url` (such as a
205    /// `wiremock::MockServer`) instead of the public OpenRouter endpoint.
206    /// Behaviour is otherwise identical to [`Self::new`].
207    #[cfg(test)]
208    fn new_with_url(
209        api_key: SecretBox<String>,
210        model: String,
211        dim: usize,
212        base_url: String,
213    ) -> Result<Self, AppError> {
214        Self::new_with_base_url(api_key, model, dim, base_url)
215    }
216
217    pub fn default_input_type(&self) -> Option<&'static str> {
218        self.default_input_type
219    }
220
221    pub async fn embed_single(
222        &self,
223        text: &str,
224        input_type: Option<&str>,
225    ) -> Result<Vec<f32>, EmbedError> {
226        // GAP-SG-02: reject an input that would overflow the model's token
227        // window BEFORE the HTTP request, surfacing a clear Validation error
228        // instead of a provider context-length rejection paid for round-trip.
229        crate::memory_guard::check_embedding_input_size(text)?;
230
231        let request = EmbeddingRequest {
232            model: &self.model,
233            input: EmbeddingInput::Single(text),
234            dimensions: if self.supports_mrl {
235                Some(self.dim)
236            } else {
237                None
238            },
239            encoding_format: "float",
240            input_type,
241        };
242
243        let response = self.execute_with_retry(&request).await?;
244
245        let embedding = response
246            .data
247            .into_iter()
248            .next()
249            .ok_or_else(|| AppError::Embedding("empty response from OpenRouter".into()))?
250            .embedding;
251
252        Ok(self.truncate_embedding(embedding)?)
253    }
254
255    pub async fn embed_batch(
256        &self,
257        texts: &[&str],
258        input_type: Option<&str>,
259    ) -> Result<Vec<Vec<f32>>, EmbedError> {
260        if texts.is_empty() {
261            return Ok(Vec::new());
262        }
263
264        // GAP-SG-02: validate every input before any HTTP request so an
265        // oversized member of the batch fails fast as Validation rather than a
266        // provider context-length rejection mid-batch.
267        for text in texts {
268            crate::memory_guard::check_embedding_input_size(text)?;
269        }
270
271        let mut all = Vec::with_capacity(texts.len());
272
273        for chunk in texts.chunks(MAX_BATCH_SIZE) {
274            let request = EmbeddingRequest {
275                model: &self.model,
276                input: EmbeddingInput::Batch(chunk.to_vec()),
277                dimensions: if self.supports_mrl {
278                    Some(self.dim)
279                } else {
280                    None
281                },
282                encoding_format: "float",
283                input_type,
284            };
285
286            let response = self.execute_with_retry(&request).await?;
287
288            if response.data.len() != chunk.len() {
289                return Err(AppError::Embedding(format!(
290                    "expected {} embeddings, got {}",
291                    chunk.len(),
292                    response.data.len()
293                ))
294                .into());
295            }
296
297            let mut sorted = response.data;
298            sorted.sort_by_key(|d| d.index);
299
300            for d in sorted {
301                all.push(self.truncate_embedding(d.embedding)?);
302            }
303        }
304
305        Ok(all)
306    }
307
308    fn truncate_embedding(&self, embedding: Vec<f32>) -> Result<Vec<f32>, AppError> {
309        if embedding.len() < self.dim {
310            return Err(AppError::Embedding(format!(
311                "embedding dimension {} < requested {}",
312                embedding.len(),
313                self.dim
314            )));
315        }
316        if embedding.len() == self.dim {
317            Ok(embedding)
318        } else {
319            Ok(embedding[..self.dim].to_vec())
320        }
321    }
322
323    /// Runs the request/retry loop, classifying every failure into an
324    /// [`EmbedError`] with `retry_class` set AT THE ORIGIN (the exact HTTP
325    /// status, or the provider's structured error code) via the same
326    /// classifiers [`crate::chat_api::OpenRouterChatClient`] uses
327    /// (GAP-SG-74 DRY) — never inferred downstream from a formatted message
328    /// (reauditor addendum).
329    async fn execute_with_retry(
330        &self,
331        request: &EmbeddingRequest<'_>,
332    ) -> Result<EmbeddingResponse, EmbedError> {
333        let mut last_err: Option<EmbedError> = None;
334
335        for attempt in 0..crate::openrouter_http::MAX_RETRIES {
336            let result = self
337                .client
338                .post(&self.base_url)
339                .header(
340                    "Authorization",
341                    format!("Bearer {}", self.api_key.expose_secret()),
342                )
343                .json(request)
344                .send()
345                .await;
346
347            let resp = match result {
348                Ok(r) => r,
349                Err(e) if e.is_timeout() => {
350                    return Err(EmbedError::new(
351                        AppError::Embedding("OpenRouter request timed out".into()),
352                        AttemptOutcome::Transient,
353                    ));
354                }
355                Err(e) => {
356                    last_err = Some(EmbedError::new(
357                        AppError::Embedding(format!("HTTP request failed: {e}")),
358                        AttemptOutcome::Transient,
359                    ));
360                    crate::openrouter_http::backoff(attempt).await;
361                    continue;
362                }
363            };
364
365            let status = resp.status();
366
367            if status.is_success() {
368                let body = resp.text().await.map_err(|e| {
369                    EmbedError::new(
370                        AppError::Embedding(format!("failed to read response body: {e}")),
371                        AttemptOutcome::Transient,
372                    )
373                })?;
374                match serde_json::from_str::<EmbeddingEnvelope>(&body) {
375                    Ok(env) => {
376                        // A structured error object inside a 2xx body is
377                        // classified by its own `code` (GAP-SG-01 surfaces
378                        // the real code/message instead of masking it as a
379                        // parse failure).
380                        if let Some(api_err) = env.error {
381                            let retry_class =
382                                crate::openrouter_http::provider_error_retry_class(&api_err);
383                            return Err(EmbedError::new(
384                                AppError::ProviderError {
385                                    code: api_err.code_string(),
386                                    message: api_err.message,
387                                },
388                                retry_class,
389                            ));
390                        }
391                        match env.data {
392                            Some(data) => return Ok(EmbeddingResponse { data }),
393                            None => {
394                                tracing::warn!(
395                                    attempt,
396                                    body_len = body.len(),
397                                    "HTTP 200 with neither data nor error (retrying)"
398                                );
399                                last_err = Some(EmbedError::new(
400                                    AppError::Embedding(
401                                        "OpenRouter 200 response had neither data nor error".into(),
402                                    ),
403                                    AttemptOutcome::Transient,
404                                ));
405                                crate::openrouter_http::backoff(attempt).await;
406                                continue;
407                            }
408                        }
409                    }
410                    Err(e) => {
411                        tracing::warn!(
412                            attempt,
413                            body_len = body.len(),
414                            "HTTP 200 but JSON unparseable (retrying): {e}"
415                        );
416                        last_err = Some(EmbedError::new(
417                            AppError::Embedding(format!("failed to parse embedding response: {e}")),
418                            AttemptOutcome::Transient,
419                        ));
420                        crate::openrouter_http::backoff(attempt).await;
421                        continue;
422                    }
423                }
424            }
425
426            if status.as_u16() == 401 {
427                return Err(EmbedError::new(
428                    AppError::Embedding("invalid OpenRouter API key (HTTP 401)".into()),
429                    AttemptOutcome::HardFailure,
430                ));
431            }
432
433            if status.as_u16() == 400 || status.as_u16() == 404 {
434                let body = resp.text().await.unwrap_or_default();
435                return Err(EmbedError::new(
436                    AppError::Embedding(format!("OpenRouter returned {status}: {body}")),
437                    AttemptOutcome::HardFailure,
438                ));
439            }
440
441            if status.as_u16() == 429 {
442                let retry_after = resp
443                    .headers()
444                    .get("retry-after")
445                    .and_then(|v| v.to_str().ok())
446                    .and_then(|v| v.parse::<u64>().ok())
447                    .unwrap_or(2);
448                tracing::warn!(
449                    attempt,
450                    retry_after_secs = retry_after,
451                    "OpenRouter rate limited, waiting"
452                );
453                // GAP-SG-56: surface the Retry-After delay to the caller. If
454                // every attempt is rate limited, the loop exits with this
455                // RateLimited error (retryable) carrying the server-advised
456                // wait, instead of a generic max-retries-exceeded message.
457                last_err = Some(EmbedError::new(
458                    AppError::RateLimited {
459                        detail: format!("OpenRouter HTTP 429 (retry-after {retry_after}s)"),
460                    },
461                    AttemptOutcome::Transient,
462                ));
463                tokio::time::sleep(Duration::from_secs(retry_after)).await;
464                continue;
465            }
466
467            if status.is_server_error() {
468                tracing::warn!(attempt, status = %status, "OpenRouter server error, retrying");
469                last_err = Some(EmbedError::new(
470                    AppError::Embedding(format!("OpenRouter server error: {status}")),
471                    AttemptOutcome::Transient,
472                ));
473                crate::openrouter_http::backoff(attempt).await;
474                continue;
475            }
476
477            let body = resp.text().await.unwrap_or_default();
478            return Err(EmbedError::new(
479                AppError::Embedding(format!("unexpected HTTP {status}: {body}")),
480                crate::openrouter_http::status_retry_class(status),
481            ));
482        }
483
484        // Reauditor addendum: exhausting every retry against a transient
485        // condition (429/5xx/timeout/network) is ITSELF transient — it is
486        // exactly the case the queue's re-embed `--max-attempts` backoff
487        // covers, and must never be reclassified as a permanent failure.
488        Err(last_err.unwrap_or_else(|| {
489            EmbedError::new(
490                AppError::Embedding("max retries exceeded for OpenRouter request".into()),
491                AttemptOutcome::Transient,
492            )
493        }))
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn test_supports_mrl_detection() {
503        assert!(model_supports_mrl("qwen/qwen3-embedding-8b"));
504        assert!(model_supports_mrl("qwen/qwen3-embedding-4b"));
505        assert!(model_supports_mrl("openai/text-embedding-3-small"));
506        assert!(model_supports_mrl("openai/text-embedding-3-large"));
507        assert!(model_supports_mrl("google/gemini-embedding-001"));
508        assert!(model_supports_mrl("google/gemini-embedding-2"));
509        assert!(model_supports_mrl(
510            "nvidia/llama-nemotron-embed-vl-1b-v2:free"
511        ));
512        assert!(model_supports_mrl("baai/bge-m3"));
513
514        assert!(!model_supports_mrl("perplexity/pplx-embed-v1-0.6b"));
515        assert!(!model_supports_mrl("mistralai/mistral-embed-2312"));
516        assert!(!model_supports_mrl("some-random-model"));
517    }
518
519    #[test]
520    fn test_model_default_input_type() {
521        assert_eq!(
522            model_default_input_type("nvidia/llama-nemotron-embed-vl-1b-v2:free"),
523            Some("passage")
524        );
525        assert_eq!(
526            model_default_input_type("mistralai/mistral-embed-2312"),
527            None
528        );
529        assert_eq!(
530            model_default_input_type("qwen/qwen3-embedding-8b"),
531            Some("search_document")
532        );
533        assert_eq!(
534            model_default_input_type("openai/text-embedding-3-small"),
535            Some("search_document")
536        );
537        assert_eq!(
538            model_default_input_type("baai/bge-m3"),
539            Some("search_document")
540        );
541    }
542
543    #[test]
544    fn test_truncate_embedding() {
545        let api_key = SecretBox::new(Box::new("test-key".to_string()));
546        let client = OpenRouterClient::new(api_key, "test-model".into(), 3).unwrap();
547
548        let full = vec![1.0, 2.0, 3.0, 4.0, 5.0];
549        let truncated = client.truncate_embedding(full).unwrap();
550        assert_eq!(truncated, vec![1.0, 2.0, 3.0]);
551
552        let exact = vec![1.0, 2.0, 3.0];
553        let kept = client.truncate_embedding(exact).unwrap();
554        assert_eq!(kept, vec![1.0, 2.0, 3.0]);
555
556        let short = vec![1.0, 2.0];
557        let err = client.truncate_embedding(short);
558        assert!(err.is_err());
559    }
560
561    #[test]
562    fn embedding_envelope_surfaces_provider_error_not_missing_field() {
563        // GAP-SG-01: a 200 body carrying an OpenRouter error object must yield
564        // the REAL message, not the misleading missing-field parse failure.
565        let body = r#"{"error":{"code":400,"message":"context length exceeded"}}"#;
566
567        // Precondition: the legacy optimistic parse masked the cause. Match
568        // instead of unwrap_err so EmbeddingResponse need not derive Debug.
569        let legacy_err = match serde_json::from_str::<EmbeddingResponse>(body) {
570            Ok(_) => panic!("legacy parse should have failed on an error body"),
571            Err(e) => e.to_string(),
572        };
573        assert!(
574            legacy_err.contains("missing field"),
575            "precondition: legacy parse masks the cause as a missing field: {legacy_err}"
576        );
577
578        // The envelope captures the structured error instead.
579        let env: EmbeddingEnvelope =
580            serde_json::from_str(body).expect("envelope parses an error body");
581        assert!(env.data.is_none());
582        let api_err = env.error.expect("error object captured");
583        assert_eq!(api_err.message, "context length exceeded");
584        assert_eq!(api_err.code_string(), "400");
585    }
586
587    #[test]
588    fn embedding_envelope_parses_success_body() {
589        let body = r#"{"data":[{"embedding":[1.0,2.0,3.0],"index":0}]}"#;
590        let env: EmbeddingEnvelope =
591            serde_json::from_str(body).expect("envelope parses a success body");
592        assert!(env.error.is_none());
593        let data = env.data.expect("data present");
594        assert_eq!(data.len(), 1);
595        assert_eq!(data[0].embedding, vec![1.0, 2.0, 3.0]);
596    }
597
598    #[test]
599    fn api_error_code_string_handles_number_string_and_missing() {
600        let num: ApiError = serde_json::from_str(r#"{"code":429,"message":"slow down"}"#).unwrap();
601        assert_eq!(num.code_string(), "429");
602
603        let s: ApiError =
604            serde_json::from_str(r#"{"code":"rate_limited","message":"slow down"}"#).unwrap();
605        assert_eq!(s.code_string(), "rate_limited");
606
607        let missing: ApiError = serde_json::from_str(r#"{"message":"oops"}"#).unwrap();
608        assert_eq!(missing.code_string(), "unknown");
609    }
610
611    #[tokio::test]
612    async fn embed_single_rejects_oversized_input_before_request() {
613        // GAP-SG-02 / v1.1.2 (Gap 2): an input above
614        // EMBEDDING_REQUEST_MAX_TOKENS must fail as the typed TooManyTokens
615        // (exit 6) WITHOUT any network call. The fake key/URL would error
616        // distinctly (Embedding) if the guard let the request through.
617        let api_key = SecretBox::new(Box::new("test-key".to_string()));
618        let client = OpenRouterClient::new(api_key, "qwen/qwen3-embedding-8b".into(), 384).unwrap();
619        let big = "word ".repeat(crate::constants::EMBEDDING_REQUEST_MAX_TOKENS + 5_000);
620        match client.embed_single(&big, None).await {
621            Err(EmbedError {
622                source: AppError::TooManyTokens { tokens, limit },
623                retry_class,
624            }) => {
625                assert!(tokens > limit, "tokens={tokens} limit={limit}");
626                assert_eq!(limit, crate::constants::EMBEDDING_REQUEST_MAX_TOKENS as u64);
627                assert_eq!(
628                    retry_class,
629                    AttemptOutcome::HardFailure,
630                    "an oversized input is a permanent client error"
631                );
632            }
633            other => unreachable!("expected TooManyTokens before request, got: {other:?}"),
634        }
635    }
636
637    async fn client_for(server: &wiremock::MockServer, model: &str) -> OpenRouterClient {
638        OpenRouterClient::new_with_url(
639            SecretBox::new(Box::new("test-key".to_string())),
640            model.to_string(),
641            384,
642            format!("{}/embeddings", server.uri()),
643        )
644        .expect("test client builds")
645    }
646
647    #[tokio::test]
648    async fn embed_single_401_is_hard_failure() {
649        // Reauditor addendum: classification happens at the HTTP status, not
650        // by matching the error message downstream.
651        use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate};
652        let server = MockServer::start().await;
653        Mock::given(method("POST"))
654            .respond_with(ResponseTemplate::new(401))
655            .mount(&server)
656            .await;
657
658        let client = client_for(&server, "qwen/qwen3-embedding-8b").await;
659        let err = client
660            .embed_single("hello", None)
661            .await
662            .expect_err("401 is an error");
663        assert_eq!(err.retry_class, AttemptOutcome::HardFailure);
664    }
665
666    #[tokio::test]
667    async fn embed_single_exhausted_5xx_is_transient() {
668        // Reauditor addendum: exhausting every retry against a persistent
669        // 5xx is TRANSIENT — the caller's --max-attempts is what eventually
670        // dead-letters it, never a HardFailure from this layer.
671        use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate};
672        let server = MockServer::start().await;
673        Mock::given(method("POST"))
674            .respond_with(ResponseTemplate::new(503))
675            .mount(&server)
676            .await;
677
678        let client = client_for(&server, "qwen/qwen3-embedding-8b").await;
679        let err = client
680            .embed_single("hello", None)
681            .await
682            .expect_err("persistent 5xx exhausts retries");
683        assert_eq!(err.retry_class, AttemptOutcome::Transient);
684    }
685
686    #[tokio::test]
687    async fn embed_single_provider_error_code_classifies_by_code_not_message() {
688        // Reauditor addendum: a 200 body carrying a structured provider error
689        // is classified by its `code`, reusing the exact same classifier
690        // `chat_api` uses (GAP-SG-74 DRY).
691        use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate};
692        let server = MockServer::start().await;
693        Mock::given(method("POST"))
694            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
695                "error": { "code": "context_length_exceeded", "message": "too many tokens" }
696            })))
697            .mount(&server)
698            .await;
699
700        let client = client_for(&server, "qwen/qwen3-embedding-8b").await;
701        let err = client
702            .embed_single("hello", None)
703            .await
704            .expect_err("provider error must surface");
705        assert_eq!(err.retry_class, AttemptOutcome::HardFailure);
706    }
707}