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