Skip to main content

sqlite_graphrag/extract/
llm_embedding.rs

1//! LLM-based embedding backend (v1.0.76 default; reworked in v1.0.79 G42).
2//!
3//! `LlmEmbedding` is the production embedding client. It wraps headless
4//! invocations of `claude code` or `codex` and returns f32 vectors of the
5//! active dimensionality (`crate::constants::embedding_dim()`, default 64).
6//!
7//! v1.0.79 (G42) changes:
8//! - S1: the dimensionality is no longer hardcoded here — the single
9//!   source of truth lives in `crate::constants` and the JSON schemas
10//!   are generated dynamically.
11//! - S2: `embed_batch` embeds N numbered texts per LLM call with the
12//!   `{items:[{i,v}]}` schema, collapsing 39 subprocess spawns into 4-5.
13//! - S4: the codex `--output-schema` file is a `tempfile::NamedTempFile`
14//!   with a randomised name created once per client and shared across
15//!   clones via `Arc` — no per-call write+delete, no PID-path races.
16//! - S5: the claude model honours `SQLITE_GRAPHRAG_CLAUDE_EMBED_MODEL`
17//!   (symmetric to the codex env var). ZERO hardcoded models without
18//!   an env override.
19//! - S6: `CLAUDE_CONFIG_DIR` points at an empty managed directory BY
20//!   DEFAULT, because `--strict-mcp-config`/`--mcp-config '{}'` are
21//!   silently ignored upstream (anthropics/claude-code#10787) and a
22//!   full `~/.claude` costs ~223k cache-creation tokens per call.
23//! - S7: the codex `request_user_input` failure mode maps to an
24//!   actionable error instead of an opaque exit 11.
25//! - BLOCO 4: every subprocess uses `kill_on_drop(true)` plus an
26//!   explicit `tokio::time::timeout`, so cancellation never leaks a
27//!   child and a hung LLM cannot stall the pipeline forever.
28//!
29//! OAuth is the only supported credential path. The constructor rejects
30//! `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` in the environment — see
31//! `v1.0.69 (G31) OAuth-Only Enforcement`.
32
33use crate::errors::AppError;
34use serde::Deserialize;
35use std::process::Stdio;
36use std::sync::Arc;
37use tokio::io::AsyncWriteExt;
38use tokio::process::Command;
39
40/// Default per-LLM-call timeout in seconds. Set to 300 to align with the
41/// ingest, enrich, opencode and llm_backend defaults, which already use a
42/// 300-second per-call budget. Override via `SQLITE_GRAPHRAG_EMBED_TIMEOUT_SECS`.
43const DEFAULT_EMBED_TIMEOUT_SECS: u64 = 300;
44
45fn embed_timeout() -> std::time::Duration {
46    let secs = std::env::var("SQLITE_GRAPHRAG_EMBED_TIMEOUT_SECS")
47        .ok()
48        .and_then(|v| v.parse::<u64>().ok())
49        .filter(|&n| (10..=3_600).contains(&n))
50        .unwrap_or(DEFAULT_EMBED_TIMEOUT_SECS);
51    std::time::Duration::from_secs(secs)
52}
53
54/// v1.0.89 (GAP-4): scales the per-call timeout with batch size.
55/// A single-item batch uses the base timeout (120s default).
56/// Each additional item adds 15s to account for the LLM generating
57/// more embedding vectors in the same call.
58#[cfg(test)]
59fn embed_timeout_for_batch(batch_size: usize) -> std::time::Duration {
60    let base = embed_timeout();
61    let extra = std::time::Duration::from_secs(15) * batch_size.saturating_sub(1) as u32;
62    base + extra
63}
64
65/// Cross-platform helper: extracts `(exit_code, signal)` from an
66/// `ExitStatus` whose `.code()` returned `None`. On Unix this means
67/// the process was killed by a signal; on Windows processes always
68/// have an exit code so this branch returns `(None, None)`.
69fn extract_exit_info(status: &std::process::ExitStatus) -> (Option<i32>, Option<i32>) {
70    #[cfg(unix)]
71    {
72        use std::os::unix::process::ExitStatusExt;
73        (None, status.signal())
74    }
75    #[cfg(not(unix))]
76    {
77        let _ = status;
78        (None, None)
79    }
80}
81
82/// G42/S1: single-vector JSON schema generated from the active dim.
83fn build_single_schema(dim: usize) -> String {
84    format!(
85        r#"{{"type":"object","properties":{{"embedding":{{"type":"array","items":{{"type":"number"}},"minItems":{dim},"maxItems":{dim}}}}},"required":["embedding"],"additionalProperties":false}}"#
86    )
87}
88
89/// G42/S2: batch JSON schema `{items:[{i,v}]}`. The `items` array length
90/// is deliberately unconstrained so ONE schema file serves every batch
91/// size (index coverage is validated in Rust after parsing).
92fn build_batch_schema(dim: usize) -> String {
93    format!(
94        r#"{{"type":"object","properties":{{"items":{{"type":"array","items":{{"type":"object","properties":{{"i":{{"type":"integer"}},"v":{{"type":"array","items":{{"type":"number"}},"minItems":{dim},"maxItems":{dim}}}}},"required":["i","v"],"additionalProperties":false}}}}}},"required":["items"],"additionalProperties":false}}"#
95    )
96}
97
98#[derive(Clone, Debug)]
99pub struct LlmEmbedding {
100    /// Which LLM headless binary to spawn. `claude` or `codex`.
101    flavour: EmbeddingFlavour,
102    /// Cached path to the binary to avoid PATH lookups on every call.
103    binary: std::path::PathBuf,
104    /// Model name. Resolved from env overrides at construction time.
105    model: String,
106    /// G42/S4: lazily-created codex `--output-schema` tempfiles, shared
107    /// across clones. Keyed by dim so an env change between tests cannot
108    /// serve a stale schema.
109    codex_schemas: Arc<parking_lot::Mutex<CodexSchemaFiles>>,
110    /// BUG-TIMEOUT-HARDCODE-001: instance-scoped timeout override.
111    /// Precedence: this field > env var > DEFAULT_EMBED_TIMEOUT_SECS.
112    timeout_override: Option<std::time::Duration>,
113}
114
115#[derive(Debug, Default)]
116struct CodexSchemaFiles {
117    single: Option<(usize, Arc<tempfile::NamedTempFile>)>,
118    batch: Option<(usize, Arc<tempfile::NamedTempFile>)>,
119}
120
121#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
122pub enum EmbeddingFlavour {
123    Claude,
124    Codex,
125    Opencode,
126}
127
128/// ADR-0042 / GAP-002: builder for [`LlmEmbedding`] that lets callers
129/// override the binary path and model without having to remember the
130/// env-var names per flavour. Replaces the duplicated `with_codex` /
131/// `with_claude` bodies that diverged in v1.0.82 (GAP-002: the Claude
132/// arm of `embed_via_backend` re-did the PATH probe via
133/// `LlmEmbedding::detect_available` and could silently pick `codex`).
134#[derive(Clone, Debug)]
135pub struct LlmEmbeddingBuilder {
136    flavour: EmbeddingFlavour,
137    binary_override: Option<std::path::PathBuf>,
138    model_override: Option<String>,
139    timeout_override: Option<std::time::Duration>,
140}
141
142impl LlmEmbeddingBuilder {
143    /// Convenience: produce a Claude-backed builder pre-configured with
144    /// the canonical default binary + model.
145    /// Convenience: produce a Claude-backed builder pre-configured with
146    /// the canonical default binary + model.
147    pub fn claude_default() -> Self {
148        Self {
149            flavour: EmbeddingFlavour::Claude,
150            binary_override: None,
151            model_override: None,
152            timeout_override: None,
153        }
154    }
155
156    /// Convenience: produce a Codex-backed builder pre-configured with
157    /// the canonical default binary + model.
158    pub fn codex_default() -> Self {
159        Self {
160            flavour: EmbeddingFlavour::Codex,
161            binary_override: None,
162            model_override: None,
163            timeout_override: None,
164        }
165    }
166
167    /// Convenience: produce an OpenCode-backed builder pre-configured with
168    /// the canonical default binary + model.
169    pub fn opencode_default() -> Self {
170        Self {
171            flavour: EmbeddingFlavour::Opencode,
172            binary_override: None,
173            model_override: None,
174            timeout_override: None,
175        }
176    }
177    /// Override the binary path (skips the `which::which` PATH probe).
178    pub fn override_binary(mut self, binary: std::path::PathBuf) -> Self {
179        self.binary_override = Some(binary);
180        self
181    }
182
183    /// Override the model name (skips the env-var lookup).
184    pub fn override_model(mut self, model: String) -> Self {
185        self.model_override = Some(model);
186        self
187    }
188
189    /// Override the per-call embedding timeout (skips env-var lookup).
190    pub fn override_timeout(mut self, secs: u64) -> Self {
191        let clamped = secs.clamp(10, 3_600);
192        self.timeout_override = Some(std::time::Duration::from_secs(clamped));
193        self
194    }
195
196    /// Build the [`LlmEmbedding`]. Enforces OAuth-only and resolves the
197    /// binary/model via the override or the env-var defaults.
198    pub fn build(self) -> Result<LlmEmbedding, AppError> {
199        LlmEmbedding::oauth_only_enforce()?;
200        let binary = match self.binary_override {
201            Some(path) => resolve_real_binary(&path),
202            None => {
203                let (env_var, which_name) = match self.flavour {
204                    EmbeddingFlavour::Codex => ("SQLITE_GRAPHRAG_CODEX_BINARY", "codex"),
205                    EmbeddingFlavour::Claude => ("SQLITE_GRAPHRAG_CLAUDE_BINARY", "claude"),
206                    EmbeddingFlavour::Opencode => ("SQLITE_GRAPHRAG_OPENCODE_BINARY", "opencode"),
207                };
208                let path = std::env::var_os(env_var)
209                    .map(std::path::PathBuf::from)
210                    .or_else(|| which::which(which_name).ok())
211                    .ok_or_else(|| {
212                        AppError::Embedding(format!("`{which_name}` not found on PATH"))
213                    })?;
214                resolve_real_binary(&path)
215            }
216        };
217        let model = match self.model_override {
218            Some(m) => m,
219            None => match self.flavour {
220                EmbeddingFlavour::Codex => codex_embed_model(),
221                EmbeddingFlavour::Claude => claude_embed_model(),
222                EmbeddingFlavour::Opencode => opencode_embed_model(),
223            },
224        };
225        Ok(LlmEmbedding {
226            flavour: self.flavour,
227            binary,
228            model,
229            codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
230            timeout_override: self.timeout_override,
231        })
232    }
233}
234
235impl EmbeddingFlavour {
236    pub fn as_str(self) -> &'static str {
237        match self {
238            Self::Claude => "claude",
239            Self::Codex => "codex",
240            Self::Opencode => "opencode",
241        }
242    }
243}
244
245#[derive(Debug, Deserialize)]
246struct EmbeddingResponse {
247    embedding: Vec<f32>,
248}
249
250#[derive(Debug, Deserialize)]
251struct BatchEmbeddingResponse {
252    items: Vec<BatchEmbeddingItem>,
253}
254
255#[derive(Debug, Deserialize)]
256struct BatchEmbeddingItem {
257    i: usize,
258    v: Vec<f32>,
259}
260
261/// Follows symlinks and shell-script shim `exec` targets to find
262/// the real ELF binary. Shim wrappers (like `~/.graphrag-shim/codex`)
263/// can strip hardening flags; bypassing them is a security requirement.
264pub fn resolve_real_binary(path: &std::path::Path) -> std::path::PathBuf {
265    if let Ok(canonical) = std::fs::canonicalize(path) {
266        if is_elf_binary(&canonical) {
267            return canonical;
268        }
269        if let Some(exec_target) = extract_exec_target_from_shim(&canonical) {
270            if exec_target.exists() && is_elf_binary(&exec_target) {
271                return exec_target;
272            }
273        }
274        return canonical;
275    }
276    path.to_path_buf()
277}
278
279fn is_elf_binary(path: &std::path::Path) -> bool {
280    std::fs::read(path)
281        .map(|bytes| bytes.len() >= 4 && bytes[..4] == [0x7f, b'E', b'L', b'F'])
282        .unwrap_or(false)
283}
284
285fn extract_exec_target_from_shim(path: &std::path::Path) -> Option<std::path::PathBuf> {
286    let content = std::fs::read_to_string(path).ok()?;
287    if !content.starts_with("#!") {
288        return None;
289    }
290    for line in content.lines().rev() {
291        let trimmed = line.trim();
292        if trimmed.starts_with("exec ") {
293            let after_exec = trimmed.strip_prefix("exec ")?;
294            let binary = after_exec.split_whitespace().next()?;
295            return Some(std::path::PathBuf::from(binary));
296        }
297    }
298    None
299}
300
301/// G42/S5: claude embedding model with env override, symmetric to the
302/// codex `SQLITE_GRAPHRAG_CODEX_EMBED_MODEL` introduced in v1.0.78.
303fn claude_embed_model() -> String {
304    // Precedence: SQLITE_GRAPHRAG_CLAUDE_EMBED_MODEL > SQLITE_GRAPHRAG_LLM_MODEL > default
305    std::env::var("SQLITE_GRAPHRAG_CLAUDE_EMBED_MODEL")
306        .or_else(|_| std::env::var("SQLITE_GRAPHRAG_LLM_MODEL"))
307        .unwrap_or_else(|_| {
308            tracing::info!(
309                target: "llm_embedding",
310                "no model specified; defaulting to claude-sonnet-4-6"
311            );
312            "claude-sonnet-4-6".to_string()
313        })
314}
315
316fn codex_embed_model() -> String {
317    // Precedence: SQLITE_GRAPHRAG_CODEX_EMBED_MODEL > SQLITE_GRAPHRAG_LLM_MODEL > default
318    std::env::var("SQLITE_GRAPHRAG_CODEX_EMBED_MODEL")
319        .or_else(|_| std::env::var("SQLITE_GRAPHRAG_LLM_MODEL"))
320        .unwrap_or_else(|_| {
321            tracing::info!(
322                target: "llm_embedding",
323                "no model specified; defaulting to gpt-5.5"
324            );
325            "gpt-5.5".to_string()
326        })
327}
328
329fn opencode_embed_model() -> String {
330    // Precedence: SQLITE_GRAPHRAG_OPENCODE_EMBED_MODEL > SQLITE_GRAPHRAG_OPENCODE_MODEL > default
331    // NOTE: intentionally does NOT fall back to SQLITE_GRAPHRAG_LLM_MODEL because that
332    // var typically holds a codex/claude model name (e.g. "gpt-5.4-mini") that opencode
333    // does not recognise — cross-contamination caused ProviderModelNotFoundError (v1.0.90 audit).
334    std::env::var("SQLITE_GRAPHRAG_OPENCODE_EMBED_MODEL")
335        .or_else(|_| std::env::var("SQLITE_GRAPHRAG_OPENCODE_MODEL"))
336        .unwrap_or_else(|_| {
337            tracing::info!(
338                target: "llm_embedding",
339                "no model specified; defaulting to opencode/big-pickle"
340            );
341            "opencode/big-pickle".to_string()
342        })
343}
344
345impl LlmEmbedding {
346    /// Detects which LLM CLI is available on PATH and returns the
347    /// matching embedding client.
348    ///
349    /// v1.0.76: PREFERS `codex` over `claude` because:
350    /// - Claude Code 2.1+ ships a 180k+ token system context (plugins,
351    ///   skills, agents, MCP) that overflows the 200k context window
352    ///   for even trivial embedding prompts and returns "Prompt is too
353    ///   long". (v1.0.79/S6 mitigates this with an empty
354    ///   `CLAUDE_CONFIG_DIR`, but codex stays the lighter default.)
355    /// - Codex 0.134+ is lightweight (~5k system context) and the
356    ///   `StructuredOutput` tool reliably returns the requested vectors.
357    pub fn detect_available() -> Result<Self, AppError> {
358        Self::oauth_only_enforce()?;
359
360        // v1.0.89 (GAP-1): honour SQLITE_GRAPHRAG_CODEX_BINARY for the
361        // embedding pipeline, symmetric with SQLITE_GRAPHRAG_CLAUDE_BINARY.
362        let codex_path = std::env::var_os("SQLITE_GRAPHRAG_CODEX_BINARY")
363            .map(std::path::PathBuf::from)
364            .or_else(|| which::which("codex").ok());
365        if let Some(path) = codex_path {
366            return Ok(Self {
367                flavour: EmbeddingFlavour::Codex,
368                binary: resolve_real_binary(&path),
369                model: codex_embed_model(),
370                codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
371                timeout_override: None,
372            });
373        }
374        // v1.0.89: honour SQLITE_GRAPHRAG_CLAUDE_BINARY for the embedding
375        // pipeline, not just ingest/enrich. This lets operators override the
376        // symlink-resolved path (e.g. a stale multi-instance binary).
377        let claude_path = std::env::var_os("SQLITE_GRAPHRAG_CLAUDE_BINARY")
378            .map(std::path::PathBuf::from)
379            .or_else(|| which::which("claude").ok());
380        if let Some(path) = claude_path {
381            return Ok(Self {
382                flavour: EmbeddingFlavour::Claude,
383                binary: resolve_real_binary(&path),
384                model: claude_embed_model(),
385                codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
386                timeout_override: None,
387            });
388        }
389        // v1.0.90 (GAP-OPENCODE-001): probe opencode as 3rd priority.
390        let opencode_path = std::env::var_os("SQLITE_GRAPHRAG_OPENCODE_BINARY")
391            .map(std::path::PathBuf::from)
392            .or_else(|| which::which("opencode").ok());
393        if let Some(path) = opencode_path {
394            return Ok(Self {
395                flavour: EmbeddingFlavour::Opencode,
396                binary: resolve_real_binary(&path),
397                model: opencode_embed_model(),
398                codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
399                timeout_override: None,
400            });
401        }
402        Err(AppError::Embedding(
403            "no LLM CLI found on PATH: install `codex` (0.130+), `claude` (Claude Code 2.1+), or `opencode` (1.17+)"
404                .to_string(),
405        ))
406    }
407
408    /// Instance-scoped timeout. Precedence:
409    /// `timeout_override` field > env var > DEFAULT_EMBED_TIMEOUT_SECS.
410    fn instance_embed_timeout(&self) -> std::time::Duration {
411        if let Some(d) = self.timeout_override {
412            return d;
413        }
414        embed_timeout()
415    }
416
417    /// Instance-scoped batch timeout: base + 15s per extra item.
418    fn instance_embed_timeout_for_batch(&self, batch_size: usize) -> std::time::Duration {
419        let base = self.instance_embed_timeout();
420        let extra = std::time::Duration::from_secs(15) * batch_size.saturating_sub(1) as u32;
421        base + extra
422    }
423
424    pub fn with_codex() -> Result<Self, AppError> {
425        Self::with_codex_builder().build()
426    }
427
428    pub fn with_claude() -> Result<Self, AppError> {
429        Self::with_claude_builder().build()
430    }
431
432    /// ADR-0042 / GAP-002: builder entry point for a codex-backed
433    /// embedder with default model resolution.
434    pub fn with_codex_builder() -> LlmEmbeddingBuilder {
435        LlmEmbeddingBuilder {
436            flavour: EmbeddingFlavour::Codex,
437            binary_override: None,
438            model_override: None,
439            timeout_override: None,
440        }
441    }
442
443    /// ADR-0042 / GAP-002: builder entry point for a claude-backed
444    /// embedder with default model resolution.
445    pub fn with_claude_builder() -> LlmEmbeddingBuilder {
446        LlmEmbeddingBuilder {
447            flavour: EmbeddingFlavour::Claude,
448            binary_override: None,
449            model_override: None,
450            timeout_override: None,
451        }
452    }
453
454    pub fn with_opencode() -> Result<Self, AppError> {
455        Self::with_opencode_builder().build()
456    }
457
458    pub fn with_opencode_builder() -> LlmEmbeddingBuilder {
459        LlmEmbeddingBuilder {
460            flavour: EmbeddingFlavour::Opencode,
461            binary_override: None,
462            model_override: None,
463            timeout_override: None,
464        }
465    }
466    /// v1.0.69 (G31): refuse to spawn if an API key is set. The CLI
467    /// must use OAuth. The two API-key env vars are NOT in the
468    /// env-clear whitelist, so a parent process that exports them
469    /// will see this error.
470    fn oauth_only_enforce() -> Result<(), AppError> {
471        if std::env::var("ANTHROPIC_API_KEY").is_ok() {
472            return Err(AppError::Validation(
473                "ANTHROPIC_API_KEY is set; v1.0.76 requires OAuth. \
474                 unset it and use `claude login` instead."
475                    .into(),
476            ));
477        }
478        if std::env::var("OPENAI_API_KEY").is_ok() {
479            return Err(AppError::Validation(
480                "OPENAI_API_KEY is set; v1.0.76 requires OAuth. \
481                 unset it and use `codex login` instead."
482                    .into(),
483            ));
484        }
485        Ok(())
486    }
487
488    /// Embeds a single passage (chunk of a memory body). Returns an
489    /// f32 vector of the active dimensionality.
490    pub fn embed_passage(&self, text: &str) -> Result<Vec<f32>, AppError> {
491        self.invoke_with_prefix(crate::constants::PASSAGE_PREFIX, text)
492    }
493
494    /// Embeds a single query. The LLM uses a different prompt prefix
495    /// to disambiguate query from passage.
496    pub fn embed_query(&self, text: &str) -> Result<Vec<f32>, AppError> {
497        self.invoke_with_prefix(crate::constants::QUERY_PREFIX, text)
498    }
499
500    /// G56: returns a stable label for the active embedding model so the
501    /// in-process entity-embedding cache can key by `(model, text)`.
502    /// Embeddings produced by different models are not interchangeable,
503    /// so a cache entry from one model must never satisfy a request
504    /// served by another.
505    pub fn model_label(&self) -> String {
506        format!("{}:{}", self.flavour.as_str(), self.model)
507    }
508
509    /// ADR-0042 / BUG-003 fix: returns the resolved []
510    /// of this embedder. Used by  and
511    ///  to report the backend that
512    /// ACTUALLY executed the embedding (not the one requested in the
513    /// chain). When  substitutes claude
514    /// for a missing codex, the operator sees the truth in
515    /// .
516    pub fn flavour(&self) -> EmbeddingFlavour {
517        self.flavour
518    }
519
520    /// G42/S2: embeds a batch of `(global_index, text)` pairs in ONE
521    /// LLM call. Returns `(global_index, vector)` pairs. Async — this
522    /// is the unit of work scheduled by the bounded fan-out in
523    /// `crate::embedder`.
524    ///
525    /// Cancel safety: the future owns its subprocess via
526    /// `kill_on_drop(true)`, so dropping it (e.g. losing a
527    /// `tokio::select!` race against a cancellation token) kills the
528    /// child and leaks nothing.
529    pub async fn embed_batch_async(
530        &self,
531        prefix: &str,
532        batch: &[(usize, String)],
533    ) -> Result<Vec<(usize, Vec<f32>)>, AppError> {
534        let dim = crate::constants::embedding_dim();
535        if batch.is_empty() {
536            return Ok(Vec::new());
537        }
538        if batch.len() == 1 {
539            let (idx, text) = (&batch[0].0, &batch[0].1);
540            let v = self.invoke_single_async(prefix, text, dim).await?;
541            return Ok(vec![(*idx, v)]);
542        }
543
544        let mut prompt = format!(
545            "Generate {dim}-dimensional semantic embedding vectors for each numbered text below.\n\
546             Return a JSON object with an \"items\" array containing EXACTLY {n} items.\n\
547             Each item has \"i\" (the 1-based index) and \"v\" (the {dim}-float vector, values between -1 and 1).\n\n",
548            n = batch.len()
549        );
550        for (pos, (_, text)) in batch.iter().enumerate() {
551            prompt.push_str(&format!("{}: {prefix}{text}\n", pos + 1));
552        }
553
554        // BUG-TIMEOUT-HARDCODE-001: batch timeout is now instance-scoped
555        // (no more std::env::set_var which was unsafe in multi-thread).
556        let _batch_timeout = self.instance_embed_timeout_for_batch(batch.len());
557        let stdout = match self.flavour {
558            EmbeddingFlavour::Claude => {
559                self.invoke_claude(&prompt, &build_batch_schema(dim))
560                    .await?
561            }
562            EmbeddingFlavour::Codex => {
563                let schema = self.codex_schema_file(dim, true)?;
564                self.invoke_codex(&prompt, schema.path()).await?
565            }
566            EmbeddingFlavour::Opencode => {
567                let opencode_prompt = format!(
568                    "You are a batch embedding function. For each numbered text item below, \
569                     generate an array of exactly {dim} floating-point numbers between -1 and 1 \
570                     representing its semantic meaning. Output ONLY a JSON object with key \"items\" \
571                     containing an array of objects, each with \"i\" (the 1-based index) and \
572                     \"v\" (the {dim}-element float array). No markdown, no explanation.\n\n\
573                     {prompt}"
574                );
575                self.invoke_opencode(&opencode_prompt).await?
576            }
577        };
578        let parsed: BatchEmbeddingResponse = parse_llm_json(&stdout).map_err(|e| {
579            AppError::Embedding(format!(
580                "LLM batch embedding response parse failed: {e}; raw={stdout}"
581            ))
582        })?;
583        if parsed.items.len() != batch.len() {
584            return Err(AppError::Embedding(format!(
585                "LLM batch returned {} items, expected {} (G42/S2 coverage check)",
586                parsed.items.len(),
587                batch.len()
588            )));
589        }
590        let mut out: Vec<Option<Vec<f32>>> = vec![None; batch.len()];
591        for item in parsed.items {
592            if item.i == 0 || item.i > batch.len() {
593                return Err(AppError::Embedding(format!(
594                    "LLM batch item index {} out of range 1..={}",
595                    item.i,
596                    batch.len()
597                )));
598            }
599            if item.v.len() != dim {
600                return Err(AppError::Embedding(format!(
601                    "LLM batch item {} returned {} dims, expected {dim}; \
602                     refusing to truncate or pad silently (G42/C5)",
603                    item.i,
604                    item.v.len()
605                )));
606            }
607            out[item.i - 1] = Some(item.v);
608        }
609        let mut result = Vec::with_capacity(batch.len());
610        for (pos, slot) in out.into_iter().enumerate() {
611            let v = slot.ok_or_else(|| {
612                AppError::Embedding(format!(
613                    "LLM batch response is missing item index {} (G42/S2 coverage check)",
614                    pos + 1
615                ))
616            })?;
617            result.push((batch[pos].0, v));
618        }
619        Ok(result)
620    }
621
622    fn invoke_with_prefix(&self, prefix: &str, text: &str) -> Result<Vec<f32>, AppError> {
623        let dim = crate::constants::embedding_dim();
624        let inner = self.invoke_single_async(prefix, text, dim);
625        // v1.0.79 (G42/A2): reuse the process-wide multi-thread runtime
626        // instead of building a current-thread runtime PER CALL. Inside
627        // an existing runtime (tests, async commands) block_in_place
628        // keeps the worker pool healthy.
629        match tokio::runtime::Handle::try_current() {
630            Ok(handle) => tokio::task::block_in_place(|| handle.block_on(inner)),
631            Err(_) => crate::embedder::shared_runtime()?.block_on(inner),
632        }
633    }
634
635    async fn invoke_single_async(
636        &self,
637        prefix: &str,
638        text: &str,
639        dim: usize,
640    ) -> Result<Vec<f32>, AppError> {
641        let prompt = format!("{prefix}{text}");
642        let stdout = match self.flavour {
643            EmbeddingFlavour::Claude => {
644                self.invoke_claude(&prompt, &build_single_schema(dim))
645                    .await?
646            }
647            EmbeddingFlavour::Codex => {
648                let schema = self.codex_schema_file(dim, false)?;
649                self.invoke_codex(&prompt, schema.path()).await?
650            }
651            EmbeddingFlavour::Opencode => {
652                let opencode_prompt = format!(
653                    "You are an embedding function. Given the input text, output a JSON object \
654                     with a single key \"embedding\" containing an array of exactly {dim} \
655                     floating-point numbers between -1 and 1 that represent the semantic meaning \
656                     of the text. Output ONLY the JSON object, nothing else.\n\n\
657                     Input text: \"{prompt}\""
658                );
659                self.invoke_opencode(&opencode_prompt).await?
660            }
661        };
662        let parsed: EmbeddingResponse = parse_llm_json(&stdout).map_err(|e| {
663            AppError::Embedding(format!(
664                "LLM embedding response parse failed: {e}; raw={stdout}"
665            ))
666        })?;
667        if parsed.embedding.len() != dim {
668            return Err(AppError::Embedding(format!(
669                "LLM returned {} dims, expected {dim}; \
670                 refusing to truncate or pad silently (G42/C5)",
671                parsed.embedding.len()
672            )));
673        }
674        Ok(parsed.embedding)
675    }
676
677    /// G42/S4: returns the lazily-created, process-shared codex schema
678    /// tempfile for the requested mode. `NamedTempFile` randomises the
679    /// filename (no PID-based collisions) and removes the file on drop
680    /// of the last `Arc` clone.
681    fn codex_schema_file(
682        &self,
683        dim: usize,
684        batch: bool,
685    ) -> Result<Arc<tempfile::NamedTempFile>, AppError> {
686        let mut guard = self.codex_schemas.lock();
687        let slot = if batch {
688            &mut guard.batch
689        } else {
690            &mut guard.single
691        };
692        if let Some((cached_dim, file)) = slot {
693            if *cached_dim == dim {
694                return Ok(Arc::clone(file));
695            }
696        }
697        let content = if batch {
698            build_batch_schema(dim)
699        } else {
700            build_single_schema(dim)
701        };
702        let file = tempfile::Builder::new()
703            .prefix("sqlite-graphrag-embed-schema-")
704            .suffix(".json")
705            .tempfile()
706            .map_err(|e| AppError::Embedding(format!("schema tempfile create failed: {e}")))?;
707        std::fs::write(file.path(), content)
708            .map_err(|e| AppError::Embedding(format!("schema tempfile write failed: {e}")))?;
709        let file = Arc::new(file);
710        *slot = Some((dim, Arc::clone(&file)));
711        Ok(file)
712    }
713
714    async fn invoke_claude(&self, prompt: &str, schema: &str) -> Result<String, AppError> {
715        // v1.0.69 hardening: --strict-mcp-config --mcp-config <PATH> --settings
716        // '{"hooks":{}}' --dangerously-skip-permissions.
717        //
718        // v1.0.76 hardening: Claude Code 2.1+ renamed --output-schema to
719        // --json-schema and accepts the schema as an inline JSON string
720        // (NOT a file path). Also pass --output-format json so the
721        // response is a single JSON object on stdout.
722        //
723        // v1.0.79 (G42/S6): CLAUDE_CONFIG_DIR points at an empty managed
724        // directory BY DEFAULT — the MCP-isolation flags above are
725        // silently ignored upstream (anthropics/claude-code#10787) and a
726        // populated ~/.claude costs ~223k cache-creation tokens per call.
727        //
728        // v1.0.88 (BUG-2 fix, ADR-0046): the inline `--mcp-config '{}'`
729        // form was rejected by Claude Code 2.1.177 (ADR-0045 Bug 2).
730        // Substitute a tempfile path produced by
731        // `write_empty_mcp_config_tempfile()` and run the full
732        // preflight gate BEFORE `Command::spawn()`, mirroring what
733        // `invoke_codex` already does for the codex backend.
734        let spawn_dir = crate::spawn::spawn_isolation_dir()?;
735        let mcp_config_path = crate::spawn::preflight::write_empty_mcp_config_tempfile()?;
736        let argv_refs: [std::ffi::OsString; 0] = [];
737        let preflight_args = crate::spawn::preflight::PreFlightArgs {
738            binary_path: &self.binary,
739            argv: &argv_refs,
740            workspace_root: &spawn_dir,
741            mcp_config_inline_json: None,
742            expected_output_bytes: 65_536,
743            spawner_name: "llm_embedding",
744        };
745        crate::spawn::preflight::preflight_check(&preflight_args)?;
746        let mut cmd = Command::new(&self.binary);
747        cmd.arg("-p")
748            .arg(prompt)
749            .arg("--model")
750            .arg(&self.model)
751            .arg("--json-schema")
752            .arg(schema)
753            .arg("--output-format")
754            .arg("json")
755            .arg("--strict-mcp-config")
756            .arg("--mcp-config")
757            .arg(mcp_config_path.as_os_str())
758            .arg("--settings")
759            .arg(r#"{"hooks":{}}"#)
760            .arg("--dangerously-skip-permissions")
761            .env_clear()
762            .env("PATH", std::env::var("PATH").unwrap_or_default())
763            .env("HOME", std::env::var("HOME").unwrap_or_default())
764            .stdin(Stdio::null())
765            .stdout(Stdio::piped())
766            .stderr(Stdio::piped())
767            // BLOCO 4: cancellation (dropped future) must kill the child.
768            .kill_on_drop(true);
769        // GAP-SPAWN-001: isolate CWD so child never inherits .mcp.json
770        cmd.current_dir(&spawn_dir);
771        cmd.env("CLAUDE_CONFIG_DIR", &spawn_dir);
772        if let Some(config_dir) = claude_embedding_config_dir() {
773            cmd.env("CLAUDE_CONFIG_DIR", &config_dir);
774        }
775        let binary_str = self.binary.to_string_lossy().into_owned();
776        let output = match tokio::time::timeout(self.instance_embed_timeout(), cmd.output()).await {
777            Err(_elapsed) => {
778                return Err(crate::llm::exit_code_hints::into_legacy_embedding(
779                    &crate::llm::exit_code_hints::LlmBackendError::Timeout {
780                        secs: self.instance_embed_timeout().as_secs(),
781                        binary: binary_str.clone(),
782                    },
783                ));
784            }
785            Ok(Err(e)) => {
786                return Err(crate::llm::exit_code_hints::into_legacy_embedding(
787                    &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
788                        binary: binary_str.clone(),
789                        source: e.to_string(),
790                    },
791                ));
792            }
793            Ok(Ok(o)) => o,
794        };
795        // G45-CR5 / ADR-0043 (v1.0.85): parse the JSON envelope from
796        // `claude -p --output-format json` and detect OAuth quota
797        // exhaustion by looking for the `rate_limit_error` or
798        // `usage` overflow markers before checking the subprocess
799        // exit status. This lets the deterministic fallback in
800        // hybrid-search and recall swap to codex immediately.
801        let stdout_str = String::from_utf8_lossy(&output.stdout);
802        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stdout_str) {
803            let is_rate_limited = parsed
804                .get("is_error")
805                .and_then(|v| v.as_bool())
806                .unwrap_or(false)
807                && parsed
808                    .get("result")
809                    .and_then(|v| v.as_str())
810                    .map(|s| {
811                        s.contains("rate limit")
812                            || s.contains("quota")
813                            || s.contains("anthropic-ratelimit")
814                    })
815                    .unwrap_or(false);
816            if is_rate_limited {
817                return Err(AppError::Embedding(format!(
818                    "OAuth usage quota exhausted: claude rate_limit detected in stdout: {}",
819                    parsed
820                        .get("result")
821                        .and_then(|v| v.as_str())
822                        .unwrap_or("")
823                        .chars()
824                        .take(120)
825                        .collect::<String>()
826                )));
827            }
828        }
829        if !output.status.success() {
830            let (exit_code, signal) = if let Some(code) = output.status.code() {
831                (Some(code), None)
832            } else {
833                extract_exit_info(&output.status)
834            };
835            let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
836                &output.stdout,
837                crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
838            );
839            let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
840                &output.stderr,
841                crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
842            );
843            let mut hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
844            // v1.0.89 (GAP-5): detect expired OAuth and suggest actionable fix.
845            if stderr_tail.contains("401")
846                || stderr_tail.contains("Unauthorized")
847                || stderr_tail.contains("expired")
848                || stderr_tail.contains("login")
849                || stdout_tail.contains("401")
850                || stdout_tail.contains("Unauthorized")
851            {
852                hint.push_str(" | Claude OAuth token may be expired; run `claude login` to renew");
853            }
854            return Err(crate::llm::exit_code_hints::into_legacy_embedding(
855                &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
856                    exit_code,
857                    signal,
858                    stdout_tail,
859                    stderr_tail,
860                    binary: binary_str,
861                    hint,
862                },
863            ));
864        }
865        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
866    }
867
868    async fn invoke_codex(
869        &self,
870        prompt: &str,
871        schema_path: &std::path::Path,
872    ) -> Result<String, AppError> {
873        let binary_str = self.binary.to_string_lossy().into_owned();
874        let mut cmd = build_codex_embedding_command(&self.binary, &self.model, schema_path)?;
875
876        // GAP-META-005 (v1.0.87, ADR-0045): pre-flight gate before spawn.
877        // `tokio::process::Command` does not expose `get_args()`, so we
878        // skip the argv-size check here and rely on binary + workspace
879        // root + output buffer guards. Embedding prompts are bounded by
880        // the schema validator so argv overflow is not a real risk here.
881        //
882        // v1.0.88 (BUG-7 fix, ADR-0046): propagate the preflight error
883        // directly via `AppError::PreFlightFailed` (via the `From`
884        // impl added in `errors.rs`) so callers and operators see the
885        // structured `PreFlightError` variant and the canonical exit
886        // code 16. The previous implementation wrapped the error in
887        // `LlmBackendError::SpawnFailed`, which mapped to a different
888        // exit code and masked the preflight signal.
889        let argv_refs: [std::ffi::OsString; 0] = [];
890        let preflight_args = crate::spawn::preflight::PreFlightArgs {
891            binary_path: &self.binary,
892            argv: &argv_refs,
893            workspace_root: std::path::Path::new("."),
894            mcp_config_inline_json: None,
895            expected_output_bytes: 65_536,
896            spawner_name: "llm_embedding",
897        };
898        crate::spawn::preflight::preflight_check(&preflight_args)?;
899        let _ = binary_str; // silenced: preflight does not need it
900
901        let mut child = match cmd.spawn() {
902            Ok(c) => c,
903            Err(e) => {
904                return Err(crate::llm::exit_code_hints::into_legacy_embedding(
905                    &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
906                        binary: binary_str,
907                        source: e.to_string(),
908                    },
909                ));
910            }
911        };
912        if let Some(mut stdin) = child.stdin.take() {
913            stdin
914                .write_all(prompt.as_bytes())
915                .await
916                .map_err(|e| AppError::Embedding(format!("codex stdin write failed: {e}")))?;
917            drop(stdin);
918        }
919        let output =
920            match tokio::time::timeout(self.instance_embed_timeout(), child.wait_with_output())
921                .await
922            {
923                Err(_elapsed) => {
924                    return Err(crate::llm::exit_code_hints::into_legacy_embedding(
925                        &crate::llm::exit_code_hints::LlmBackendError::Timeout {
926                            secs: self.instance_embed_timeout().as_secs(),
927                            binary: binary_str,
928                        },
929                    ));
930                }
931                Ok(Err(e)) => {
932                    return Err(crate::llm::exit_code_hints::into_legacy_embedding(
933                        &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
934                            binary: binary_str,
935                            source: format!("codex wait failed: {e}"),
936                        },
937                    ));
938                }
939                Ok(Ok(o)) => o,
940            };
941        if !output.status.success() {
942            let (exit_code, signal) = if let Some(code) = output.status.code() {
943                (Some(code), None)
944            } else {
945                extract_exit_info(&output.status)
946            };
947            let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
948                &output.stdout,
949                crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
950            );
951            let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
952                &output.stderr,
953                crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
954            );
955            let hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
956            // G42/S7: the headless spawn can still hit interactive
957            // prompts on some codex builds; keep the legacy request_user_input
958            // branch as a special-case hint, and stamp the diagnostic
959            // tail on top of the canonical NonZeroExit envelope.
960            let mut combined_hint = hint;
961            if stderr_tail.contains("request_user_input") {
962                combined_hint.push_str(
963                    " | codex requested interactive input in a headless embedding call; \
964                     upgrade codex (>= 0.134) or switch the embedding backend to claude",
965                );
966            }
967            return Err(crate::llm::exit_code_hints::into_legacy_embedding(
968                &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
969                    exit_code,
970                    signal,
971                    stdout_tail,
972                    stderr_tail,
973                    binary: binary_str,
974                    hint: combined_hint,
975                },
976            ));
977        }
978        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
979    }
980
981    async fn invoke_opencode(&self, prompt: &str) -> Result<String, AppError> {
982        let binary_str = self.binary.to_string_lossy().into_owned();
983        let spawn_dir = crate::spawn::spawn_isolation_dir()?;
984        let mut cmd = Command::new(&self.binary);
985        cmd.current_dir(&spawn_dir);
986        cmd.arg("run")
987            .arg("--format")
988            .arg("json")
989            .arg("-m")
990            .arg(&self.model)
991            .arg("--dangerously-skip-permissions")
992            .arg(prompt)
993            .env_clear()
994            .env("PATH", std::env::var("PATH").unwrap_or_default())
995            .env("HOME", std::env::var("HOME").unwrap_or_default())
996            .stdin(Stdio::null())
997            .stdout(Stdio::piped())
998            .stderr(Stdio::piped())
999            .kill_on_drop(true);
1000        crate::commands::opencode_runner::propagate_opencode_env(&mut cmd);
1001
1002        let output = match tokio::time::timeout(self.instance_embed_timeout(), cmd.output()).await {
1003            Err(_elapsed) => {
1004                return Err(crate::llm::exit_code_hints::into_legacy_embedding(
1005                    &crate::llm::exit_code_hints::LlmBackendError::Timeout {
1006                        secs: self.instance_embed_timeout().as_secs(),
1007                        binary: binary_str.clone(),
1008                    },
1009                ));
1010            }
1011            Ok(Err(e)) => {
1012                return Err(crate::llm::exit_code_hints::into_legacy_embedding(
1013                    &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
1014                        binary: binary_str.clone(),
1015                        source: e.to_string(),
1016                    },
1017                ));
1018            }
1019            Ok(Ok(o)) => o,
1020        };
1021        if !output.status.success() {
1022            let (exit_code, signal) = if let Some(code) = output.status.code() {
1023                (Some(code), None)
1024            } else {
1025                extract_exit_info(&output.status)
1026            };
1027            let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
1028                &output.stdout,
1029                crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
1030            );
1031            let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
1032                &output.stderr,
1033                crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
1034            );
1035            let hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
1036            return Err(crate::llm::exit_code_hints::into_legacy_embedding(
1037                &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
1038                    exit_code,
1039                    signal,
1040                    stdout_tail,
1041                    stderr_tail,
1042                    binary: binary_str,
1043                    hint,
1044                },
1045            ));
1046        }
1047        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1048    }
1049}
1050
1051/// G42/S6: resolves the empty `CLAUDE_CONFIG_DIR` used for embedding
1052/// subprocesses.
1053///
1054/// - `SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR` is honoured when set and
1055///   pointing at a directory (same contract as G28-A in claude_runner);
1056/// - otherwise a managed directory is created at
1057///   `~/.local/state/sqlite-graphrag/claude-empty-config` (mode 0700).
1058///   If `~/.claude/.credentials.json` exists (Linux OAuth storage) it is
1059///   copied in so authentication still works; on macOS credentials live
1060///   in the Keychain and the empty dir is sufficient.
1061///
1062/// Returns `None` only when HOME is unset AND no override is given —
1063/// in that case the subprocess falls back to claude's own default.
1064fn claude_embedding_config_dir() -> Option<std::path::PathBuf> {
1065    if let Ok(dir) = std::env::var("SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR") {
1066        let path = std::path::PathBuf::from(dir);
1067        if path.is_dir() {
1068            return Some(path);
1069        }
1070        tracing::warn!(
1071            target: "embedding",
1072            path = %path.display(),
1073            "SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR is set but not a directory; \
1074             falling back to the managed empty config dir"
1075        );
1076    }
1077    let home = std::env::var("HOME").ok()?;
1078    let dir = std::path::Path::new(&home)
1079        .join(".local/state/sqlite-graphrag")
1080        .join("claude-empty-config");
1081    if std::fs::create_dir_all(&dir).is_err() {
1082        return None;
1083    }
1084    #[cfg(unix)]
1085    {
1086        use std::os::unix::fs::PermissionsExt;
1087        let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
1088    }
1089    // Linux stores OAuth credentials on disk; copy them so the isolated
1090    // config dir still authenticates. Best-effort: macOS uses Keychain.
1091    // v1.0.89: ALWAYS copy (was: skip if target exists). OAuth tokens
1092    // expire and the stale copy causes 401 until manually deleted.
1093    let creds = std::path::Path::new(&home).join(".claude/.credentials.json");
1094    if creds.exists() {
1095        let target = dir.join(".credentials.json");
1096        let _ = std::fs::copy(&creds, &target);
1097    }
1098    Some(dir)
1099}
1100
1101fn build_codex_embedding_command(
1102    binary: &std::path::Path,
1103    model: &str,
1104    schema_path: &std::path::Path,
1105) -> Result<Command, AppError> {
1106    let spawn_dir = crate::spawn::spawn_isolation_dir()?;
1107    let mut cmd = Command::new(binary);
1108    cmd.current_dir(&spawn_dir);
1109    cmd.arg("exec")
1110        .arg("-c")
1111        .arg("sandbox_mode='read-only'")
1112        .arg("-c")
1113        .arg("approval_policy='never'")
1114        .arg("--json")
1115        .arg("--output-schema")
1116        .arg(schema_path)
1117        .arg("--ephemeral")
1118        .arg("--skip-git-repo-check")
1119        .arg("--sandbox")
1120        .arg("read-only")
1121        .arg("--ignore-user-config")
1122        .arg("--ignore-rules");
1123    if crate::extract::codex_compat::codex_supports_ask_for_approval() {
1124        cmd.arg("--ask-for-approval").arg("never");
1125    }
1126    // v1.0.89: use the real CODEX_HOME (~/.codex) instead of an isolated
1127    // per-PID directory. The isolated dir caused cold-start overhead (codex
1128    // creates ~6 SQLite databases on first run) that regularly exceeded
1129    // the 30s embedding timeout. The --ignore-user-config + --ephemeral
1130    // flags already prevent config pollution; CODEX_HOME only needs auth.
1131    cmd.arg("--model")
1132        .arg(model)
1133        .arg("-")
1134        .env_clear()
1135        .env("PATH", std::env::var("PATH").unwrap_or_default())
1136        .env("HOME", std::env::var("HOME").unwrap_or_default());
1137    if let Ok(codex_home) = std::env::var("CODEX_HOME") {
1138        cmd.env("CODEX_HOME", codex_home);
1139    } else if let Ok(home) = std::env::var("HOME") {
1140        let default_home = std::path::Path::new(&home).join(".codex");
1141        if default_home.exists() {
1142            cmd.env("CODEX_HOME", &default_home);
1143        }
1144    }
1145    cmd.stdin(Stdio::piped())
1146        .stdout(Stdio::piped())
1147        .stderr(Stdio::piped())
1148        // BLOCO 4: cancellation (dropped future) must kill the child.
1149        .kill_on_drop(true);
1150    Ok(cmd)
1151}
1152
1153// prepare_isolated_codex_home removed in v1.0.89: the per-PID isolated
1154// CODEX_HOME caused cold-start overhead that exceeded the 30s embedding
1155// timeout. The real ~/.codex is now used directly (see build_codex_embedding_command).
1156
1157/// Parse an LLM JSON response of type `T`. The two backends emit
1158/// different shapes:
1159/// - Claude (with `--output-format json`): single JSON object on stdout.
1160/// - Codex (with `--json`): JSONL stream with one event per line; the
1161///   `agent_message` event's `text` field is the JSON payload.
1162///
1163/// This helper accepts both shapes and returns the parsed value (or an
1164/// error describing the first mismatch).
1165fn parse_llm_json<T: serde::de::DeserializeOwned>(stdout: &str) -> Result<T, String> {
1166    // Strategy 1: try the whole stdout as JSON (Claude path).
1167    if let Ok(parsed) = serde_json::from_str::<T>(stdout) {
1168        return Ok(parsed);
1169    }
1170    // Strategy 3: walk NDJSON and collect `.part.text` from `type == "text"`
1171    // events (OpenCode path: `opencode run --format json`).
1172    let mut opencode_texts: Vec<String> = Vec::new();
1173    for line in stdout.lines() {
1174        let line = line.trim();
1175        if line.is_empty() {
1176            continue;
1177        }
1178        let Ok(event) = serde_json::from_str::<serde_json::Value>(line) else {
1179            continue;
1180        };
1181        if event.get("type").and_then(|t| t.as_str()) == Some("text") {
1182            if let Some(text) = event
1183                .get("part")
1184                .and_then(|p| p.get("text"))
1185                .and_then(|t| t.as_str())
1186            {
1187                opencode_texts.push(text.to_string());
1188            }
1189        }
1190    }
1191    if !opencode_texts.is_empty() {
1192        let combined = opencode_texts.concat();
1193        if let Ok(parsed) = serde_json::from_str::<T>(&combined) {
1194            return Ok(parsed);
1195        }
1196    }
1197    // Strategy 2: walk the JSONL line by line and pick the last
1198    // `item.completed` of type `agent_message` (Codex path).
1199    let mut last_agent_text: Option<String> = None;
1200    for line in stdout.lines() {
1201        let line = line.trim();
1202        if line.is_empty() {
1203            continue;
1204        }
1205        let Ok(event) = serde_json::from_str::<serde_json::Value>(line) else {
1206            continue;
1207        };
1208        if event.get("type").and_then(|t| t.as_str()) != Some("item.completed") {
1209            continue;
1210        }
1211        let item = match event.get("item") {
1212            Some(i) => i,
1213            None => continue,
1214        };
1215        if item.get("type").and_then(|t| t.as_str()) != Some("agent_message") {
1216            continue;
1217        }
1218        if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
1219            last_agent_text = Some(text.to_string());
1220        }
1221    }
1222    let text = last_agent_text
1223        .ok_or_else(|| "no agent_message found in codex JSONL output".to_string())?;
1224    serde_json::from_str::<T>(&text)
1225        .map_err(|e| format!("codex agent_message text does not match schema: {e}; raw={text}"))
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230    use super::*;
1231
1232    fn test_client(flavour: EmbeddingFlavour, binary: std::path::PathBuf) -> LlmEmbedding {
1233        LlmEmbedding {
1234            flavour,
1235            binary,
1236            model: "gpt-5.4".to_string(),
1237            codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
1238            timeout_override: None,
1239        }
1240    }
1241
1242    #[test]
1243    fn embed_timeout_default_is_300() {
1244        assert_eq!(DEFAULT_EMBED_TIMEOUT_SECS, 300);
1245    }
1246
1247    #[test]
1248    #[serial_test::serial(env)]
1249    fn oauth_only_enforce_blocks_api_keys() {
1250        // SAFETY: this test only sets and unsets env vars; the
1251        // `serial(env)` group prevents cross-test interference.
1252        unsafe {
1253            std::env::set_var("ANTHROPIC_API_KEY", "test");
1254            assert!(LlmEmbedding::oauth_only_enforce().is_err());
1255            std::env::remove_var("ANTHROPIC_API_KEY");
1256
1257            std::env::set_var("OPENAI_API_KEY", "test");
1258            assert!(LlmEmbedding::oauth_only_enforce().is_err());
1259            std::env::remove_var("OPENAI_API_KEY");
1260        }
1261        assert!(LlmEmbedding::oauth_only_enforce().is_ok());
1262    }
1263
1264    #[test]
1265    fn flavour_as_str_is_stable() {
1266        assert_eq!(EmbeddingFlavour::Claude.as_str(), "claude");
1267        assert_eq!(EmbeddingFlavour::Codex.as_str(), "codex");
1268    }
1269
1270    #[test]
1271    fn single_schema_embeds_active_dim() {
1272        let schema = build_single_schema(64);
1273        assert!(schema.contains(r#""minItems":64"#));
1274        assert!(schema.contains(r#""maxItems":64"#));
1275        let parsed: serde_json::Value =
1276            serde_json::from_str(&schema).expect("single schema must be valid JSON");
1277        assert_eq!(parsed["properties"]["embedding"]["minItems"], 64);
1278    }
1279
1280    #[test]
1281    fn batch_schema_is_valid_json_and_unbounded_items() {
1282        let schema = build_batch_schema(64);
1283        let parsed: serde_json::Value =
1284            serde_json::from_str(&schema).expect("batch schema must be valid JSON");
1285        // The items array must NOT constrain its length so one schema
1286        // file serves every batch size (G42/S4).
1287        assert!(parsed["properties"]["items"].get("minItems").is_none());
1288        assert_eq!(
1289            parsed["properties"]["items"]["items"]["properties"]["v"]["minItems"],
1290            64
1291        );
1292    }
1293
1294    #[test]
1295    fn parse_llm_json_accepts_claude_json() {
1296        let stdout = r#"{"embedding":[0.0,1.0,2.0]}"#;
1297
1298        let parsed: EmbeddingResponse = parse_llm_json(stdout).expect("claude JSON must parse");
1299
1300        assert_eq!(parsed.embedding, vec![0.0, 1.0, 2.0]);
1301    }
1302
1303    #[test]
1304    fn parse_llm_json_accepts_codex_jsonl() {
1305        let stdout = r#"{"type":"thread.started","thread_id":"mock-thread-0"}
1306{"type":"item.completed","item":{"type":"agent_message","text":"{\"embedding\":[0.0,1.0,2.0]}"}}
1307{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}"#;
1308
1309        let parsed: EmbeddingResponse = parse_llm_json(stdout).expect("codex JSONL must parse");
1310
1311        assert_eq!(parsed.embedding, vec![0.0, 1.0, 2.0]);
1312    }
1313
1314    #[test]
1315    fn parse_llm_json_rejects_jsonl_without_agent_message() {
1316        let stdout = r#"{"type":"thread.started","thread_id":"mock-thread-0"}"#;
1317
1318        let err = parse_llm_json::<EmbeddingResponse>(stdout)
1319            .expect_err("missing agent_message must fail");
1320
1321        assert!(err.contains("no agent_message"));
1322    }
1323
1324    #[test]
1325    fn parse_llm_json_accepts_batch_response() {
1326        let stdout = r#"{"items":[{"i":1,"v":[0.0,1.0]},{"i":2,"v":[2.0,3.0]}]}"#;
1327
1328        let parsed: BatchEmbeddingResponse = parse_llm_json(stdout).expect("batch JSON must parse");
1329
1330        assert_eq!(parsed.items.len(), 2);
1331        assert_eq!(parsed.items[0].i, 1);
1332        assert_eq!(parsed.items[1].v, vec![2.0, 3.0]);
1333    }
1334
1335    #[test]
1336    fn codex_schema_file_is_created_once_and_reused() {
1337        let client = test_client(
1338            EmbeddingFlavour::Codex,
1339            std::path::PathBuf::from("/bin/true"),
1340        );
1341        let first = client
1342            .codex_schema_file(64, false)
1343            .expect("schema file must be created");
1344        let second = client
1345            .codex_schema_file(64, false)
1346            .expect("schema file must be reused");
1347        assert_eq!(first.path(), second.path(), "same dim must reuse the file");
1348
1349        let batch = client
1350            .codex_schema_file(64, true)
1351            .expect("batch schema file must be created");
1352        assert_ne!(
1353            first.path(),
1354            batch.path(),
1355            "single and batch schemas are distinct files"
1356        );
1357
1358        let content = std::fs::read_to_string(first.path()).expect("schema file must be readable");
1359        assert!(content.contains(r#""minItems":64"#));
1360    }
1361
1362    #[test]
1363    fn codex_embedding_command_reads_prompt_from_stdin() {
1364        let schema_path = std::env::temp_dir().join("sqlite-graphrag-embed-schema-test.json");
1365        let cmd = build_codex_embedding_command(
1366            std::path::Path::new("/bin/true"),
1367            "gpt-5.4",
1368            &schema_path,
1369        )
1370        .expect("build_codex_embedding_command must succeed in test");
1371        let argv: Vec<String> = cmd
1372            .as_std()
1373            .get_args()
1374            .filter_map(|arg| arg.to_str().map(|s| s.to_string()))
1375            .collect();
1376
1377        assert!(
1378            argv.iter().any(|arg| arg == "-"),
1379            "codex embedding command must read prompt from stdin: {argv:?}"
1380        );
1381        assert!(
1382            !argv.iter().any(|arg| arg.starts_with("passage: ")),
1383            "prompt text must not be passed as argv: {argv:?}"
1384        );
1385        for required in &[
1386            "exec",
1387            "-c",
1388            "sandbox_mode='read-only'",
1389            "approval_policy='never'",
1390            "--json",
1391            "--output-schema",
1392            "--ephemeral",
1393            "--skip-git-repo-check",
1394            "--sandbox",
1395            "read-only",
1396            "--ignore-user-config",
1397            "--ignore-rules",
1398            "--model",
1399            "gpt-5.4",
1400        ] {
1401            assert!(
1402                argv.iter().any(|arg| arg == required),
1403                "missing flag {required} in {argv:?}"
1404            );
1405        }
1406    }
1407
1408    #[cfg(unix)]
1409    #[test]
1410    #[serial_test::serial(env)]
1411    fn embed_passage_sends_prompt_to_codex_stdin() {
1412        use std::os::unix::fs::PermissionsExt;
1413
1414        // Pin the dimensionality so the mock script and the validation
1415        // agree regardless of test execution order.
1416        // SAFETY: guarded by serial(env).
1417        unsafe {
1418            std::env::set_var("SQLITE_GRAPHRAG_EMBEDDING_DIM", "64");
1419        }
1420
1421        let temp = tempfile::tempdir().expect("tempdir must exist");
1422        let binary = temp.path().join("codex-stdin-check");
1423        let script = r#"#!/usr/bin/env bash
1424set -euo pipefail
1425
1426prompt="$(cat)"
1427if [[ "$prompt" != "passage: codex-cli" ]]; then
1428  echo "unexpected stdin: $prompt" >&2
1429  exit 41
1430fi
1431
1432vals="0.0"
1433for _ in $(seq 2 64); do
1434  vals="$vals,0.0"
1435done
1436payload="{\"embedding\":[$vals]}"
1437escaped="${payload//\"/\\\"}"
1438echo "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"$escaped\"}}"
1439"#;
1440        std::fs::write(&binary, script).expect("mock codex script must be written");
1441        let mut perms = std::fs::metadata(&binary)
1442            .expect("mock codex metadata must exist")
1443            .permissions();
1444        perms.set_mode(0o755);
1445        std::fs::set_permissions(&binary, perms).expect("mock codex must be executable");
1446
1447        let embedding = test_client(EmbeddingFlavour::Codex, binary);
1448
1449        let vector = embedding
1450            .embed_passage("codex-cli")
1451            .expect("stdin-backed codex embedding must succeed");
1452
1453        // SAFETY: guarded by serial(env).
1454        unsafe {
1455            std::env::remove_var("SQLITE_GRAPHRAG_EMBEDDING_DIM");
1456        }
1457
1458        assert_eq!(vector.len(), 64);
1459        assert!(vector.iter().all(|value| *value == 0.0));
1460    }
1461
1462    // ---------------------------------------------------------------
1463    // ADR-0042 / GAP-002: LlmEmbeddingBuilder unit tests
1464    // ---------------------------------------------------------------
1465
1466    /// `claude_default` is the `with_claude_builder` alias: returns a
1467    /// builder pre-set to the Claude flavour. Build requires the
1468    /// Claude binary to be on PATH; in CI without `claude`, the build
1469    /// fails with the canonical `claude not found` error, which is
1470    /// itself the proof that the flavour is propagated correctly.
1471    #[test]
1472    fn claude_default_resolves_path() {
1473        let builder = LlmEmbeddingBuilder::claude_default();
1474        assert_eq!(builder.flavour, EmbeddingFlavour::Claude);
1475        assert!(builder.binary_override.is_none());
1476        assert!(builder.model_override.is_none());
1477    }
1478
1479    /// `override_binary` short-circuits the PATH probe. The builder
1480    /// stores the override verbatim so the `build()` call can fall
1481    /// back to `resolve_real_binary` for ELF canonicalisation.
1482    #[test]
1483    fn override_binary_uses_provided() {
1484        let path = std::path::PathBuf::from("/tmp/fake-claude-binary");
1485        let builder = LlmEmbeddingBuilder::claude_default().override_binary(path.clone());
1486        assert_eq!(builder.binary_override.as_ref(), Some(&path));
1487    }
1488
1489    /// `override_model` short-circuits the env-var lookup. The model
1490    /// override travels untouched through `build()` so the LLM
1491    /// subprocess spawn honours it.
1492    #[test]
1493    fn override_model_uses_provided() {
1494        let builder =
1495            LlmEmbeddingBuilder::codex_default().override_model("gpt-5.4-custom".to_string());
1496        assert_eq!(builder.model_override.as_deref(), Some("gpt-5.4-custom"));
1497    }
1498
1499    // ---------------------------------------------------------------
1500    // v1.0.89 GAP tests
1501    // ---------------------------------------------------------------
1502
1503    #[test]
1504    fn embed_timeout_for_batch_scales_with_size() {
1505        let t1 = embed_timeout_for_batch(1);
1506        let t4 = embed_timeout_for_batch(4);
1507        let t8 = embed_timeout_for_batch(8);
1508        assert!(
1509            t1 < t4,
1510            "batch of 4 must have longer timeout than batch of 1"
1511        );
1512        assert!(
1513            t4 < t8,
1514            "batch of 8 must have longer timeout than batch of 4"
1515        );
1516        assert_eq!(t8 - t1, std::time::Duration::from_secs(15 * 7));
1517    }
1518
1519    #[test]
1520    fn embed_timeout_for_batch_single_equals_base() {
1521        let base = embed_timeout();
1522        let single = embed_timeout_for_batch(1);
1523        assert_eq!(base, single);
1524    }
1525
1526    #[test]
1527    fn opencode_flavour_as_str() {
1528        assert_eq!(EmbeddingFlavour::Opencode.as_str(), "opencode");
1529    }
1530
1531    #[test]
1532    #[serial_test::serial(env)]
1533    fn opencode_embed_model_uses_env_override() {
1534        unsafe {
1535            std::env::set_var(
1536                "SQLITE_GRAPHRAG_OPENCODE_EMBED_MODEL",
1537                "opencode/test-model",
1538            );
1539            let model = opencode_embed_model();
1540            std::env::remove_var("SQLITE_GRAPHRAG_OPENCODE_EMBED_MODEL");
1541            assert_eq!(model, "opencode/test-model");
1542        }
1543    }
1544
1545    #[test]
1546    #[serial_test::serial(env)]
1547    fn opencode_embed_model_falls_back_to_opencode_model() {
1548        unsafe {
1549            std::env::remove_var("SQLITE_GRAPHRAG_OPENCODE_EMBED_MODEL");
1550            std::env::set_var("SQLITE_GRAPHRAG_OPENCODE_MODEL", "opencode/fallback");
1551            let model = opencode_embed_model();
1552            std::env::remove_var("SQLITE_GRAPHRAG_OPENCODE_MODEL");
1553            assert_eq!(model, "opencode/fallback");
1554        }
1555    }
1556
1557    #[test]
1558    #[serial_test::serial(env)]
1559    fn opencode_embed_model_ignores_llm_model() {
1560        unsafe {
1561            std::env::remove_var("SQLITE_GRAPHRAG_OPENCODE_EMBED_MODEL");
1562            std::env::remove_var("SQLITE_GRAPHRAG_OPENCODE_MODEL");
1563            std::env::set_var("SQLITE_GRAPHRAG_LLM_MODEL", "gpt-5.4-mini");
1564            let model = opencode_embed_model();
1565            std::env::remove_var("SQLITE_GRAPHRAG_LLM_MODEL");
1566            assert_eq!(
1567                model, "opencode/big-pickle",
1568                "must NOT cross-contaminate with LLM_MODEL"
1569            );
1570        }
1571    }
1572
1573    #[test]
1574    fn parse_llm_json_accepts_opencode_ndjson() {
1575        let stdout = r#"{"type":"step_start","timestamp":1234,"sessionID":"ses_test","part":{"type":"step-start"}}
1576{"type":"text","timestamp":1235,"sessionID":"ses_test","part":{"type":"text","text":"{\"embedding\":[0.1,0.2,0.3]}"}}
1577{"type":"step_finish","timestamp":1236,"sessionID":"ses_test","part":{"type":"step-finish","tokens":{"total":100,"input":90,"output":10,"reasoning":0},"cost":0}}"#;
1578
1579        let parsed: EmbeddingResponse = parse_llm_json(stdout).expect("opencode NDJSON must parse");
1580        assert_eq!(parsed.embedding, vec![0.1, 0.2, 0.3]);
1581    }
1582
1583    #[test]
1584    fn parse_llm_json_accepts_opencode_batch_ndjson() {
1585        let stdout = r#"{"type":"step_start","timestamp":1234,"sessionID":"ses_test","part":{"type":"step-start"}}
1586{"type":"text","timestamp":1235,"sessionID":"ses_test","part":{"type":"text","text":"{\"items\":[{\"i\":1,\"v\":[0.1,0.2]},{\"i\":2,\"v\":[0.3,0.4]}]}"}}
1587{"type":"step_finish","timestamp":1236,"sessionID":"ses_test","part":{"type":"step-finish","tokens":{"total":100,"input":90,"output":10,"reasoning":0},"cost":0}}"#;
1588
1589        let parsed: BatchEmbeddingResponse =
1590            parse_llm_json(stdout).expect("opencode batch NDJSON must parse");
1591        assert_eq!(parsed.items.len(), 2);
1592        assert_eq!(parsed.items[0].i, 1);
1593        assert_eq!(parsed.items[1].v, vec![0.3, 0.4]);
1594    }
1595
1596    #[test]
1597    fn opencode_builder_default_has_correct_flavour() {
1598        let builder = LlmEmbeddingBuilder::opencode_default();
1599        assert_eq!(builder.flavour, EmbeddingFlavour::Opencode);
1600        assert!(builder.binary_override.is_none());
1601        assert!(builder.model_override.is_none());
1602    }
1603}