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 `embed_query_local` (used by
188/// write paths where embedding failure aborts the operation) and the
189/// graceful-degradation contract of `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 match embed_query_local(models_dir, query) {
195 Ok(v) => Ok((v, LlmBackendKind::None)),
196 Err(e) => Err(classify_embedding_error(e)),
197 }
198}
199
200/// G58 / ADR-0043 (v1.0.85): deterministic fallback for `recall` and
201/// `hybrid-search`.
202///
203/// - On `OAuthQuota { backend }`, retry once with the alternative backend
204/// (codex ↔ claude) before giving up.
205/// - On `SlotExhausted`, sleep 750ms and retry once (gives the slot
206/// semaphore time to release a permit from a sibling subprocess).
207/// - On any other `FallbackReason`, return immediately (deterministic).
208pub fn try_embed_query_with_deterministic_fallback(
209 models_dir: &Path,
210 query: &str,
211 choice: Option<crate::cli::LlmBackendChoice>,
212) -> Result<(Vec<f32>, LlmBackendKind), FallbackReason> {
213 match try_embed_query_with_choice(models_dir, query, choice) {
214 Ok(t) => Ok(t),
215 Err(reason @ FallbackReason::OAuthQuota { backend }) => {
216 let alt = match backend {
217 "codex" => Some(crate::cli::LlmBackendChoice::Claude),
218 "claude" => Some(crate::cli::LlmBackendChoice::Codex),
219 "opencode" => Some(crate::cli::LlmBackendChoice::Codex),
220 "openrouter" => Some(crate::cli::LlmBackendChoice::Codex),
221 _ => None,
222 };
223 if let Some(alt_choice) = alt {
224 try_embed_query_with_choice(models_dir, query, Some(alt_choice))
225 } else {
226 Err(reason)
227 }
228 }
229 Err(reason @ FallbackReason::SlotExhausted) => {
230 std::thread::sleep(std::time::Duration::from_millis(750));
231 try_embed_query_with_choice(models_dir, query, choice).or(Err(reason))
232 }
233 Err(other) => Err(other),
234 }
235}
236
237/// Classify an embedding [`AppError`] into a typed [`FallbackReason`].
238///
239/// v1.0.85 (ADR-0043): discriminates the 4 new causes (SlotExhausted,
240/// OAuthQuota, BackendMismatch, DimZero) from the legacy generic
241/// EmbeddingFailed bucket. The classification is purely lexical
242/// (substring match on the message) — no I/O, no retries, no
243/// telemetry, deterministic and `#[serial_test::serial(env)]`-safe.
244pub fn classify_embedding_error(err: AppError) -> FallbackReason {
245 match err {
246 AppError::Timeout {
247 operation,
248 duration_secs,
249 } => FallbackReason::Timeout {
250 operation,
251 duration_secs,
252 },
253 AppError::Embedding(msg) => match EmbeddingErrorKind::classify(&msg) {
254 // GAP-004 (v1.0.88): typed-discriminator dispatch.
255 // The lexical classifier picks the discriminator; the arms below
256 // enrich the result with the backend name and the
257 // requested/resolved pair that the JSON envelope needs.
258 //
259 // Note: `Cancelled` and `EmbeddingFailed(msg)` are not in the
260 // 6-variant enum (they have no lexical marker) so we keep them
261 // as explicit guards at the head of the match.
262 EmbeddingErrorKind::SlotExhausted => FallbackReason::SlotExhausted,
263 EmbeddingErrorKind::OAuth => {
264 let backend = if msg.contains("codex") {
265 "codex"
266 } else if msg.contains("claude") || msg.contains("anthropic-ratelimit") {
267 // G45-CR5: anthropic-ratelimit-* headers are emitted only by
268 // the Claude CLI subprocess; treat them as claude quota
269 // signals even when the message text omits the word
270 // "claude" explicitly.
271 "claude"
272 } else if msg.contains("opencode") {
273 "opencode"
274 } else {
275 "unknown"
276 };
277 FallbackReason::OAuthQuota { backend }
278 }
279 EmbeddingErrorKind::Quota => {
280 let backend = if msg.contains("codex") {
281 "codex"
282 } else if msg.contains("claude") || msg.contains("anthropic-ratelimit") {
283 "claude"
284 } else if msg.contains("opencode") {
285 "opencode"
286 } else {
287 "unknown"
288 };
289 FallbackReason::OAuthQuota { backend }
290 }
291 EmbeddingErrorKind::BackendMismatch => {
292 // The `msg.contains("claude")` arm is intentionally
293 // placed BEFORE the OAuth arm so that a backend-mismatch
294 // message that mentions both "claude" and "codex" maps to
295 // BackendMismatch (the more specific failure mode).
296 let (requested, resolved) =
297 if msg.contains("requested claude") && msg.contains("but codex") {
298 ("claude", "codex")
299 } else if msg.contains("requested codex") && msg.contains("but claude") {
300 ("codex", "claude")
301 } else if msg.contains("requested claude") {
302 ("claude", "unknown")
303 } else if msg.contains("requested codex") {
304 ("codex", "unknown")
305 } else {
306 ("unknown", "unknown")
307 };
308 FallbackReason::BackendMismatch {
309 requested,
310 resolved,
311 }
312 }
313 EmbeddingErrorKind::ZeroDimension => FallbackReason::DimZero,
314 EmbeddingErrorKind::Unknown => {
315 if msg.contains("cancelled") {
316 FallbackReason::Cancelled
317 } else {
318 FallbackReason::EmbeddingFailed(msg)
319 }
320 }
321 },
322 e => FallbackReason::EmbeddingFailed(e.to_string()),
323 }
324}
325// backends before giving up. The chain order matches the user-supplied
326// `--llm-fallback` list (default: codex, claude, none).
327// =============================================================================
328
329/// Tries each LLM backend in `chain` in order, returning the first
330/// successful embedding. On failure, the diagnostic tail of the last
331/// error is preserved in the returned `AppError::Embedding` so the
332/// operator can see WHY every backend failed.
333///
334/// If `skip_on_failure` is `true` AND every backend fails, the function
335/// returns `Ok(Vec::new())` (an empty vector) to signal "persist
336/// without embedding" — the call site is then responsible for writing
337/// a `pending_embeddings` row that can be retried later by the
338/// `embedding retry` subcommand.
339///
340/// Defaults the chain to `[codex, claude, none]` when `chain` is
341/// empty, matching the v1.0.81 behaviour where codex was the
342/// implicit default and claude was the implicit fallback.
343pub fn embed_with_fallback(
344 models_dir: &Path,
345 text: &str,
346 chain: &[LlmBackendKind],
347 skip_on_failure: bool,
348) -> Result<(Vec<f32>, LlmBackendKind), AppError> {
349 use crate::llm::exit_code_hints::LlmBackendError;
350 let effective: Vec<LlmBackendKind> = if chain.is_empty() {
351 vec![
352 LlmBackendKind::Codex,
353 LlmBackendKind::Claude,
354 LlmBackendKind::Opencode,
355 LlmBackendKind::None,
356 ]
357 } else {
358 chain.to_vec()
359 };
360
361 let mut last_err: Option<AppError> = None;
362 for backend in &effective {
363 // GAP-E2E-06 / v1.1.8: fail-fast credential/binary probe so Auto
364 // does not burn ~20s on a dead Codex/Claude before FTS fallback.
365 if let Err(probe_err) = backend_ready_probe(backend) {
366 tracing::warn!(
367 target: "embedding",
368 backend = ?backend,
369 error = %probe_err,
370 "embed_with_fallback: backend probe failed, skipping"
371 );
372 last_err = Some(probe_err);
373 continue;
374 }
375 // BUG-003 / v1.0.85: propagar o backend REAL retornado por
376 // embed_via_backend (que pode diferir do chain position quando
377 // LlmEmbedding::detect_available substitui codex por claude).
378 // O tuple `(_, requested_kind)` é descartado — só queremos o
379 // backend resolvido na primeira posição.
380 // ADR-0046 / BUG-11 v1.0.88: use `embed_via_backend_strict` so the
381 // sentinel `None` backend propagates the last real error instead
382 // of silently degrading to `Ok((Vec::new(), None))`. This is the
383 // path that caused preflight rejections to be swallowed by the
384 // chain's default trailing `None`.
385 match embed_via_backend_strict(
386 models_dir,
387 text,
388 backend,
389 last_err.as_ref(),
390 skip_on_failure,
391 ) {
392 Ok((v, resolved_kind)) => return Ok((v, resolved_kind)),
393 Err(e) => {
394 // ADR-0011: Validation errors (OAuth-only enforcement) are
395 // FATAL — propagate immediately without trying the next
396 // backend. This prevents the fallback chain from swallowing
397 // OAuth violations via the trailing `None` sentinel.
398 if matches!(e, AppError::Validation(_)) {
399 return Err(e);
400 }
401 tracing::warn!(
402 target: "embedding",
403 backend = ?backend,
404 error = %e,
405 "embed_with_fallback: backend failed, trying next"
406 );
407 last_err = Some(e);
408 }
409 }
410 }
411 if skip_on_failure {
412 // Signal "persist with no embedding" via an empty vector paired
413 // with `None` so callers know the chain exhausted without a hit.
414 // Caller is responsible for writing a `pending_embeddings` row
415 // that can be retried later by the `embedding retry` subcommand.
416 return Ok((Vec::new(), LlmBackendKind::None));
417 }
418 Err(last_err.unwrap_or_else(|| {
419 AppError::Embedding(crate::i18n::validation::embedding_detail(
420 LlmBackendError::NoBackendsAvailable,
421 ))
422 }))
423}