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