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}