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 /// OpenRouter HTTP API (v1.0.93).
13 OpenRouter,
14 /// No embedding — empty vector returned.
15 None,
16}
17
18impl LlmBackendKind {
19 /// Stable string label used in tracing and JSON envelopes. The
20 /// string values are part of the public contract for `envelope.backend_invoked`.
21 pub fn as_str(self) -> &'static str {
22 match self {
23 Self::OpenRouter => "openrouter",
24 Self::None => "none",
25 }
26 }
27}
28
29/// Cheap readiness probe before spawning an LLM subprocess.
30///
31/// Checks binary presence on PATH and credential material on disk.
32/// Does **not** perform network I/O. Failures are non-fatal for the
33/// fallback chain — the caller skips to the next backend.
34pub(crate) fn backend_ready_probe(backend: &LlmBackendKind) -> Result<(), AppError> {
35 match backend {
36 LlmBackendKind::None => Ok(()),
37 LlmBackendKind::OpenRouter => {
38 if OPENROUTER_CLIENT.get().is_some() {
39 Ok(())
40 } else {
41 Err(AppError::Embedding(
42 crate::i18n::validation::embedding_openrouter_probe_not_initialised(),
43 ))
44 }
45 }
46 }
47}
48
49/// Embeds a single text via the given backend. Used by
50/// `embed_with_fallback` and exposed to allow direct one-shot
51/// selection without a chain.
52/// Embeds a single text via the given backend. Used by
53/// `embed_with_fallback` and exposed to allow direct one-shot
54/// selection without a chain.
55///
56/// BUG-003 / v1.0.85: returns `(Vec<f32>, LlmBackendKind)`. The
57/// second element reports the backend that ACTUALLY executed the
58/// embedding, not the chain position requested by the caller, so
59/// `envelope.backend_invoked` shows the operator the truth.
60///
61/// The tuple mattered more when the chain could substitute one
62/// subprocess backend for another; v1.2.0 reduced [`LlmBackendKind`]
63/// to `OpenRouter` and `None`, so the two now only differ when
64/// OpenRouter is unreachable and the caller falls through to `None`.
65pub fn embed_via_backend(
66 _models_dir: &Path,
67 text: &str,
68 backend: &LlmBackendKind,
69) -> Result<(Vec<f32>, LlmBackendKind), AppError> {
70 match backend {
71 LlmBackendKind::None => Ok((Vec::new(), LlmBackendKind::None)),
72 LlmBackendKind::OpenRouter => {
73 tracing::debug!(
74 target: "embedder",
75 backend = "openrouter",
76 "embed_via_backend: using OpenRouter API (v1.0.93)"
77 );
78 let client = OPENROUTER_CLIENT.get().ok_or_else(|| {
79 AppError::Embedding(
80 crate::i18n::validation::embedding_openrouter_client_not_initialised(),
81 )
82 })?;
83 // GAP-001 (v1.1.04): canonical nested-runtime guard. When called
84 // from inside an existing tokio runtime (e.g. deep-research fan-out),
85 // `block_in_place` parks the current worker thread and drives the
86 // future via the existing handle instead of building a nested
87 // runtime, which would panic with "Cannot start a runtime from
88 // within a runtime".
89 // GAP-SG-270: `?` alone would go through `From<EmbedError> for
90 // AppError`, which drops the origin-computed retry verdict and
91 // makes a permanent failure look transient to the enrich queue.
92 let vec = match tokio::runtime::Handle::try_current() {
93 Ok(handle) => tokio::task::block_in_place(|| {
94 handle.block_on(client.embed_single(text, client.default_input_type()))
95 })
96 .map_err(super::embed_error::app_error_preserving_retry_class)?,
97 Err(_) => shared_runtime()?
98 .block_on(client.embed_single(text, client.default_input_type()))
99 .map_err(super::embed_error::app_error_preserving_retry_class)?,
100 };
101 Ok((vec, LlmBackendKind::OpenRouter))
102 }
103 }
104}
105
106// ADR-0046 / BUG-11 v1.0.88: specialisation of `embed_via_backend` that
107// refuses to SILENTLY DEGRADE to `LlmBackendKind::None` after all real
108// backends (Codex, Claude) have failed. The previous behaviour
109// (`Ok((Vec::new(), None))`) caused the `remember` write path to persist
110// memories with zero-dimensional embeddings — breaking `recall` and
111// `hybrid-search` while returning exit 0 (BUG-11 CRITICAL).
112//
113// When `--llm-backend none` is explicitly requested (i.e. `last_err` is
114// None AND the chain was a single-element `[None]`), pass
115// `skip_on_failure = true` to `embed_with_fallback` to consume the empty
116// vector via the pending-embeddings retry queue instead of persisting
117// directly. This helper is the right hook for `remember`/`edit`/`ingest`.
118/// Embed via backend strict.
119pub fn embed_via_backend_strict(
120 models_dir: &Path,
121 text: &str,
122 backend: &LlmBackendKind,
123 last_err: Option<&AppError>,
124 skip_on_failure: bool,
125) -> Result<(Vec<f32>, LlmBackendKind), AppError> {
126 use crate::llm::exit_code_hints::LlmBackendError;
127 match backend {
128 LlmBackendKind::None => {
129 // GAP-CLI-EMBED-NONE (v1.1.8): an intentional chain of only
130 // `[None]` (`--llm-backend none`) MUST skip embedding with an
131 // empty vector — matching the CLI help contract "skips embedding;
132 // useful for tests". When `None` is reached *after* a real
133 // backend failed (`last_err.is_some()`), honour
134 // `skip_on_failure` or propagate the prior error (BUG-11).
135 // Intentional none-only chain, or skip-on-failure after a prior error.
136 if last_err.is_none() || skip_on_failure {
137 Ok((Vec::new(), LlmBackendKind::None))
138 } else {
139 Err(match last_err {
140 // GAP-SG-270: restating the last backend error as a detail
141 // string must not throw its retry verdict away one step
142 // after the conversion preserved it.
143 Some(e) => super::embed_error::embedding_error_with_class_of(
144 crate::i18n::validation::embedding_detail(e),
145 e,
146 ),
147 None => AppError::Embedding(crate::i18n::validation::embedding_detail(
148 LlmBackendError::NoBackendsAvailable,
149 )),
150 })
151 }
152 }
153 LlmBackendKind::OpenRouter => embed_via_backend(models_dir, text, backend),
154 }
155}
156
157/// Legacy one-shot wrapper around `embed_via_backend` that discards
158/// the resolved backend. Kept for call sites that only care about
159/// the vector and ignore the executed-backend signal. New code
160/// should prefer `embed_via_backend` directly.
161pub fn embed_via_backend_legacy(
162 models_dir: &Path,
163 text: &str,
164 backend: &LlmBackendKind,
165) -> Result<Vec<f32>, AppError> {
166 embed_via_backend(models_dir, text, backend).map(|(v, _)| v)
167}
168
169/// F 32 to bytes.
170pub fn f32_to_bytes(v: &[f32]) -> Vec<u8> {
171 let mut out = Vec::with_capacity(v.len() * 4);
172 for f in v {
173 out.extend_from_slice(&f.to_le_bytes());
174 }
175 out
176}
177
178/// Bytes to f 32.
179pub fn bytes_to_f32(bytes: &[u8]) -> Vec<f32> {
180 let mut out = Vec::with_capacity(bytes.len() / 4);
181 for chunk in bytes.chunks_exact(4) {
182 out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
183 }
184 out
185}
186
187/// Returns the dimensionality of the embedding space. Used to
188/// validate LLM responses and to size the in-memory cache.
189pub fn embedding_dim() -> usize {
190 crate::constants::embedding_dim()
191}