Skip to main content

sqlite_graphrag/embedder/
backend.rs

1//! Backend kind selection and raw embed-via-backend helpers.
2
3use super::*;
4use crate::errors::AppError;
5use std::path::Path;
6
7/// LLM backend kind for the fallback chain. Mirrors the CLI
8/// `--llm-backend` enum so users can pass the same value to
9/// `--llm-fallback` without translation.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum LlmBackendKind {
12    /// `codex exec` (default for v1.0.76+).
13    Codex,
14    /// `claude -p` (fallback for ChatGPT Pro OAuth unavailability).
15    Claude,
16    /// `opencode run` (v1.0.90).
17    Opencode,
18    /// OpenRouter HTTP API (v1.0.93).
19    OpenRouter,
20    /// No embedding — empty vector returned.
21    None,
22}
23
24impl LlmBackendKind {
25    /// Stable string label used in tracing and JSON envelopes. The
26    /// string values are part of the public contract for `envelope.backend_invoked`.
27    pub fn as_str(self) -> &'static str {
28        match self {
29            Self::Codex => "codex",
30            Self::Claude => "claude",
31            Self::Opencode => "opencode",
32            Self::OpenRouter => "openrouter",
33            Self::None => "none",
34        }
35    }
36}
37
38/// Cheap readiness probe before spawning an LLM subprocess.
39///
40/// Checks binary presence on PATH and credential material on disk.
41/// Does **not** perform network I/O. Failures are non-fatal for the
42/// fallback chain — the caller skips to the next backend.
43pub(crate) fn backend_ready_probe(backend: &LlmBackendKind) -> Result<(), AppError> {
44    match backend {
45        LlmBackendKind::None => Ok(()),
46        LlmBackendKind::OpenRouter => {
47            if OPENROUTER_CLIENT.get().is_some() {
48                Ok(())
49            } else {
50                Err(AppError::Embedding(
51                    crate::i18n::validation::embedding_openrouter_probe_not_initialised(),
52                ))
53            }
54        }
55        LlmBackendKind::Codex => {
56            let bin = crate::runtime_config::codex_binary().unwrap_or_else(|| "codex".into());
57            if which::which(&bin).is_err() && which::which("codex").is_err() {
58                return Err(AppError::Embedding(
59                    crate::i18n::validation::embedding_codex_probe_binary_not_on_path(),
60                ));
61            }
62            // OAuth material: ~/.codex/auth.json or CODEX_HOME/auth.json
63            let auth = std::env::var_os("CODEX_HOME")
64                .map(std::path::PathBuf::from)
65                .or_else(|| {
66                    std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".codex"))
67                })
68                .map(|p| p.join("auth.json"));
69            match auth {
70                Some(p) if p.is_file() => Ok(()),
71                _ => Err(AppError::Embedding(
72                    crate::i18n::validation::embedding_codex_probe_auth_missing(),
73                )),
74            }
75        }
76        LlmBackendKind::Claude => {
77            let bin = crate::runtime_config::claude_binary().unwrap_or_else(|| "claude".into());
78            if which::which(&bin).is_err() && which::which("claude").is_err() {
79                return Err(AppError::Embedding(
80                    crate::i18n::validation::embedding_claude_probe_binary_not_on_path(),
81                ));
82            }
83            Ok(())
84        }
85        LlmBackendKind::Opencode => {
86            let bin = crate::runtime_config::opencode_binary().unwrap_or_else(|| "opencode".into());
87            if which::which(&bin).is_err() && which::which("opencode").is_err() {
88                return Err(AppError::Embedding(
89                    crate::i18n::validation::embedding_opencode_probe_binary_not_on_path(),
90                ));
91            }
92            Ok(())
93        }
94    }
95}
96
97/// Embeds a single text via the given backend. Used by
98/// `embed_with_fallback` and exposed to allow direct one-shot
99/// selection without a chain.
100/// Embeds a single text via the given backend. Used by
101/// `embed_with_fallback` and exposed to allow direct one-shot
102/// selection without a chain.
103///
104/// BUG-003 / v1.0.85: returns `(Vec<f32>, LlmBackendKind)`. The
105/// second element reports the backend that ACTUALLY executed the
106/// embedding, not the chain position requested by the caller. When
107/// `LlmBackendKind::Codex` is requested but `codex` is absent from
108/// PATH, `LlmEmbedding::detect_available` substitutes claude and the
109/// tuple carries `LlmBackendKind::Claude` so the operator sees the
110/// truth in `envelope.backend_invoked`.
111pub fn embed_via_backend(
112    models_dir: &Path,
113    text: &str,
114    backend: &LlmBackendKind,
115) -> Result<(Vec<f32>, LlmBackendKind), AppError> {
116    match backend {
117        LlmBackendKind::None => Ok((Vec::new(), LlmBackendKind::None)),
118        LlmBackendKind::Codex => embed_passage_local_resolved(models_dir, text),
119        LlmBackendKind::Claude => {
120            // ADR-0042 / GAP-002: route Claude through its own static
121            // embedder instead of re-using the Codex path (which used
122            // to silently pick Codex if PATH ordered it first).
123            tracing::debug!(
124                target: "embedder",
125                backend = "claude",
126                "embed_via_backend: forcing claude (ADR-0042 / GAP-002 fix)"
127            );
128            embed_via_claude_local_resolved(models_dir, text, None, None)
129        }
130        LlmBackendKind::Opencode => {
131            tracing::debug!(
132                target: "embedder",
133                backend = "opencode",
134                "embed_via_backend: forcing opencode (GAP-OPENCODE-001)"
135            );
136            embed_via_opencode_local_resolved(models_dir, text, None, None)
137        }
138        LlmBackendKind::OpenRouter => {
139            tracing::debug!(
140                target: "embedder",
141                backend = "openrouter",
142                "embed_via_backend: using OpenRouter API (v1.0.93)"
143            );
144            let client = OPENROUTER_CLIENT.get().ok_or_else(|| {
145                AppError::Embedding(
146                    crate::i18n::validation::embedding_openrouter_client_not_initialised(),
147                )
148            })?;
149            // GAP-001 (v1.1.04): canonical nested-runtime guard. When called
150            // from inside an existing tokio runtime (e.g. deep-research fan-out),
151            // `block_in_place` parks the current worker thread and drives the
152            // future via the existing handle instead of building a nested
153            // runtime, which would panic with "Cannot start a runtime from
154            // within a runtime".
155            let vec = match tokio::runtime::Handle::try_current() {
156                Ok(handle) => tokio::task::block_in_place(|| {
157                    handle.block_on(client.embed_single(text, client.default_input_type()))
158                })?,
159                Err(_) => shared_runtime()?
160                    .block_on(client.embed_single(text, client.default_input_type()))?,
161            };
162            Ok((vec, LlmBackendKind::OpenRouter))
163        }
164    }
165}
166
167// ADR-0046 / BUG-11 v1.0.88: specialisation of `embed_via_backend` that
168// refuses to SILENTLY DEGRADE to `LlmBackendKind::None` after all real
169// backends (Codex, Claude) have failed. The previous behaviour
170// (`Ok((Vec::new(), None))`) caused the `remember` write path to persist
171// memories with zero-dimensional embeddings — breaking `recall` and
172// `hybrid-search` while returning exit 0 (BUG-11 CRITICAL).
173//
174// When `--llm-backend none` is explicitly requested (i.e. `last_err` is
175// None AND the chain was a single-element `[None]`), pass
176// `skip_on_failure = true` to `embed_with_fallback` to consume the empty
177// vector via the pending-embeddings retry queue instead of persisting
178// directly. This helper is the right hook for `remember`/`edit`/`ingest`.
179/// Embed via backend strict.
180pub fn embed_via_backend_strict(
181    models_dir: &Path,
182    text: &str,
183    backend: &LlmBackendKind,
184    last_err: Option<&AppError>,
185    skip_on_failure: bool,
186) -> Result<(Vec<f32>, LlmBackendKind), AppError> {
187    use crate::llm::exit_code_hints::LlmBackendError;
188    match backend {
189        LlmBackendKind::None => {
190            // GAP-CLI-EMBED-NONE (v1.1.8): an intentional chain of only
191            // `[None]` (`--llm-backend none`) MUST skip embedding with an
192            // empty vector — matching the CLI help contract "skips embedding;
193            // useful for tests". When `None` is reached *after* a real
194            // backend failed (`last_err.is_some()`), honour
195            // `skip_on_failure` or propagate the prior error (BUG-11).
196            // Intentional none-only chain, or skip-on-failure after a prior error.
197            if last_err.is_none() || skip_on_failure {
198                Ok((Vec::new(), LlmBackendKind::None))
199            } else {
200                Err(match last_err {
201                    Some(e) => AppError::Embedding(crate::i18n::validation::embedding_detail(e)),
202                    None => AppError::Embedding(crate::i18n::validation::embedding_detail(
203                        LlmBackendError::NoBackendsAvailable,
204                    )),
205                })
206            }
207        }
208        LlmBackendKind::Codex => embed_passage_local_resolved(models_dir, text),
209        LlmBackendKind::Claude => {
210            tracing::debug!(
211                target: "embedder",
212                backend = "claude",
213                "embed_via_backend_strict: forcing claude (ADR-0042 / GAP-002 fix)"
214            );
215            embed_via_claude_local_resolved(models_dir, text, None, None)
216        }
217        LlmBackendKind::Opencode => {
218            tracing::debug!(
219                target: "embedder",
220                backend = "opencode",
221                "embed_via_backend_strict: forcing opencode (GAP-OPENCODE-001)"
222            );
223            embed_via_opencode_local_resolved(models_dir, text, None, None)
224        }
225        LlmBackendKind::OpenRouter => embed_via_backend(models_dir, text, backend),
226    }
227}
228
229/// Legacy one-shot wrapper around `embed_via_backend` that discards
230/// the resolved backend. Kept for call sites that only care about
231/// the vector and ignore the executed-backend signal. New code
232/// should prefer `embed_via_backend` directly.
233pub fn embed_via_backend_legacy(
234    models_dir: &Path,
235    text: &str,
236    backend: &LlmBackendKind,
237) -> Result<Vec<f32>, AppError> {
238    embed_via_backend(models_dir, text, backend).map(|(v, _)| v)
239}
240
241/// F 32 to bytes.
242pub fn f32_to_bytes(v: &[f32]) -> Vec<u8> {
243    let mut out = Vec::with_capacity(v.len() * 4);
244    for f in v {
245        out.extend_from_slice(&f.to_le_bytes());
246    }
247    out
248}
249
250/// Bytes to f 32.
251pub fn bytes_to_f32(bytes: &[u8]) -> Vec<f32> {
252    let mut out = Vec::with_capacity(bytes.len() / 4);
253    for chunk in bytes.chunks_exact(4) {
254        out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
255    }
256    out
257}
258
259/// Returns the dimensionality of the embedding space. Used to
260/// validate LLM responses and to size the in-memory cache.
261pub fn embedding_dim() -> usize {
262    crate::constants::embedding_dim()
263}
264
265/// G42/C5: a vector with a divergent dimensionality is an ERROR, never
266/// silently truncated or zero-padded (the pre-v1.0.79 `normalise_dim`
267/// masked malformed LLM responses).
268pub(crate) fn validate_dim(v: Vec<f32>) -> Result<Vec<f32>, AppError> {
269    let dim = crate::constants::embedding_dim();
270    if v.len() != dim {
271        return Err(AppError::Embedding(
272            crate::i18n::validation::embedding_has_dims_expected(v.len(), dim),
273        ));
274    }
275    Ok(v)
276}