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 = 300;
44
45fn embed_timeout() -> std::time::Duration {
46 let secs = std::env::var("SQLITE_GRAPHRAG_EMBED_TIMEOUT_SECS")
47 .ok()
48 .and_then(|v| v.parse::<u64>().ok())
49 .filter(|&n| (10..=3_600).contains(&n))
50 .unwrap_or(DEFAULT_EMBED_TIMEOUT_SECS);
51 std::time::Duration::from_secs(secs)
52}
53
54fn build_single_schema(dim: usize) -> String {
56 format!(
57 r#"{{"type":"object","properties":{{"embedding":{{"type":"array","items":{{"type":"number"}},"minItems":{dim},"maxItems":{dim}}}}},"required":["embedding"],"additionalProperties":false}}"#
58 )
59}
60
61fn build_batch_schema(dim: usize) -> String {
65 format!(
66 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}}"#
67 )
68}
69
70#[derive(Clone, Debug)]
71pub struct LlmEmbedding {
72 flavour: EmbeddingFlavour,
74 binary: std::path::PathBuf,
76 model: String,
78 codex_schemas: Arc<parking_lot::Mutex<CodexSchemaFiles>>,
82}
83
84#[derive(Debug, Default)]
85struct CodexSchemaFiles {
86 single: Option<(usize, Arc<tempfile::NamedTempFile>)>,
87 batch: Option<(usize, Arc<tempfile::NamedTempFile>)>,
88}
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
91pub enum EmbeddingFlavour {
92 Claude,
93 Codex,
94}
95
96impl EmbeddingFlavour {
97 pub fn as_str(self) -> &'static str {
98 match self {
99 Self::Claude => "claude",
100 Self::Codex => "codex",
101 }
102 }
103}
104
105#[derive(Debug, Deserialize)]
106struct EmbeddingResponse {
107 embedding: Vec<f32>,
108}
109
110#[derive(Debug, Deserialize)]
111struct BatchEmbeddingResponse {
112 items: Vec<BatchEmbeddingItem>,
113}
114
115#[derive(Debug, Deserialize)]
116struct BatchEmbeddingItem {
117 i: usize,
118 v: Vec<f32>,
119}
120
121pub fn resolve_real_binary(path: &std::path::Path) -> std::path::PathBuf {
125 if let Ok(canonical) = std::fs::canonicalize(path) {
126 if is_elf_binary(&canonical) {
127 return canonical;
128 }
129 if let Some(exec_target) = extract_exec_target_from_shim(&canonical) {
130 if exec_target.exists() && is_elf_binary(&exec_target) {
131 return exec_target;
132 }
133 }
134 return canonical;
135 }
136 path.to_path_buf()
137}
138
139fn is_elf_binary(path: &std::path::Path) -> bool {
140 std::fs::read(path)
141 .map(|bytes| bytes.len() >= 4 && bytes[..4] == [0x7f, b'E', b'L', b'F'])
142 .unwrap_or(false)
143}
144
145fn extract_exec_target_from_shim(path: &std::path::Path) -> Option<std::path::PathBuf> {
146 let content = std::fs::read_to_string(path).ok()?;
147 if !content.starts_with("#!") {
148 return None;
149 }
150 for line in content.lines().rev() {
151 let trimmed = line.trim();
152 if trimmed.starts_with("exec ") {
153 let after_exec = trimmed.strip_prefix("exec ")?;
154 let binary = after_exec.split_whitespace().next()?;
155 return Some(std::path::PathBuf::from(binary));
156 }
157 }
158 None
159}
160
161fn claude_embed_model() -> String {
164 std::env::var("SQLITE_GRAPHRAG_CLAUDE_EMBED_MODEL")
165 .unwrap_or_else(|_| "claude-sonnet-4-6".to_string())
166}
167
168fn codex_embed_model() -> String {
169 std::env::var("SQLITE_GRAPHRAG_CODEX_EMBED_MODEL").unwrap_or_else(|_| "gpt-5.5".to_string())
170}
171
172impl LlmEmbedding {
173 pub fn detect_available() -> Result<Self, AppError> {
185 Self::oauth_only_enforce()?;
186
187 if let Ok(path) = which::which("codex") {
188 return Ok(Self {
189 flavour: EmbeddingFlavour::Codex,
190 binary: resolve_real_binary(&path),
191 model: codex_embed_model(),
192 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
193 });
194 }
195 if let Ok(path) = which::which("claude") {
196 return Ok(Self {
197 flavour: EmbeddingFlavour::Claude,
198 binary: resolve_real_binary(&path),
199 model: claude_embed_model(),
200 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
201 });
202 }
203 Err(AppError::Embedding(
204 "no LLM CLI found on PATH: install `codex` (0.130+) or `claude` (Claude Code 2.1+)"
205 .to_string(),
206 ))
207 }
208
209 pub fn with_codex() -> Result<Self, AppError> {
210 Self::oauth_only_enforce()?;
211 let path = which::which("codex")
212 .map_err(|_| AppError::Embedding("`codex` not found on PATH".to_string()))?;
213 Ok(Self {
214 flavour: EmbeddingFlavour::Codex,
215 binary: resolve_real_binary(&path),
216 model: codex_embed_model(),
217 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
218 })
219 }
220
221 pub fn with_claude() -> Result<Self, AppError> {
222 Self::oauth_only_enforce()?;
223 let path = which::which("claude")
224 .map_err(|_| AppError::Embedding("`claude` not found on PATH".to_string()))?;
225 Ok(Self {
226 flavour: EmbeddingFlavour::Claude,
227 binary: resolve_real_binary(&path),
228 model: claude_embed_model(),
229 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
230 })
231 }
232
233 fn oauth_only_enforce() -> Result<(), AppError> {
238 if std::env::var("ANTHROPIC_API_KEY").is_ok() {
239 return Err(AppError::Validation(
240 "ANTHROPIC_API_KEY is set; v1.0.76 requires OAuth. \
241 unset it and use `claude login` instead."
242 .into(),
243 ));
244 }
245 if std::env::var("OPENAI_API_KEY").is_ok() {
246 return Err(AppError::Validation(
247 "OPENAI_API_KEY is set; v1.0.76 requires OAuth. \
248 unset it and use `codex login` instead."
249 .into(),
250 ));
251 }
252 Ok(())
253 }
254
255 pub fn embed_passage(&self, text: &str) -> Result<Vec<f32>, AppError> {
258 self.invoke_with_prefix(crate::constants::PASSAGE_PREFIX, text)
259 }
260
261 pub fn embed_query(&self, text: &str) -> Result<Vec<f32>, AppError> {
264 self.invoke_with_prefix(crate::constants::QUERY_PREFIX, text)
265 }
266
267 pub fn model_label(&self) -> String {
273 format!("{}:{}", self.flavour.as_str(), self.model)
274 }
275
276 pub async fn embed_batch_async(
286 &self,
287 prefix: &str,
288 batch: &[(usize, String)],
289 ) -> Result<Vec<(usize, Vec<f32>)>, AppError> {
290 let dim = crate::constants::embedding_dim();
291 if batch.is_empty() {
292 return Ok(Vec::new());
293 }
294 if batch.len() == 1 {
295 let (idx, text) = (&batch[0].0, &batch[0].1);
296 let v = self.invoke_single_async(prefix, text, dim).await?;
297 return Ok(vec![(*idx, v)]);
298 }
299
300 let mut prompt = format!(
301 "Generate {dim}-dimensional semantic embedding vectors for each numbered text below.\n\
302 Return a JSON object with an \"items\" array containing EXACTLY {n} items.\n\
303 Each item has \"i\" (the 1-based index) and \"v\" (the {dim}-float vector, values between -1 and 1).\n\n",
304 n = batch.len()
305 );
306 for (pos, (_, text)) in batch.iter().enumerate() {
307 prompt.push_str(&format!("{}: {prefix}{text}\n", pos + 1));
308 }
309
310 let stdout = match self.flavour {
311 EmbeddingFlavour::Claude => {
312 self.invoke_claude(&prompt, &build_batch_schema(dim))
313 .await?
314 }
315 EmbeddingFlavour::Codex => {
316 let schema = self.codex_schema_file(dim, true)?;
317 self.invoke_codex(&prompt, schema.path()).await?
318 }
319 };
320
321 let parsed: BatchEmbeddingResponse = parse_llm_json(&stdout).map_err(|e| {
322 AppError::Embedding(format!(
323 "LLM batch embedding response parse failed: {e}; raw={stdout}"
324 ))
325 })?;
326 if parsed.items.len() != batch.len() {
327 return Err(AppError::Embedding(format!(
328 "LLM batch returned {} items, expected {} (G42/S2 coverage check)",
329 parsed.items.len(),
330 batch.len()
331 )));
332 }
333 let mut out: Vec<Option<Vec<f32>>> = vec![None; batch.len()];
334 for item in parsed.items {
335 if item.i == 0 || item.i > batch.len() {
336 return Err(AppError::Embedding(format!(
337 "LLM batch item index {} out of range 1..={}",
338 item.i,
339 batch.len()
340 )));
341 }
342 if item.v.len() != dim {
343 return Err(AppError::Embedding(format!(
344 "LLM batch item {} returned {} dims, expected {dim}; \
345 refusing to truncate or pad silently (G42/C5)",
346 item.i,
347 item.v.len()
348 )));
349 }
350 out[item.i - 1] = Some(item.v);
351 }
352 let mut result = Vec::with_capacity(batch.len());
353 for (pos, slot) in out.into_iter().enumerate() {
354 let v = slot.ok_or_else(|| {
355 AppError::Embedding(format!(
356 "LLM batch response is missing item index {} (G42/S2 coverage check)",
357 pos + 1
358 ))
359 })?;
360 result.push((batch[pos].0, v));
361 }
362 Ok(result)
363 }
364
365 fn invoke_with_prefix(&self, prefix: &str, text: &str) -> Result<Vec<f32>, AppError> {
366 let dim = crate::constants::embedding_dim();
367 let inner = self.invoke_single_async(prefix, text, dim);
368 match tokio::runtime::Handle::try_current() {
373 Ok(handle) => tokio::task::block_in_place(|| handle.block_on(inner)),
374 Err(_) => crate::embedder::shared_runtime()?.block_on(inner),
375 }
376 }
377
378 async fn invoke_single_async(
379 &self,
380 prefix: &str,
381 text: &str,
382 dim: usize,
383 ) -> Result<Vec<f32>, AppError> {
384 let prompt = format!("{prefix}{text}");
385 let stdout = match self.flavour {
386 EmbeddingFlavour::Claude => {
387 self.invoke_claude(&prompt, &build_single_schema(dim))
388 .await?
389 }
390 EmbeddingFlavour::Codex => {
391 let schema = self.codex_schema_file(dim, false)?;
392 self.invoke_codex(&prompt, schema.path()).await?
393 }
394 };
395 let parsed: EmbeddingResponse = parse_llm_json(&stdout).map_err(|e| {
396 AppError::Embedding(format!(
397 "LLM embedding response parse failed: {e}; raw={stdout}"
398 ))
399 })?;
400 if parsed.embedding.len() != dim {
401 return Err(AppError::Embedding(format!(
402 "LLM returned {} dims, expected {dim}; \
403 refusing to truncate or pad silently (G42/C5)",
404 parsed.embedding.len()
405 )));
406 }
407 Ok(parsed.embedding)
408 }
409
410 fn codex_schema_file(
415 &self,
416 dim: usize,
417 batch: bool,
418 ) -> Result<Arc<tempfile::NamedTempFile>, AppError> {
419 let mut guard = self.codex_schemas.lock();
420 let slot = if batch {
421 &mut guard.batch
422 } else {
423 &mut guard.single
424 };
425 if let Some((cached_dim, file)) = slot {
426 if *cached_dim == dim {
427 return Ok(Arc::clone(file));
428 }
429 }
430 let content = if batch {
431 build_batch_schema(dim)
432 } else {
433 build_single_schema(dim)
434 };
435 let file = tempfile::Builder::new()
436 .prefix("sqlite-graphrag-embed-schema-")
437 .suffix(".json")
438 .tempfile()
439 .map_err(|e| AppError::Embedding(format!("schema tempfile create failed: {e}")))?;
440 std::fs::write(file.path(), content)
441 .map_err(|e| AppError::Embedding(format!("schema tempfile write failed: {e}")))?;
442 let file = Arc::new(file);
443 *slot = Some((dim, Arc::clone(&file)));
444 Ok(file)
445 }
446
447 async fn invoke_claude(&self, prompt: &str, schema: &str) -> Result<String, AppError> {
448 let mut cmd = Command::new(&self.binary);
461 cmd.arg("-p")
462 .arg(prompt)
463 .arg("--model")
464 .arg(&self.model)
465 .arg("--json-schema")
466 .arg(schema)
467 .arg("--output-format")
468 .arg("json")
469 .arg("--strict-mcp-config")
470 .arg("--mcp-config")
471 .arg(r#"{"mcpServers":{}}"#)
472 .arg("--settings")
473 .arg(r#"{"hooks":{}}"#)
474 .arg("--dangerously-skip-permissions")
475 .env_clear()
476 .env("PATH", std::env::var("PATH").unwrap_or_default())
477 .env("HOME", std::env::var("HOME").unwrap_or_default())
478 .stdin(Stdio::null())
479 .stdout(Stdio::piped())
480 .stderr(Stdio::piped())
481 .kill_on_drop(true);
483 if let Some(config_dir) = claude_embedding_config_dir() {
484 cmd.env("CLAUDE_CONFIG_DIR", &config_dir);
485 }
486 let binary_str = self.binary.to_string_lossy().into_owned();
487 let output = match tokio::time::timeout(embed_timeout(), cmd.output()).await {
488 Err(_elapsed) => {
489 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
490 &crate::llm::exit_code_hints::LlmBackendError::Timeout {
491 secs: embed_timeout().as_secs(),
492 binary: binary_str.clone(),
493 },
494 ));
495 }
496 Ok(Err(e)) => {
497 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
498 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
499 binary: binary_str.clone(),
500 source: e.to_string(),
501 },
502 ));
503 }
504 Ok(Ok(o)) => o,
505 };
506 if !output.status.success() {
507 let (exit_code, signal) = if let Some(code) = output.status.code() {
508 (Some(code), None)
509 } else {
510 use std::os::unix::process::ExitStatusExt;
511 (None, output.status.signal())
512 };
513 let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
514 &output.stdout,
515 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
516 );
517 let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
518 &output.stderr,
519 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
520 );
521 let hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
522 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
523 &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
524 exit_code,
525 signal,
526 stdout_tail,
527 stderr_tail,
528 binary: binary_str,
529 hint,
530 },
531 ));
532 }
533 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
534 }
535
536 async fn invoke_codex(
537 &self,
538 prompt: &str,
539 schema_path: &std::path::Path,
540 ) -> Result<String, AppError> {
541 let binary_str = self.binary.to_string_lossy().into_owned();
542 let mut child =
543 match build_codex_embedding_command(&self.binary, &self.model, schema_path).spawn() {
544 Ok(c) => c,
545 Err(e) => {
546 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
547 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
548 binary: binary_str,
549 source: e.to_string(),
550 },
551 ));
552 }
553 };
554 if let Some(mut stdin) = child.stdin.take() {
555 stdin
556 .write_all(prompt.as_bytes())
557 .await
558 .map_err(|e| AppError::Embedding(format!("codex stdin write failed: {e}")))?;
559 }
560 let output = match tokio::time::timeout(embed_timeout(), child.wait_with_output()).await {
561 Err(_elapsed) => {
562 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
563 &crate::llm::exit_code_hints::LlmBackendError::Timeout {
564 secs: embed_timeout().as_secs(),
565 binary: binary_str,
566 },
567 ));
568 }
569 Ok(Err(e)) => {
570 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
571 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
572 binary: binary_str,
573 source: format!("codex wait failed: {e}"),
574 },
575 ));
576 }
577 Ok(Ok(o)) => o,
578 };
579 if !output.status.success() {
580 let (exit_code, signal) = if let Some(code) = output.status.code() {
581 (Some(code), None)
582 } else {
583 use std::os::unix::process::ExitStatusExt;
584 (None, output.status.signal())
585 };
586 let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
587 &output.stdout,
588 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
589 );
590 let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
591 &output.stderr,
592 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
593 );
594 let hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
595 let mut combined_hint = hint;
600 if stderr_tail.contains("request_user_input") {
601 combined_hint.push_str(
602 " | codex requested interactive input in a headless embedding call; \
603 upgrade codex (>= 0.134) or switch the embedding backend to claude",
604 );
605 }
606 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
607 &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
608 exit_code,
609 signal,
610 stdout_tail,
611 stderr_tail,
612 binary: binary_str,
613 hint: combined_hint,
614 },
615 ));
616 }
617 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
618 }
619}
620
621fn claude_embedding_config_dir() -> Option<std::path::PathBuf> {
635 if let Ok(dir) = std::env::var("SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR") {
636 let path = std::path::PathBuf::from(dir);
637 if path.is_dir() {
638 return Some(path);
639 }
640 tracing::warn!(
641 target: "embedding",
642 path = %path.display(),
643 "SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR is set but not a directory; \
644 falling back to the managed empty config dir"
645 );
646 }
647 let home = std::env::var("HOME").ok()?;
648 let dir = std::path::Path::new(&home)
649 .join(".local/state/sqlite-graphrag")
650 .join("claude-empty-config");
651 if std::fs::create_dir_all(&dir).is_err() {
652 return None;
653 }
654 #[cfg(unix)]
655 {
656 use std::os::unix::fs::PermissionsExt;
657 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
658 }
659 let creds = std::path::Path::new(&home).join(".claude/.credentials.json");
662 if creds.exists() {
663 let target = dir.join(".credentials.json");
664 if !target.exists() {
665 let _ = std::fs::copy(&creds, &target);
666 }
667 }
668 Some(dir)
669}
670
671fn build_codex_embedding_command(
672 binary: &std::path::Path,
673 model: &str,
674 schema_path: &std::path::Path,
675) -> Command {
676 let mut cmd = Command::new(binary);
677 cmd.arg("exec")
681 .arg("-c")
682 .arg("sandbox_mode='read-only'")
683 .arg("-c")
684 .arg("approval_policy='never'")
685 .arg("--json")
686 .arg("--output-schema")
687 .arg(schema_path)
688 .arg("--ephemeral")
689 .arg("--skip-git-repo-check")
690 .arg("--sandbox")
691 .arg("read-only")
692 .arg("--ignore-user-config")
693 .arg("--ignore-rules");
694 if crate::extract::codex_compat::codex_supports_ask_for_approval() {
695 cmd.arg("--ask-for-approval").arg("never");
696 }
697 let codex_home = prepare_isolated_codex_home();
700 cmd.arg("--model")
701 .arg(model)
702 .arg("-")
703 .env_clear()
704 .env("PATH", std::env::var("PATH").unwrap_or_default())
705 .env("HOME", std::env::var("HOME").unwrap_or_default());
706 if let Some(ref ch) = codex_home {
707 cmd.env("CODEX_HOME", ch);
708 }
709 cmd.stdin(Stdio::piped())
710 .stdout(Stdio::piped())
711 .stderr(Stdio::piped())
712 .kill_on_drop(true);
714 cmd
715}
716
717fn prepare_isolated_codex_home() -> Option<std::path::PathBuf> {
718 let home = std::env::var("HOME").ok()?;
719 let real_auth = std::path::Path::new(&home).join(".codex/auth.json");
720 if !real_auth.exists() {
721 return None;
722 }
723 let base = std::path::Path::new(&home).join(".local/share/sqlite-graphrag");
724 let isolated = base.join(format!("codex-home-{}", std::process::id()));
725 let _ = std::fs::create_dir_all(&isolated);
726 let target = isolated.join("auth.json");
727 if !target.exists() {
728 let _ = std::fs::copy(&real_auth, &target);
729 }
730 Some(isolated)
731}
732
733fn parse_llm_json<T: serde::de::DeserializeOwned>(stdout: &str) -> Result<T, String> {
742 if let Ok(parsed) = serde_json::from_str::<T>(stdout) {
744 return Ok(parsed);
745 }
746 let mut last_agent_text: Option<String> = None;
749 for line in stdout.lines() {
750 let line = line.trim();
751 if line.is_empty() {
752 continue;
753 }
754 let Ok(event) = serde_json::from_str::<serde_json::Value>(line) else {
755 continue;
756 };
757 if event.get("type").and_then(|t| t.as_str()) != Some("item.completed") {
758 continue;
759 }
760 let item = match event.get("item") {
761 Some(i) => i,
762 None => continue,
763 };
764 if item.get("type").and_then(|t| t.as_str()) != Some("agent_message") {
765 continue;
766 }
767 if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
768 last_agent_text = Some(text.to_string());
769 }
770 }
771 let text = last_agent_text
772 .ok_or_else(|| "no agent_message found in codex JSONL output".to_string())?;
773 serde_json::from_str::<T>(&text)
774 .map_err(|e| format!("codex agent_message text does not match schema: {e}; raw={text}"))
775}
776
777#[cfg(test)]
778mod tests {
779 use super::*;
780
781 fn test_client(flavour: EmbeddingFlavour, binary: std::path::PathBuf) -> LlmEmbedding {
782 LlmEmbedding {
783 flavour,
784 binary,
785 model: "gpt-5.4".to_string(),
786 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
787 }
788 }
789
790 #[test]
791 #[serial_test::serial(env)]
792 fn oauth_only_enforce_blocks_api_keys() {
793 unsafe {
796 std::env::set_var("ANTHROPIC_API_KEY", "test");
797 assert!(LlmEmbedding::oauth_only_enforce().is_err());
798 std::env::remove_var("ANTHROPIC_API_KEY");
799
800 std::env::set_var("OPENAI_API_KEY", "test");
801 assert!(LlmEmbedding::oauth_only_enforce().is_err());
802 std::env::remove_var("OPENAI_API_KEY");
803 }
804 assert!(LlmEmbedding::oauth_only_enforce().is_ok());
805 }
806
807 #[test]
808 fn flavour_as_str_is_stable() {
809 assert_eq!(EmbeddingFlavour::Claude.as_str(), "claude");
810 assert_eq!(EmbeddingFlavour::Codex.as_str(), "codex");
811 }
812
813 #[test]
814 fn single_schema_embeds_active_dim() {
815 let schema = build_single_schema(64);
816 assert!(schema.contains(r#""minItems":64"#));
817 assert!(schema.contains(r#""maxItems":64"#));
818 let parsed: serde_json::Value =
819 serde_json::from_str(&schema).expect("single schema must be valid JSON");
820 assert_eq!(parsed["properties"]["embedding"]["minItems"], 64);
821 }
822
823 #[test]
824 fn batch_schema_is_valid_json_and_unbounded_items() {
825 let schema = build_batch_schema(64);
826 let parsed: serde_json::Value =
827 serde_json::from_str(&schema).expect("batch schema must be valid JSON");
828 assert!(parsed["properties"]["items"].get("minItems").is_none());
831 assert_eq!(
832 parsed["properties"]["items"]["items"]["properties"]["v"]["minItems"],
833 64
834 );
835 }
836
837 #[test]
838 fn parse_llm_json_accepts_claude_json() {
839 let stdout = r#"{"embedding":[0.0,1.0,2.0]}"#;
840
841 let parsed: EmbeddingResponse = parse_llm_json(stdout).expect("claude JSON must parse");
842
843 assert_eq!(parsed.embedding, vec![0.0, 1.0, 2.0]);
844 }
845
846 #[test]
847 fn parse_llm_json_accepts_codex_jsonl() {
848 let stdout = r#"{"type":"thread.started","thread_id":"mock-thread-0"}
849{"type":"item.completed","item":{"type":"agent_message","text":"{\"embedding\":[0.0,1.0,2.0]}"}}
850{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}"#;
851
852 let parsed: EmbeddingResponse = parse_llm_json(stdout).expect("codex JSONL must parse");
853
854 assert_eq!(parsed.embedding, vec![0.0, 1.0, 2.0]);
855 }
856
857 #[test]
858 fn parse_llm_json_rejects_jsonl_without_agent_message() {
859 let stdout = r#"{"type":"thread.started","thread_id":"mock-thread-0"}"#;
860
861 let err = parse_llm_json::<EmbeddingResponse>(stdout)
862 .expect_err("missing agent_message must fail");
863
864 assert!(err.contains("no agent_message"));
865 }
866
867 #[test]
868 fn parse_llm_json_accepts_batch_response() {
869 let stdout = r#"{"items":[{"i":1,"v":[0.0,1.0]},{"i":2,"v":[2.0,3.0]}]}"#;
870
871 let parsed: BatchEmbeddingResponse = parse_llm_json(stdout).expect("batch JSON must parse");
872
873 assert_eq!(parsed.items.len(), 2);
874 assert_eq!(parsed.items[0].i, 1);
875 assert_eq!(parsed.items[1].v, vec![2.0, 3.0]);
876 }
877
878 #[test]
879 fn codex_schema_file_is_created_once_and_reused() {
880 let client = test_client(
881 EmbeddingFlavour::Codex,
882 std::path::PathBuf::from("/bin/true"),
883 );
884 let first = client
885 .codex_schema_file(64, false)
886 .expect("schema file must be created");
887 let second = client
888 .codex_schema_file(64, false)
889 .expect("schema file must be reused");
890 assert_eq!(first.path(), second.path(), "same dim must reuse the file");
891
892 let batch = client
893 .codex_schema_file(64, true)
894 .expect("batch schema file must be created");
895 assert_ne!(
896 first.path(),
897 batch.path(),
898 "single and batch schemas are distinct files"
899 );
900
901 let content = std::fs::read_to_string(first.path()).expect("schema file must be readable");
902 assert!(content.contains(r#""minItems":64"#));
903 }
904
905 #[test]
906 fn codex_embedding_command_reads_prompt_from_stdin() {
907 let schema_path = std::env::temp_dir().join("sqlite-graphrag-embed-schema-test.json");
908 let cmd = build_codex_embedding_command(
909 std::path::Path::new("/bin/true"),
910 "gpt-5.4",
911 &schema_path,
912 );
913 let argv: Vec<String> = cmd
914 .as_std()
915 .get_args()
916 .filter_map(|arg| arg.to_str().map(|s| s.to_string()))
917 .collect();
918
919 assert!(
920 argv.iter().any(|arg| arg == "-"),
921 "codex embedding command must read prompt from stdin: {argv:?}"
922 );
923 assert!(
924 !argv.iter().any(|arg| arg.starts_with("passage: ")),
925 "prompt text must not be passed as argv: {argv:?}"
926 );
927 for required in &[
928 "exec",
929 "-c",
930 "sandbox_mode='read-only'",
931 "approval_policy='never'",
932 "--json",
933 "--output-schema",
934 "--ephemeral",
935 "--skip-git-repo-check",
936 "--sandbox",
937 "read-only",
938 "--ignore-user-config",
939 "--ignore-rules",
940 "--model",
941 "gpt-5.4",
942 ] {
943 assert!(
944 argv.iter().any(|arg| arg == required),
945 "missing flag {required} in {argv:?}"
946 );
947 }
948 }
949
950 #[cfg(unix)]
951 #[test]
952 #[serial_test::serial(env)]
953 fn embed_passage_sends_prompt_to_codex_stdin() {
954 use std::os::unix::fs::PermissionsExt;
955
956 unsafe {
960 std::env::set_var("SQLITE_GRAPHRAG_EMBEDDING_DIM", "64");
961 }
962
963 let temp = tempfile::tempdir().expect("tempdir must exist");
964 let binary = temp.path().join("codex-stdin-check");
965 let script = r#"#!/usr/bin/env bash
966set -euo pipefail
967
968prompt="$(cat)"
969if [[ "$prompt" != "passage: codex-cli" ]]; then
970 echo "unexpected stdin: $prompt" >&2
971 exit 41
972fi
973
974vals="0.0"
975for _ in $(seq 2 64); do
976 vals="$vals,0.0"
977done
978payload="{\"embedding\":[$vals]}"
979escaped="${payload//\"/\\\"}"
980echo "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"$escaped\"}}"
981"#;
982 std::fs::write(&binary, script).expect("mock codex script must be written");
983 let mut perms = std::fs::metadata(&binary)
984 .expect("mock codex metadata must exist")
985 .permissions();
986 perms.set_mode(0o755);
987 std::fs::set_permissions(&binary, perms).expect("mock codex must be executable");
988
989 let embedding = test_client(EmbeddingFlavour::Codex, binary);
990
991 let vector = embedding
992 .embed_passage("codex-cli")
993 .expect("stdin-backed codex embedding must succeed");
994
995 unsafe {
997 std::env::remove_var("SQLITE_GRAPHRAG_EMBEDDING_DIM");
998 }
999
1000 assert_eq!(vector.len(), 64);
1001 assert!(vector.iter().all(|value| *value == 0.0));
1002 }
1003}