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