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    /// 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}