1use crate::errors::AppError;
34use serde::Deserialize;
35use std::process::Stdio;
36use std::sync::Arc;
37use tokio::io::AsyncWriteExt;
38use tokio::process::Command;
39
40const DEFAULT_EMBED_TIMEOUT_SECS: u64 = 60;
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
54fn embed_timeout_for_batch(batch_size: usize) -> std::time::Duration {
59 let base = embed_timeout();
60 let extra = std::time::Duration::from_secs(15) * batch_size.saturating_sub(1) as u32;
61 base + extra
62}
63
64fn build_single_schema(dim: usize) -> String {
66 format!(
67 r#"{{"type":"object","properties":{{"embedding":{{"type":"array","items":{{"type":"number"}},"minItems":{dim},"maxItems":{dim}}}}},"required":["embedding"],"additionalProperties":false}}"#
68 )
69}
70
71fn build_batch_schema(dim: usize) -> String {
75 format!(
76 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}}"#
77 )
78}
79
80#[derive(Clone, Debug)]
81pub struct LlmEmbedding {
82 flavour: EmbeddingFlavour,
84 binary: std::path::PathBuf,
86 model: String,
88 codex_schemas: Arc<parking_lot::Mutex<CodexSchemaFiles>>,
92}
93
94#[derive(Debug, Default)]
95struct CodexSchemaFiles {
96 single: Option<(usize, Arc<tempfile::NamedTempFile>)>,
97 batch: Option<(usize, Arc<tempfile::NamedTempFile>)>,
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
101pub enum EmbeddingFlavour {
102 Claude,
103 Codex,
104}
105
106#[derive(Clone, Debug)]
113pub struct LlmEmbeddingBuilder {
114 flavour: EmbeddingFlavour,
115 binary_override: Option<std::path::PathBuf>,
116 model_override: Option<String>,
117}
118
119impl LlmEmbeddingBuilder {
120 pub fn claude_default() -> Self {
125 Self {
126 flavour: EmbeddingFlavour::Claude,
127 binary_override: None,
128 model_override: None,
129 }
130 }
131
132 pub fn codex_default() -> Self {
135 Self {
136 flavour: EmbeddingFlavour::Codex,
137 binary_override: None,
138 model_override: None,
139 }
140 }
141 pub fn override_binary(mut self, binary: std::path::PathBuf) -> Self {
143 self.binary_override = Some(binary);
144 self
145 }
146
147 pub fn override_model(mut self, model: String) -> Self {
149 self.model_override = Some(model);
150 self
151 }
152
153 pub fn build(self) -> Result<LlmEmbedding, AppError> {
156 LlmEmbedding::oauth_only_enforce()?;
157 let binary = match self.binary_override {
158 Some(path) => resolve_real_binary(&path),
159 None => {
160 let (env_var, which_name) = match self.flavour {
161 EmbeddingFlavour::Codex => ("SQLITE_GRAPHRAG_CODEX_BINARY", "codex"),
162 EmbeddingFlavour::Claude => ("SQLITE_GRAPHRAG_CLAUDE_BINARY", "claude"),
163 };
164 let path = std::env::var_os(env_var)
165 .map(std::path::PathBuf::from)
166 .or_else(|| which::which(which_name).ok())
167 .ok_or_else(|| {
168 AppError::Embedding(format!("`{which_name}` not found on PATH"))
169 })?;
170 resolve_real_binary(&path)
171 }
172 };
173 let model = match self.model_override {
174 Some(m) => m,
175 None => match self.flavour {
176 EmbeddingFlavour::Codex => codex_embed_model(),
177 EmbeddingFlavour::Claude => claude_embed_model(),
178 },
179 };
180 Ok(LlmEmbedding {
181 flavour: self.flavour,
182 binary,
183 model,
184 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
185 })
186 }
187}
188
189impl EmbeddingFlavour {
190 pub fn as_str(self) -> &'static str {
191 match self {
192 Self::Claude => "claude",
193 Self::Codex => "codex",
194 }
195 }
196}
197
198#[derive(Debug, Deserialize)]
199struct EmbeddingResponse {
200 embedding: Vec<f32>,
201}
202
203#[derive(Debug, Deserialize)]
204struct BatchEmbeddingResponse {
205 items: Vec<BatchEmbeddingItem>,
206}
207
208#[derive(Debug, Deserialize)]
209struct BatchEmbeddingItem {
210 i: usize,
211 v: Vec<f32>,
212}
213
214pub fn resolve_real_binary(path: &std::path::Path) -> std::path::PathBuf {
218 if let Ok(canonical) = std::fs::canonicalize(path) {
219 if is_elf_binary(&canonical) {
220 return canonical;
221 }
222 if let Some(exec_target) = extract_exec_target_from_shim(&canonical) {
223 if exec_target.exists() && is_elf_binary(&exec_target) {
224 return exec_target;
225 }
226 }
227 return canonical;
228 }
229 path.to_path_buf()
230}
231
232fn is_elf_binary(path: &std::path::Path) -> bool {
233 std::fs::read(path)
234 .map(|bytes| bytes.len() >= 4 && bytes[..4] == [0x7f, b'E', b'L', b'F'])
235 .unwrap_or(false)
236}
237
238fn extract_exec_target_from_shim(path: &std::path::Path) -> Option<std::path::PathBuf> {
239 let content = std::fs::read_to_string(path).ok()?;
240 if !content.starts_with("#!") {
241 return None;
242 }
243 for line in content.lines().rev() {
244 let trimmed = line.trim();
245 if trimmed.starts_with("exec ") {
246 let after_exec = trimmed.strip_prefix("exec ")?;
247 let binary = after_exec.split_whitespace().next()?;
248 return Some(std::path::PathBuf::from(binary));
249 }
250 }
251 None
252}
253
254fn claude_embed_model() -> String {
257 std::env::var("SQLITE_GRAPHRAG_CLAUDE_EMBED_MODEL")
259 .or_else(|_| std::env::var("SQLITE_GRAPHRAG_LLM_MODEL"))
260 .unwrap_or_else(|_| {
261 tracing::info!(
262 target: "llm_embedding",
263 "no model specified; defaulting to claude-sonnet-4-6"
264 );
265 "claude-sonnet-4-6".to_string()
266 })
267}
268
269fn codex_embed_model() -> String {
270 std::env::var("SQLITE_GRAPHRAG_CODEX_EMBED_MODEL")
272 .or_else(|_| std::env::var("SQLITE_GRAPHRAG_LLM_MODEL"))
273 .unwrap_or_else(|_| {
274 tracing::info!(
275 target: "llm_embedding",
276 "no model specified; defaulting to gpt-5.5"
277 );
278 "gpt-5.5".to_string()
279 })
280}
281
282impl LlmEmbedding {
283 pub fn detect_available() -> Result<Self, AppError> {
295 Self::oauth_only_enforce()?;
296
297 let codex_path = std::env::var_os("SQLITE_GRAPHRAG_CODEX_BINARY")
300 .map(std::path::PathBuf::from)
301 .or_else(|| which::which("codex").ok());
302 if let Some(path) = codex_path {
303 return Ok(Self {
304 flavour: EmbeddingFlavour::Codex,
305 binary: resolve_real_binary(&path),
306 model: codex_embed_model(),
307 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
308 });
309 }
310 let claude_path = std::env::var_os("SQLITE_GRAPHRAG_CLAUDE_BINARY")
314 .map(std::path::PathBuf::from)
315 .or_else(|| which::which("claude").ok());
316 if let Some(path) = claude_path {
317 return Ok(Self {
318 flavour: EmbeddingFlavour::Claude,
319 binary: resolve_real_binary(&path),
320 model: claude_embed_model(),
321 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
322 });
323 }
324 Err(AppError::Embedding(
325 "no LLM CLI found on PATH: install `codex` (0.130+) or `claude` (Claude Code 2.1+)"
326 .to_string(),
327 ))
328 }
329
330 pub fn with_codex() -> Result<Self, AppError> {
331 Self::with_codex_builder().build()
332 }
333
334 pub fn with_claude() -> Result<Self, AppError> {
335 Self::with_claude_builder().build()
336 }
337
338 pub fn with_codex_builder() -> LlmEmbeddingBuilder {
341 LlmEmbeddingBuilder {
342 flavour: EmbeddingFlavour::Codex,
343 binary_override: None,
344 model_override: None,
345 }
346 }
347
348 pub fn with_claude_builder() -> LlmEmbeddingBuilder {
351 LlmEmbeddingBuilder {
352 flavour: EmbeddingFlavour::Claude,
353 binary_override: None,
354 model_override: None,
355 }
356 }
357 fn oauth_only_enforce() -> Result<(), AppError> {
362 if std::env::var("ANTHROPIC_API_KEY").is_ok() {
363 return Err(AppError::Validation(
364 "ANTHROPIC_API_KEY is set; v1.0.76 requires OAuth. \
365 unset it and use `claude login` instead."
366 .into(),
367 ));
368 }
369 if std::env::var("OPENAI_API_KEY").is_ok() {
370 return Err(AppError::Validation(
371 "OPENAI_API_KEY is set; v1.0.76 requires OAuth. \
372 unset it and use `codex login` instead."
373 .into(),
374 ));
375 }
376 Ok(())
377 }
378
379 pub fn embed_passage(&self, text: &str) -> Result<Vec<f32>, AppError> {
382 self.invoke_with_prefix(crate::constants::PASSAGE_PREFIX, text)
383 }
384
385 pub fn embed_query(&self, text: &str) -> Result<Vec<f32>, AppError> {
388 self.invoke_with_prefix(crate::constants::QUERY_PREFIX, text)
389 }
390
391 pub fn model_label(&self) -> String {
397 format!("{}:{}", self.flavour.as_str(), self.model)
398 }
399
400 pub fn flavour(&self) -> EmbeddingFlavour {
408 self.flavour
409 }
410
411 pub async fn embed_batch_async(
421 &self,
422 prefix: &str,
423 batch: &[(usize, String)],
424 ) -> Result<Vec<(usize, Vec<f32>)>, AppError> {
425 let dim = crate::constants::embedding_dim();
426 if batch.is_empty() {
427 return Ok(Vec::new());
428 }
429 if batch.len() == 1 {
430 let (idx, text) = (&batch[0].0, &batch[0].1);
431 let v = self.invoke_single_async(prefix, text, dim).await?;
432 return Ok(vec![(*idx, v)]);
433 }
434
435 let mut prompt = format!(
436 "Generate {dim}-dimensional semantic embedding vectors for each numbered text below.\n\
437 Return a JSON object with an \"items\" array containing EXACTLY {n} items.\n\
438 Each item has \"i\" (the 1-based index) and \"v\" (the {dim}-float vector, values between -1 and 1).\n\n",
439 n = batch.len()
440 );
441 for (pos, (_, text)) in batch.iter().enumerate() {
442 prompt.push_str(&format!("{}: {prefix}{text}\n", pos + 1));
443 }
444
445 let batch_timeout = embed_timeout_for_batch(batch.len());
449 let prev_timeout = std::env::var("SQLITE_GRAPHRAG_EMBED_TIMEOUT_SECS").ok();
450 std::env::set_var(
451 "SQLITE_GRAPHRAG_EMBED_TIMEOUT_SECS",
452 batch_timeout.as_secs().to_string(),
453 );
454 let stdout = match self.flavour {
455 EmbeddingFlavour::Claude => {
456 self.invoke_claude(&prompt, &build_batch_schema(dim))
457 .await?
458 }
459 EmbeddingFlavour::Codex => {
460 let schema = self.codex_schema_file(dim, true)?;
461 self.invoke_codex(&prompt, schema.path()).await?
462 }
463 };
464 match prev_timeout {
466 Some(v) => std::env::set_var("SQLITE_GRAPHRAG_EMBED_TIMEOUT_SECS", v),
467 None => std::env::remove_var("SQLITE_GRAPHRAG_EMBED_TIMEOUT_SECS"),
468 }
469
470 let parsed: BatchEmbeddingResponse = parse_llm_json(&stdout).map_err(|e| {
471 AppError::Embedding(format!(
472 "LLM batch embedding response parse failed: {e}; raw={stdout}"
473 ))
474 })?;
475 if parsed.items.len() != batch.len() {
476 return Err(AppError::Embedding(format!(
477 "LLM batch returned {} items, expected {} (G42/S2 coverage check)",
478 parsed.items.len(),
479 batch.len()
480 )));
481 }
482 let mut out: Vec<Option<Vec<f32>>> = vec![None; batch.len()];
483 for item in parsed.items {
484 if item.i == 0 || item.i > batch.len() {
485 return Err(AppError::Embedding(format!(
486 "LLM batch item index {} out of range 1..={}",
487 item.i,
488 batch.len()
489 )));
490 }
491 if item.v.len() != dim {
492 return Err(AppError::Embedding(format!(
493 "LLM batch item {} returned {} dims, expected {dim}; \
494 refusing to truncate or pad silently (G42/C5)",
495 item.i,
496 item.v.len()
497 )));
498 }
499 out[item.i - 1] = Some(item.v);
500 }
501 let mut result = Vec::with_capacity(batch.len());
502 for (pos, slot) in out.into_iter().enumerate() {
503 let v = slot.ok_or_else(|| {
504 AppError::Embedding(format!(
505 "LLM batch response is missing item index {} (G42/S2 coverage check)",
506 pos + 1
507 ))
508 })?;
509 result.push((batch[pos].0, v));
510 }
511 Ok(result)
512 }
513
514 fn invoke_with_prefix(&self, prefix: &str, text: &str) -> Result<Vec<f32>, AppError> {
515 let dim = crate::constants::embedding_dim();
516 let inner = self.invoke_single_async(prefix, text, dim);
517 match tokio::runtime::Handle::try_current() {
522 Ok(handle) => tokio::task::block_in_place(|| handle.block_on(inner)),
523 Err(_) => crate::embedder::shared_runtime()?.block_on(inner),
524 }
525 }
526
527 async fn invoke_single_async(
528 &self,
529 prefix: &str,
530 text: &str,
531 dim: usize,
532 ) -> Result<Vec<f32>, AppError> {
533 let prompt = format!("{prefix}{text}");
534 let stdout = match self.flavour {
535 EmbeddingFlavour::Claude => {
536 self.invoke_claude(&prompt, &build_single_schema(dim))
537 .await?
538 }
539 EmbeddingFlavour::Codex => {
540 let schema = self.codex_schema_file(dim, false)?;
541 self.invoke_codex(&prompt, schema.path()).await?
542 }
543 };
544 let parsed: EmbeddingResponse = parse_llm_json(&stdout).map_err(|e| {
545 AppError::Embedding(format!(
546 "LLM embedding response parse failed: {e}; raw={stdout}"
547 ))
548 })?;
549 if parsed.embedding.len() != dim {
550 return Err(AppError::Embedding(format!(
551 "LLM returned {} dims, expected {dim}; \
552 refusing to truncate or pad silently (G42/C5)",
553 parsed.embedding.len()
554 )));
555 }
556 Ok(parsed.embedding)
557 }
558
559 fn codex_schema_file(
564 &self,
565 dim: usize,
566 batch: bool,
567 ) -> Result<Arc<tempfile::NamedTempFile>, AppError> {
568 let mut guard = self.codex_schemas.lock();
569 let slot = if batch {
570 &mut guard.batch
571 } else {
572 &mut guard.single
573 };
574 if let Some((cached_dim, file)) = slot {
575 if *cached_dim == dim {
576 return Ok(Arc::clone(file));
577 }
578 }
579 let content = if batch {
580 build_batch_schema(dim)
581 } else {
582 build_single_schema(dim)
583 };
584 let file = tempfile::Builder::new()
585 .prefix("sqlite-graphrag-embed-schema-")
586 .suffix(".json")
587 .tempfile()
588 .map_err(|e| AppError::Embedding(format!("schema tempfile create failed: {e}")))?;
589 std::fs::write(file.path(), content)
590 .map_err(|e| AppError::Embedding(format!("schema tempfile write failed: {e}")))?;
591 let file = Arc::new(file);
592 *slot = Some((dim, Arc::clone(&file)));
593 Ok(file)
594 }
595
596 async fn invoke_claude(&self, prompt: &str, schema: &str) -> Result<String, AppError> {
597 let mcp_config_path = crate::spawn::preflight::write_empty_mcp_config_tempfile()?;
617 let argv_refs: [std::ffi::OsString; 0] = [];
618 let preflight_args = crate::spawn::preflight::PreFlightArgs {
619 binary_path: &self.binary,
620 argv: &argv_refs,
621 workspace_root: std::path::Path::new("."),
622 mcp_config_inline_json: None,
623 expected_output_bytes: 65_536,
624 spawner_name: "llm_embedding",
625 };
626 crate::spawn::preflight::preflight_check(&preflight_args)?;
627 let mut cmd = Command::new(&self.binary);
628 cmd.arg("-p")
629 .arg(prompt)
630 .arg("--model")
631 .arg(&self.model)
632 .arg("--json-schema")
633 .arg(schema)
634 .arg("--output-format")
635 .arg("json")
636 .arg("--strict-mcp-config")
637 .arg("--mcp-config")
638 .arg(mcp_config_path.as_os_str())
639 .arg("--settings")
640 .arg(r#"{"hooks":{}}"#)
641 .arg("--dangerously-skip-permissions")
642 .env_clear()
643 .env("PATH", std::env::var("PATH").unwrap_or_default())
644 .env("HOME", std::env::var("HOME").unwrap_or_default())
645 .stdin(Stdio::null())
646 .stdout(Stdio::piped())
647 .stderr(Stdio::piped())
648 .kill_on_drop(true);
650 if let Some(config_dir) = claude_embedding_config_dir() {
651 cmd.env("CLAUDE_CONFIG_DIR", &config_dir);
652 }
653 let binary_str = self.binary.to_string_lossy().into_owned();
654 let output = match tokio::time::timeout(embed_timeout(), cmd.output()).await {
655 Err(_elapsed) => {
656 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
657 &crate::llm::exit_code_hints::LlmBackendError::Timeout {
658 secs: embed_timeout().as_secs(),
659 binary: binary_str.clone(),
660 },
661 ));
662 }
663 Ok(Err(e)) => {
664 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
665 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
666 binary: binary_str.clone(),
667 source: e.to_string(),
668 },
669 ));
670 }
671 Ok(Ok(o)) => o,
672 };
673 let stdout_str = String::from_utf8_lossy(&output.stdout);
680 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stdout_str) {
681 let is_rate_limited = parsed
682 .get("is_error")
683 .and_then(|v| v.as_bool())
684 .unwrap_or(false)
685 && parsed
686 .get("result")
687 .and_then(|v| v.as_str())
688 .map(|s| {
689 s.contains("rate limit")
690 || s.contains("quota")
691 || s.contains("anthropic-ratelimit")
692 })
693 .unwrap_or(false);
694 if is_rate_limited {
695 return Err(AppError::Embedding(format!(
696 "OAuth usage quota exhausted: claude rate_limit detected in stdout: {}",
697 parsed
698 .get("result")
699 .and_then(|v| v.as_str())
700 .unwrap_or("")
701 .chars()
702 .take(120)
703 .collect::<String>()
704 )));
705 }
706 }
707 if !output.status.success() {
708 let (exit_code, signal) = if let Some(code) = output.status.code() {
709 (Some(code), None)
710 } else {
711 use std::os::unix::process::ExitStatusExt;
712 (None, output.status.signal())
713 };
714 let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
715 &output.stdout,
716 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
717 );
718 let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
719 &output.stderr,
720 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
721 );
722 let mut hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
723 if stderr_tail.contains("401")
725 || stderr_tail.contains("Unauthorized")
726 || stderr_tail.contains("expired")
727 || stderr_tail.contains("login")
728 || stdout_tail.contains("401")
729 || stdout_tail.contains("Unauthorized")
730 {
731 hint.push_str(
732 " | Claude OAuth token may be expired; run `claude login` to renew",
733 );
734 }
735 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
736 &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
737 exit_code,
738 signal,
739 stdout_tail,
740 stderr_tail,
741 binary: binary_str,
742 hint,
743 },
744 ));
745 }
746 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
747 }
748
749 async fn invoke_codex(
750 &self,
751 prompt: &str,
752 schema_path: &std::path::Path,
753 ) -> Result<String, AppError> {
754 let binary_str = self.binary.to_string_lossy().into_owned();
755 let mut cmd = build_codex_embedding_command(&self.binary, &self.model, schema_path);
756
757 let argv_refs: [std::ffi::OsString; 0] = [];
771 let preflight_args = crate::spawn::preflight::PreFlightArgs {
772 binary_path: &self.binary,
773 argv: &argv_refs,
774 workspace_root: std::path::Path::new("."),
775 mcp_config_inline_json: None,
776 expected_output_bytes: 65_536,
777 spawner_name: "llm_embedding",
778 };
779 crate::spawn::preflight::preflight_check(&preflight_args)?;
780 let _ = binary_str; let mut child = match cmd.spawn() {
783 Ok(c) => c,
784 Err(e) => {
785 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
786 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
787 binary: binary_str,
788 source: e.to_string(),
789 },
790 ));
791 }
792 };
793 if let Some(mut stdin) = child.stdin.take() {
794 stdin
795 .write_all(prompt.as_bytes())
796 .await
797 .map_err(|e| AppError::Embedding(format!("codex stdin write failed: {e}")))?;
798 drop(stdin);
799 }
800 let output = match tokio::time::timeout(embed_timeout(), child.wait_with_output()).await {
801 Err(_elapsed) => {
802 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
803 &crate::llm::exit_code_hints::LlmBackendError::Timeout {
804 secs: embed_timeout().as_secs(),
805 binary: binary_str,
806 },
807 ));
808 }
809 Ok(Err(e)) => {
810 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
811 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
812 binary: binary_str,
813 source: format!("codex wait failed: {e}"),
814 },
815 ));
816 }
817 Ok(Ok(o)) => o,
818 };
819 if !output.status.success() {
820 let (exit_code, signal) = if let Some(code) = output.status.code() {
821 (Some(code), None)
822 } else {
823 use std::os::unix::process::ExitStatusExt;
824 (None, output.status.signal())
825 };
826 let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
827 &output.stdout,
828 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
829 );
830 let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
831 &output.stderr,
832 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
833 );
834 let hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
835 let mut combined_hint = hint;
840 if stderr_tail.contains("request_user_input") {
841 combined_hint.push_str(
842 " | codex requested interactive input in a headless embedding call; \
843 upgrade codex (>= 0.134) or switch the embedding backend to claude",
844 );
845 }
846 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
847 &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
848 exit_code,
849 signal,
850 stdout_tail,
851 stderr_tail,
852 binary: binary_str,
853 hint: combined_hint,
854 },
855 ));
856 }
857 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
858 }
859}
860
861fn claude_embedding_config_dir() -> Option<std::path::PathBuf> {
875 if let Ok(dir) = std::env::var("SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR") {
876 let path = std::path::PathBuf::from(dir);
877 if path.is_dir() {
878 return Some(path);
879 }
880 tracing::warn!(
881 target: "embedding",
882 path = %path.display(),
883 "SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR is set but not a directory; \
884 falling back to the managed empty config dir"
885 );
886 }
887 let home = std::env::var("HOME").ok()?;
888 let dir = std::path::Path::new(&home)
889 .join(".local/state/sqlite-graphrag")
890 .join("claude-empty-config");
891 if std::fs::create_dir_all(&dir).is_err() {
892 return None;
893 }
894 #[cfg(unix)]
895 {
896 use std::os::unix::fs::PermissionsExt;
897 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
898 }
899 let creds = std::path::Path::new(&home).join(".claude/.credentials.json");
904 if creds.exists() {
905 let target = dir.join(".credentials.json");
906 let _ = std::fs::copy(&creds, &target);
907 }
908 Some(dir)
909}
910
911fn build_codex_embedding_command(
912 binary: &std::path::Path,
913 model: &str,
914 schema_path: &std::path::Path,
915) -> Command {
916 let mut cmd = Command::new(binary);
917 cmd.arg("exec")
921 .arg("-c")
922 .arg("sandbox_mode='read-only'")
923 .arg("-c")
924 .arg("approval_policy='never'")
925 .arg("--json")
926 .arg("--output-schema")
927 .arg(schema_path)
928 .arg("--ephemeral")
929 .arg("--skip-git-repo-check")
930 .arg("--sandbox")
931 .arg("read-only")
932 .arg("--ignore-user-config")
933 .arg("--ignore-rules");
934 if crate::extract::codex_compat::codex_supports_ask_for_approval() {
935 cmd.arg("--ask-for-approval").arg("never");
936 }
937 cmd.arg("--model")
943 .arg(model)
944 .arg("-")
945 .env_clear()
946 .env("PATH", std::env::var("PATH").unwrap_or_default())
947 .env("HOME", std::env::var("HOME").unwrap_or_default());
948 if let Ok(codex_home) = std::env::var("CODEX_HOME") {
949 cmd.env("CODEX_HOME", codex_home);
950 } else if let Ok(home) = std::env::var("HOME") {
951 let default_home = std::path::Path::new(&home).join(".codex");
952 if default_home.exists() {
953 cmd.env("CODEX_HOME", &default_home);
954 }
955 }
956 cmd.stdin(Stdio::piped())
957 .stdout(Stdio::piped())
958 .stderr(Stdio::piped())
959 .kill_on_drop(true);
961 cmd
962}
963
964fn parse_llm_json<T: serde::de::DeserializeOwned>(stdout: &str) -> Result<T, String> {
977 if let Ok(parsed) = serde_json::from_str::<T>(stdout) {
979 return Ok(parsed);
980 }
981 let mut last_agent_text: Option<String> = None;
984 for line in stdout.lines() {
985 let line = line.trim();
986 if line.is_empty() {
987 continue;
988 }
989 let Ok(event) = serde_json::from_str::<serde_json::Value>(line) else {
990 continue;
991 };
992 if event.get("type").and_then(|t| t.as_str()) != Some("item.completed") {
993 continue;
994 }
995 let item = match event.get("item") {
996 Some(i) => i,
997 None => continue,
998 };
999 if item.get("type").and_then(|t| t.as_str()) != Some("agent_message") {
1000 continue;
1001 }
1002 if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
1003 last_agent_text = Some(text.to_string());
1004 }
1005 }
1006 let text = last_agent_text
1007 .ok_or_else(|| "no agent_message found in codex JSONL output".to_string())?;
1008 serde_json::from_str::<T>(&text)
1009 .map_err(|e| format!("codex agent_message text does not match schema: {e}; raw={text}"))
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014 use super::*;
1015
1016 fn test_client(flavour: EmbeddingFlavour, binary: std::path::PathBuf) -> LlmEmbedding {
1017 LlmEmbedding {
1018 flavour,
1019 binary,
1020 model: "gpt-5.4".to_string(),
1021 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
1022 }
1023 }
1024
1025 #[test]
1026 fn embed_timeout_default_is_60() {
1027 assert_eq!(DEFAULT_EMBED_TIMEOUT_SECS, 60);
1028 }
1029
1030 #[test]
1031 #[serial_test::serial(env)]
1032 fn oauth_only_enforce_blocks_api_keys() {
1033 unsafe {
1036 std::env::set_var("ANTHROPIC_API_KEY", "test");
1037 assert!(LlmEmbedding::oauth_only_enforce().is_err());
1038 std::env::remove_var("ANTHROPIC_API_KEY");
1039
1040 std::env::set_var("OPENAI_API_KEY", "test");
1041 assert!(LlmEmbedding::oauth_only_enforce().is_err());
1042 std::env::remove_var("OPENAI_API_KEY");
1043 }
1044 assert!(LlmEmbedding::oauth_only_enforce().is_ok());
1045 }
1046
1047 #[test]
1048 fn flavour_as_str_is_stable() {
1049 assert_eq!(EmbeddingFlavour::Claude.as_str(), "claude");
1050 assert_eq!(EmbeddingFlavour::Codex.as_str(), "codex");
1051 }
1052
1053 #[test]
1054 fn single_schema_embeds_active_dim() {
1055 let schema = build_single_schema(64);
1056 assert!(schema.contains(r#""minItems":64"#));
1057 assert!(schema.contains(r#""maxItems":64"#));
1058 let parsed: serde_json::Value =
1059 serde_json::from_str(&schema).expect("single schema must be valid JSON");
1060 assert_eq!(parsed["properties"]["embedding"]["minItems"], 64);
1061 }
1062
1063 #[test]
1064 fn batch_schema_is_valid_json_and_unbounded_items() {
1065 let schema = build_batch_schema(64);
1066 let parsed: serde_json::Value =
1067 serde_json::from_str(&schema).expect("batch schema must be valid JSON");
1068 assert!(parsed["properties"]["items"].get("minItems").is_none());
1071 assert_eq!(
1072 parsed["properties"]["items"]["items"]["properties"]["v"]["minItems"],
1073 64
1074 );
1075 }
1076
1077 #[test]
1078 fn parse_llm_json_accepts_claude_json() {
1079 let stdout = r#"{"embedding":[0.0,1.0,2.0]}"#;
1080
1081 let parsed: EmbeddingResponse = parse_llm_json(stdout).expect("claude JSON must parse");
1082
1083 assert_eq!(parsed.embedding, vec![0.0, 1.0, 2.0]);
1084 }
1085
1086 #[test]
1087 fn parse_llm_json_accepts_codex_jsonl() {
1088 let stdout = r#"{"type":"thread.started","thread_id":"mock-thread-0"}
1089{"type":"item.completed","item":{"type":"agent_message","text":"{\"embedding\":[0.0,1.0,2.0]}"}}
1090{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}"#;
1091
1092 let parsed: EmbeddingResponse = parse_llm_json(stdout).expect("codex JSONL must parse");
1093
1094 assert_eq!(parsed.embedding, vec![0.0, 1.0, 2.0]);
1095 }
1096
1097 #[test]
1098 fn parse_llm_json_rejects_jsonl_without_agent_message() {
1099 let stdout = r#"{"type":"thread.started","thread_id":"mock-thread-0"}"#;
1100
1101 let err = parse_llm_json::<EmbeddingResponse>(stdout)
1102 .expect_err("missing agent_message must fail");
1103
1104 assert!(err.contains("no agent_message"));
1105 }
1106
1107 #[test]
1108 fn parse_llm_json_accepts_batch_response() {
1109 let stdout = r#"{"items":[{"i":1,"v":[0.0,1.0]},{"i":2,"v":[2.0,3.0]}]}"#;
1110
1111 let parsed: BatchEmbeddingResponse = parse_llm_json(stdout).expect("batch JSON must parse");
1112
1113 assert_eq!(parsed.items.len(), 2);
1114 assert_eq!(parsed.items[0].i, 1);
1115 assert_eq!(parsed.items[1].v, vec![2.0, 3.0]);
1116 }
1117
1118 #[test]
1119 fn codex_schema_file_is_created_once_and_reused() {
1120 let client = test_client(
1121 EmbeddingFlavour::Codex,
1122 std::path::PathBuf::from("/bin/true"),
1123 );
1124 let first = client
1125 .codex_schema_file(64, false)
1126 .expect("schema file must be created");
1127 let second = client
1128 .codex_schema_file(64, false)
1129 .expect("schema file must be reused");
1130 assert_eq!(first.path(), second.path(), "same dim must reuse the file");
1131
1132 let batch = client
1133 .codex_schema_file(64, true)
1134 .expect("batch schema file must be created");
1135 assert_ne!(
1136 first.path(),
1137 batch.path(),
1138 "single and batch schemas are distinct files"
1139 );
1140
1141 let content = std::fs::read_to_string(first.path()).expect("schema file must be readable");
1142 assert!(content.contains(r#""minItems":64"#));
1143 }
1144
1145 #[test]
1146 fn codex_embedding_command_reads_prompt_from_stdin() {
1147 let schema_path = std::env::temp_dir().join("sqlite-graphrag-embed-schema-test.json");
1148 let cmd = build_codex_embedding_command(
1149 std::path::Path::new("/bin/true"),
1150 "gpt-5.4",
1151 &schema_path,
1152 );
1153 let argv: Vec<String> = cmd
1154 .as_std()
1155 .get_args()
1156 .filter_map(|arg| arg.to_str().map(|s| s.to_string()))
1157 .collect();
1158
1159 assert!(
1160 argv.iter().any(|arg| arg == "-"),
1161 "codex embedding command must read prompt from stdin: {argv:?}"
1162 );
1163 assert!(
1164 !argv.iter().any(|arg| arg.starts_with("passage: ")),
1165 "prompt text must not be passed as argv: {argv:?}"
1166 );
1167 for required in &[
1168 "exec",
1169 "-c",
1170 "sandbox_mode='read-only'",
1171 "approval_policy='never'",
1172 "--json",
1173 "--output-schema",
1174 "--ephemeral",
1175 "--skip-git-repo-check",
1176 "--sandbox",
1177 "read-only",
1178 "--ignore-user-config",
1179 "--ignore-rules",
1180 "--model",
1181 "gpt-5.4",
1182 ] {
1183 assert!(
1184 argv.iter().any(|arg| arg == required),
1185 "missing flag {required} in {argv:?}"
1186 );
1187 }
1188 }
1189
1190 #[cfg(unix)]
1191 #[test]
1192 #[serial_test::serial(env)]
1193 fn embed_passage_sends_prompt_to_codex_stdin() {
1194 use std::os::unix::fs::PermissionsExt;
1195
1196 unsafe {
1200 std::env::set_var("SQLITE_GRAPHRAG_EMBEDDING_DIM", "64");
1201 }
1202
1203 let temp = tempfile::tempdir().expect("tempdir must exist");
1204 let binary = temp.path().join("codex-stdin-check");
1205 let script = r#"#!/usr/bin/env bash
1206set -euo pipefail
1207
1208prompt="$(cat)"
1209if [[ "$prompt" != "passage: codex-cli" ]]; then
1210 echo "unexpected stdin: $prompt" >&2
1211 exit 41
1212fi
1213
1214vals="0.0"
1215for _ in $(seq 2 64); do
1216 vals="$vals,0.0"
1217done
1218payload="{\"embedding\":[$vals]}"
1219escaped="${payload//\"/\\\"}"
1220echo "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"$escaped\"}}"
1221"#;
1222 std::fs::write(&binary, script).expect("mock codex script must be written");
1223 let mut perms = std::fs::metadata(&binary)
1224 .expect("mock codex metadata must exist")
1225 .permissions();
1226 perms.set_mode(0o755);
1227 std::fs::set_permissions(&binary, perms).expect("mock codex must be executable");
1228
1229 let embedding = test_client(EmbeddingFlavour::Codex, binary);
1230
1231 let vector = embedding
1232 .embed_passage("codex-cli")
1233 .expect("stdin-backed codex embedding must succeed");
1234
1235 unsafe {
1237 std::env::remove_var("SQLITE_GRAPHRAG_EMBEDDING_DIM");
1238 }
1239
1240 assert_eq!(vector.len(), 64);
1241 assert!(vector.iter().all(|value| *value == 0.0));
1242 }
1243
1244 #[test]
1254 fn claude_default_resolves_path() {
1255 let builder = LlmEmbeddingBuilder::claude_default();
1256 assert_eq!(builder.flavour, EmbeddingFlavour::Claude);
1257 assert!(builder.binary_override.is_none());
1258 assert!(builder.model_override.is_none());
1259 }
1260
1261 #[test]
1265 fn override_binary_uses_provided() {
1266 let path = std::path::PathBuf::from("/tmp/fake-claude-binary");
1267 let builder = LlmEmbeddingBuilder::claude_default().override_binary(path.clone());
1268 assert_eq!(builder.binary_override.as_ref(), Some(&path));
1269 }
1270
1271 #[test]
1275 fn override_model_uses_provided() {
1276 let builder =
1277 LlmEmbeddingBuilder::codex_default().override_model("gpt-5.4-custom".to_string());
1278 assert_eq!(builder.model_override.as_deref(), Some("gpt-5.4-custom"));
1279 }
1280
1281 #[test]
1286 fn embed_timeout_for_batch_scales_with_size() {
1287 let t1 = embed_timeout_for_batch(1);
1288 let t4 = embed_timeout_for_batch(4);
1289 let t8 = embed_timeout_for_batch(8);
1290 assert!(t1 < t4, "batch of 4 must have longer timeout than batch of 1");
1291 assert!(t4 < t8, "batch of 8 must have longer timeout than batch of 4");
1292 assert_eq!(t8 - t1, std::time::Duration::from_secs(15 * 7));
1293 }
1294
1295 #[test]
1296 fn embed_timeout_for_batch_single_equals_base() {
1297 let base = embed_timeout();
1298 let single = embed_timeout_for_batch(1);
1299 assert_eq!(base, single);
1300 }
1301}