Skip to main content

sqlite_graphrag/embedder/
fallback.rs

1//! Embedding error classification and fallback orchestration.
2
3use super::*;
4use crate::errors::AppError;
5use std::path::Path;
6
7/// GAP-004 (v1.0.88): typed classifier for embedding error messages.
8///
9/// Decomposes the legacy `AppError::Embedding(String)` payload into a
10/// small enum so the call sites can branch on the cause instead of
11/// repeating `msg.contains(...)` literals. The classification is purely
12/// lexical (case-insensitive substring match on the error message) — no
13/// I/O, no retries, no telemetry, deterministic and safe under
14/// `#[serial_test::serial(env)]`.
15///
16/// 6 variants cover the 5 known discriminators from v1.0.85 (ADR-0043)
17/// plus an `Unknown` fallback for messages that do not match any marker.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum EmbeddingErrorKind {
20    /// OAuth token expired or absent; no backend can authenticate.
21    OAuth,
22    /// OAuth usage quota exhausted on the named backend.
23    Quota,
24    /// LLM slot semaphore exhausted after the backoff window.
25    SlotExhausted,
26    /// User-requested backend differs from the one that actually executed.
27    BackendMismatch,
28    /// Embedding returned a zero-dimensional vector (structural bug).
29    ZeroDimension,
30    /// Message did not match any of the 5 markers above.
31    Unknown,
32}
33
34impl EmbeddingErrorKind {
35    /// Classify an embedding error message into a typed kind.
36    ///
37    /// Order of checks matters: `OAuth` is matched before `Quota` because
38    /// both substrings can co-occur in the same message. `SlotExhausted`
39    /// is checked before `Quota` because the slot-sema path is more
40    /// specific (the LLM never even tried to authenticate). The checks
41    /// are case-insensitive so `OAuth` and `oauth` both classify to
42    /// `EmbeddingErrorKind::OAuth`.
43    pub fn classify(msg: &str) -> Self {
44        let m = msg.to_lowercase();
45        if m.contains("oauth") {
46            Self::OAuth
47        } else if m.contains("quota") {
48            Self::Quota
49        } else if m.contains("slot exhausted") {
50            Self::SlotExhausted
51        } else if m.contains("backend mismatch") {
52            Self::BackendMismatch
53        } else if m.contains("dim") && m.contains("zero") {
54            Self::ZeroDimension
55        } else {
56            Self::Unknown
57        }
58    }
59
60    /// Stable, machine-friendly discriminator code (lowercase, kebab-safe).
61    pub fn code(&self) -> &'static str {
62        match self {
63            Self::OAuth => "oauth",
64            Self::Quota => "quota",
65            Self::SlotExhausted => "slot-exhausted",
66            Self::BackendMismatch => "backend-mismatch",
67            Self::ZeroDimension => "zero-dimension",
68            Self::Unknown => "unknown",
69        }
70    }
71}
72
73impl std::fmt::Display for EmbeddingErrorKind {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.write_str(self.code())
76    }
77}
78
79/// G58/S1: reason an embedding call could not be completed and the caller
80/// must fall back to a non-vector retrieval path (FTS5 prefix + LIKE).
81///
82/// Returned by [`try_embed_query_with_fallback`] so the `recall` and
83/// `hybrid-search` handlers can surface a structured `vec_degraded` /
84/// `warning` envelope instead of a hard `AppError::Embedding` exit 11.
85#[derive(Debug, Clone, PartialEq)]
86pub enum FallbackReason {
87    /// The LLM subprocess failed (rate limit, OAuth contention, quota
88    /// exhausted, model unparsable response, divergent dim, etc.).
89    /// Carries the original error message for observability.
90    EmbeddingFailed(String),
91    /// The LLM slot semaphore was exhausted: 8+ concurrent LLM
92    /// subprocesses blocked the acquire beyond the backoff window
93    /// (50ms + 100ms + 200ms + 400ms = 750ms total). Resolved at v1.0.85
94    /// (GAP-003 / ADR-0043).
95    SlotExhausted,
96    /// OAuth usage quota exhausted on the named backend. The caller
97    /// should retry with an alternative backend (codex ↔ claude)
98    /// before falling back to FTS5-puro.
99    OAuthQuota {
100        /// Backend identifier.
101        backend: &'static str,
102    },
103    /// The user requested a backend that differs from the one that
104    /// actually executed the embedding (legacy "synonym for codex"
105    /// bug from v1.0.83). Resolved at v1.0.84 (GAP-002).
106    BackendMismatch {
107        /// Requested.
108        requested: &'static str,
109        /// Resolved.
110        resolved: &'static str,
111    },
112    /// The embedding returned a zero-dimensional vector, signalling a
113    /// structural bug (the LLM did not produce any floats). Distinct
114    /// from OAuthQuota (quota exhausted) and EmbeddingFailed
115    /// (subprocess error).
116    DimZero,
117    /// The embedding was cancelled by an external signal (SIGTERM, etc.).
118    Cancelled,
119    /// The embedding exceeded its time budget. Carries the operation name
120    /// and the elapsed seconds for diagnostic logging.
121    Timeout {
122        /// Operation.
123        operation: String,
124        /// Duration secs.
125        duration_secs: u64,
126    },
127}
128
129impl FallbackReason {
130    /// Stable, machine-friendly reason code used by JSON envelopes
131    /// (`vec_degraded_reason`). Mirrors the v1.0.84 contract extended
132    /// at v1.0.85 with 4 new variants (GAP-003 / ADR-0043).
133    pub fn reason_code(&self) -> &'static str {
134        match self {
135            Self::EmbeddingFailed(_) => "embedding_failed",
136            Self::SlotExhausted => "slot_exhausted",
137            Self::OAuthQuota { .. } => "oauth_quota",
138            Self::BackendMismatch { .. } => "backend_mismatch",
139            Self::DimZero => "dim_zero",
140            Self::Cancelled => "cancelled",
141            Self::Timeout { .. } => "timeout",
142        }
143    }
144}
145
146impl std::fmt::Display for FallbackReason {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        match self {
149            Self::EmbeddingFailed(msg) => write!(f, "embedding failed: {msg}"),
150            Self::SlotExhausted => write!(
151                f,
152                "slot exhausted: failed to acquire LLM slot after backoff window (max=8 concurrent, total backoff=750ms)"
153            ),
154            Self::OAuthQuota { backend } => {
155                write!(f, "OAuth usage quota exhausted on backend '{backend}'")
156            }
157            Self::BackendMismatch {
158                requested,
159                resolved,
160            } => {
161                write!(
162                    f,
163                    "backend mismatch: user requested '{requested}' but '{resolved}' was invoked"
164                )
165            }
166            Self::DimZero => write!(f, "embedding returned zero-dimensional vector"),
167            Self::Cancelled => write!(f, "embedding cancelled by external signal"),
168            Self::Timeout {
169                operation,
170                duration_secs,
171            } => {
172                write!(
173                    f,
174                    "embedding timed out after {duration_secs}s during {operation}"
175                )
176            }
177        }
178    }
179}
180
181impl std::error::Error for FallbackReason {}
182
183/// G58/S1: try to embed a query, mapping any failure to a structured
184/// [`FallbackReason`] so callers can route to FTS5 + LIKE fallback instead
185/// of returning exit 11 to the user.
186///
187/// This is the bridge between the hard-fail write paths (where embedding
188/// failure aborts the operation) and the graceful-degradation contract of
189/// `recall` / `hybrid-search` in v1.0.80.
190pub fn try_embed_query_with_fallback(
191    models_dir: &Path,
192    query: &str,
193) -> Result<(Vec<f32>, LlmBackendKind), FallbackReason> {
194    try_embed_query_with_choice(models_dir, query, None)
195}
196
197/// G58 / ADR-0043 (v1.0.85): deterministic fallback for `recall` and
198/// `hybrid-search`.
199///
200/// - On `SlotExhausted`, sleep 750ms and retry once (gives the slot
201///   semaphore time to release a permit from a sibling subprocess).
202/// - On any other `FallbackReason`, return immediately (deterministic).
203pub fn try_embed_query_with_deterministic_fallback(
204    models_dir: &Path,
205    query: &str,
206    choice: Option<crate::cli::LlmBackendChoice>,
207) -> Result<(Vec<f32>, LlmBackendKind), FallbackReason> {
208    match try_embed_query_with_choice(models_dir, query, choice) {
209        Ok(t) => Ok(t),
210        Err(reason @ FallbackReason::SlotExhausted) => {
211            std::thread::sleep(std::time::Duration::from_millis(
212                crate::constants::EMBED_SLOT_RETRY_DELAY_MS,
213            ));
214            try_embed_query_with_choice(models_dir, query, choice).or(Err(reason))
215        }
216        Err(other) => Err(other),
217    }
218}
219
220/// Classify an embedding [`AppError`] into a typed [`FallbackReason`].
221///
222/// v1.0.85 (ADR-0043): discriminates the 4 new causes (SlotExhausted,
223/// OAuthQuota, BackendMismatch, DimZero) from the legacy generic
224/// EmbeddingFailed bucket. The classification is purely lexical
225/// (substring match on the message) — no I/O, no retries, no
226/// telemetry, deterministic and `#[serial_test::serial(env)]`-safe.
227pub fn classify_embedding_error(err: AppError) -> FallbackReason {
228    match err {
229        AppError::Timeout {
230            operation,
231            duration_secs,
232        } => FallbackReason::Timeout {
233            operation,
234            duration_secs,
235        },
236        AppError::Embedding(msg) => match EmbeddingErrorKind::classify(&msg) {
237            // GAP-004 (v1.0.88): typed-discriminator dispatch.
238            // The lexical classifier picks the discriminator; the arms below
239            // enrich the result with the backend name and the
240            // requested/resolved pair that the JSON envelope needs.
241            //
242            // Note: `Cancelled` and `EmbeddingFailed(msg)` are not in the
243            // 6-variant enum (they have no lexical marker) so we keep them
244            // as explicit guards at the head of the match.
245            EmbeddingErrorKind::SlotExhausted => FallbackReason::SlotExhausted,
246            EmbeddingErrorKind::OAuth | EmbeddingErrorKind::Quota => {
247                let backend = if msg.contains("openrouter") {
248                    "openrouter"
249                } else {
250                    "unknown"
251                };
252                FallbackReason::OAuthQuota { backend }
253            }
254            EmbeddingErrorKind::BackendMismatch => {
255                let (requested, resolved) = if msg.contains("requested openrouter") {
256                    ("openrouter", "unknown")
257                } else {
258                    ("unknown", "unknown")
259                };
260                FallbackReason::BackendMismatch {
261                    requested,
262                    resolved,
263                }
264            }
265            EmbeddingErrorKind::ZeroDimension => FallbackReason::DimZero,
266            EmbeddingErrorKind::Unknown => {
267                if msg.contains("cancelled") {
268                    FallbackReason::Cancelled
269                } else {
270                    FallbackReason::EmbeddingFailed(msg)
271                }
272            }
273        },
274        e => FallbackReason::EmbeddingFailed(e.to_string()),
275    }
276}
277// backends before giving up. The chain order matches the user-supplied
278// `--llm-fallback` list (default: none).
279// =============================================================================
280
281/// Tries each LLM backend in `chain` in order, returning the first
282/// successful embedding. On failure, the diagnostic tail of the last
283/// error is preserved in the returned `AppError::Embedding` so the
284/// operator can see WHY every backend failed.
285///
286/// If `skip_on_failure` is `true` AND every backend fails, the function
287/// returns `Ok(Vec::new())` (an empty vector) to signal "persist
288/// without embedding" — the call site is then responsible for writing
289/// a `pending_embeddings` row that can be retried later by the
290/// `embedding retry` subcommand.
291///
292/// Defaults the chain to `[openrouter, none]` when `chain` is empty.
293pub fn embed_with_fallback(
294    models_dir: &Path,
295    text: &str,
296    chain: &[LlmBackendKind],
297    skip_on_failure: bool,
298) -> Result<(Vec<f32>, LlmBackendKind), AppError> {
299    use crate::llm::exit_code_hints::LlmBackendError;
300    let effective: Vec<LlmBackendKind> = if chain.is_empty() {
301        vec![LlmBackendKind::OpenRouter, LlmBackendKind::None]
302    } else {
303        chain.to_vec()
304    };
305
306    let mut last_err: Option<AppError> = None;
307    for backend in &effective {
308        // GAP-E2E-06 / v1.1.8: fail-fast credential probe so a dead
309        // backend does not stall the chain before FTS fallback.
310        if let Err(probe_err) = backend_ready_probe(backend) {
311            tracing::warn!(
312                target: "embedding",
313                backend = ?backend,
314                error = %probe_err,
315                "embed_with_fallback: backend probe failed, skipping"
316            );
317            last_err = Some(probe_err);
318            continue;
319        }
320        // ADR-0046 / BUG-11 v1.0.88: use `embed_via_backend_strict` so the
321        // sentinel `None` backend propagates the last real error instead
322        // of silently degrading to `Ok((Vec::new(), None))`. This is the
323        // path that caused preflight rejections to be swallowed by the
324        // chain's default trailing `None`.
325        match embed_via_backend_strict(
326            models_dir,
327            text,
328            backend,
329            last_err.as_ref(),
330            skip_on_failure,
331        ) {
332            Ok((v, resolved_kind)) => return Ok((v, resolved_kind)),
333            Err(e) => {
334                // ADR-0011: Validation errors (OAuth-only enforcement) are
335                // FATAL — propagate immediately without trying the next
336                // backend. This prevents the fallback chain from swallowing
337                // OAuth violations via the trailing `None` sentinel.
338                if matches!(e, AppError::Validation(_)) {
339                    return Err(e);
340                }
341                tracing::warn!(
342                    target: "embedding",
343                    backend = ?backend,
344                    error = %e,
345                    "embed_with_fallback: backend failed, trying next"
346                );
347                last_err = Some(e);
348            }
349        }
350    }
351    if skip_on_failure {
352        // Signal "persist with no embedding" via an empty vector paired
353        // with `None` so callers know the chain exhausted without a hit.
354        // Caller is responsible for writing a `pending_embeddings` row
355        // that can be retried later by the `embedding retry` subcommand.
356        return Ok((Vec::new(), LlmBackendKind::None));
357    }
358    Err(last_err.unwrap_or_else(|| {
359        AppError::Embedding(crate::i18n::validation::embedding_detail(
360            LlmBackendError::NoBackendsAvailable,
361        ))
362    }))
363}