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 = 120;
44
45fn embed_timeout() -> std::time::Duration {
46 let secs = std::env::var("SQLITE_GRAPHRAG_EMBED_TIMEOUT_SECS")
47 .ok()
48 .and_then(|v| v.parse::<u64>().ok())
49 .filter(|&n| (10..=3_600).contains(&n))
50 .unwrap_or(DEFAULT_EMBED_TIMEOUT_SECS);
51 std::time::Duration::from_secs(secs)
52}
53
54#[cfg(test)]
59fn embed_timeout_for_batch(batch_size: usize) -> std::time::Duration {
60 let base = embed_timeout();
61 let extra = std::time::Duration::from_secs(15) * batch_size.saturating_sub(1) as u32;
62 base + extra
63}
64
65fn extract_exit_info(status: &std::process::ExitStatus) -> (Option<i32>, Option<i32>) {
70 #[cfg(unix)]
71 {
72 use std::os::unix::process::ExitStatusExt;
73 (None, status.signal())
74 }
75 #[cfg(not(unix))]
76 {
77 let _ = status;
78 (None, None)
79 }
80}
81
82fn build_single_schema(dim: usize) -> String {
84 format!(
85 r#"{{"type":"object","properties":{{"embedding":{{"type":"array","items":{{"type":"number"}},"minItems":{dim},"maxItems":{dim}}}}},"required":["embedding"],"additionalProperties":false}}"#
86 )
87}
88
89fn build_batch_schema(dim: usize) -> String {
93 format!(
94 r#"{{"type":"object","properties":{{"items":{{"type":"array","items":{{"type":"object","properties":{{"i":{{"type":"integer"}},"v":{{"type":"array","items":{{"type":"number"}},"minItems":{dim},"maxItems":{dim}}}}},"required":["i","v"],"additionalProperties":false}}}}}},"required":["items"],"additionalProperties":false}}"#
95 )
96}
97
98#[derive(Clone, Debug)]
99pub struct LlmEmbedding {
100 flavour: EmbeddingFlavour,
102 binary: std::path::PathBuf,
104 model: String,
106 codex_schemas: Arc<parking_lot::Mutex<CodexSchemaFiles>>,
110 timeout_override: Option<std::time::Duration>,
113}
114
115#[derive(Debug, Default)]
116struct CodexSchemaFiles {
117 single: Option<(usize, Arc<tempfile::NamedTempFile>)>,
118 batch: Option<(usize, Arc<tempfile::NamedTempFile>)>,
119}
120
121#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
122pub enum EmbeddingFlavour {
123 Claude,
124 Codex,
125 Opencode,
126}
127
128#[derive(Clone, Debug)]
135pub struct LlmEmbeddingBuilder {
136 flavour: EmbeddingFlavour,
137 binary_override: Option<std::path::PathBuf>,
138 model_override: Option<String>,
139 timeout_override: Option<std::time::Duration>,
140}
141
142impl LlmEmbeddingBuilder {
143 pub fn claude_default() -> Self {
148 Self {
149 flavour: EmbeddingFlavour::Claude,
150 binary_override: None,
151 model_override: None,
152 timeout_override: None,
153 }
154 }
155
156 pub fn codex_default() -> Self {
159 Self {
160 flavour: EmbeddingFlavour::Codex,
161 binary_override: None,
162 model_override: None,
163 timeout_override: None,
164 }
165 }
166
167 pub fn opencode_default() -> Self {
170 Self {
171 flavour: EmbeddingFlavour::Opencode,
172 binary_override: None,
173 model_override: None,
174 timeout_override: None,
175 }
176 }
177 pub fn override_binary(mut self, binary: std::path::PathBuf) -> Self {
179 self.binary_override = Some(binary);
180 self
181 }
182
183 pub fn override_model(mut self, model: String) -> Self {
185 self.model_override = Some(model);
186 self
187 }
188
189 pub fn override_timeout(mut self, secs: u64) -> Self {
191 let clamped = secs.clamp(10, 3_600);
192 self.timeout_override = Some(std::time::Duration::from_secs(clamped));
193 self
194 }
195
196 pub fn build(self) -> Result<LlmEmbedding, AppError> {
199 LlmEmbedding::oauth_only_enforce()?;
200 let binary = match self.binary_override {
201 Some(path) => resolve_real_binary(&path),
202 None => {
203 let (env_var, which_name) = match self.flavour {
204 EmbeddingFlavour::Codex => ("SQLITE_GRAPHRAG_CODEX_BINARY", "codex"),
205 EmbeddingFlavour::Claude => ("SQLITE_GRAPHRAG_CLAUDE_BINARY", "claude"),
206 EmbeddingFlavour::Opencode => ("SQLITE_GRAPHRAG_OPENCODE_BINARY", "opencode"),
207 };
208 let path = std::env::var_os(env_var)
209 .map(std::path::PathBuf::from)
210 .or_else(|| which::which(which_name).ok())
211 .ok_or_else(|| {
212 AppError::Embedding(format!("`{which_name}` not found on PATH"))
213 })?;
214 resolve_real_binary(&path)
215 }
216 };
217 let model = match self.model_override {
218 Some(m) => m,
219 None => match self.flavour {
220 EmbeddingFlavour::Codex => codex_embed_model(),
221 EmbeddingFlavour::Claude => claude_embed_model(),
222 EmbeddingFlavour::Opencode => opencode_embed_model(),
223 },
224 };
225 Ok(LlmEmbedding {
226 flavour: self.flavour,
227 binary,
228 model,
229 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
230 timeout_override: self.timeout_override,
231 })
232 }
233}
234
235impl EmbeddingFlavour {
236 pub fn as_str(self) -> &'static str {
237 match self {
238 Self::Claude => "claude",
239 Self::Codex => "codex",
240 Self::Opencode => "opencode",
241 }
242 }
243}
244
245#[derive(Debug, Deserialize)]
246struct EmbeddingResponse {
247 embedding: Vec<f32>,
248}
249
250#[derive(Debug, Deserialize)]
251struct BatchEmbeddingResponse {
252 items: Vec<BatchEmbeddingItem>,
253}
254
255#[derive(Debug, Deserialize)]
256struct BatchEmbeddingItem {
257 i: usize,
258 v: Vec<f32>,
259}
260
261pub fn resolve_real_binary(path: &std::path::Path) -> std::path::PathBuf {
265 if let Ok(canonical) = std::fs::canonicalize(path) {
266 if is_elf_binary(&canonical) {
267 return canonical;
268 }
269 if let Some(exec_target) = extract_exec_target_from_shim(&canonical) {
270 if exec_target.exists() && is_elf_binary(&exec_target) {
271 return exec_target;
272 }
273 }
274 return canonical;
275 }
276 path.to_path_buf()
277}
278
279fn is_elf_binary(path: &std::path::Path) -> bool {
280 std::fs::read(path)
281 .map(|bytes| bytes.len() >= 4 && bytes[..4] == [0x7f, b'E', b'L', b'F'])
282 .unwrap_or(false)
283}
284
285fn extract_exec_target_from_shim(path: &std::path::Path) -> Option<std::path::PathBuf> {
286 let content = std::fs::read_to_string(path).ok()?;
287 if !content.starts_with("#!") {
288 return None;
289 }
290 for line in content.lines().rev() {
291 let trimmed = line.trim();
292 if trimmed.starts_with("exec ") {
293 let after_exec = trimmed.strip_prefix("exec ")?;
294 let binary = after_exec.split_whitespace().next()?;
295 return Some(std::path::PathBuf::from(binary));
296 }
297 }
298 None
299}
300
301fn claude_embed_model() -> String {
304 std::env::var("SQLITE_GRAPHRAG_CLAUDE_EMBED_MODEL")
306 .or_else(|_| std::env::var("SQLITE_GRAPHRAG_LLM_MODEL"))
307 .unwrap_or_else(|_| {
308 tracing::info!(
309 target: "llm_embedding",
310 "no model specified; defaulting to claude-sonnet-4-6"
311 );
312 "claude-sonnet-4-6".to_string()
313 })
314}
315
316fn codex_embed_model() -> String {
317 std::env::var("SQLITE_GRAPHRAG_CODEX_EMBED_MODEL")
319 .or_else(|_| std::env::var("SQLITE_GRAPHRAG_LLM_MODEL"))
320 .unwrap_or_else(|_| {
321 tracing::info!(
322 target: "llm_embedding",
323 "no model specified; defaulting to gpt-5.5"
324 );
325 "gpt-5.5".to_string()
326 })
327}
328
329fn opencode_embed_model() -> String {
330 std::env::var("SQLITE_GRAPHRAG_OPENCODE_EMBED_MODEL")
335 .or_else(|_| std::env::var("SQLITE_GRAPHRAG_OPENCODE_MODEL"))
336 .unwrap_or_else(|_| {
337 tracing::info!(
338 target: "llm_embedding",
339 "no model specified; defaulting to opencode/big-pickle"
340 );
341 "opencode/big-pickle".to_string()
342 })
343}
344
345impl LlmEmbedding {
346 pub fn detect_available() -> Result<Self, AppError> {
358 Self::oauth_only_enforce()?;
359
360 let codex_path = std::env::var_os("SQLITE_GRAPHRAG_CODEX_BINARY")
363 .map(std::path::PathBuf::from)
364 .or_else(|| which::which("codex").ok());
365 if let Some(path) = codex_path {
366 return Ok(Self {
367 flavour: EmbeddingFlavour::Codex,
368 binary: resolve_real_binary(&path),
369 model: codex_embed_model(),
370 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
371 timeout_override: None,
372 });
373 }
374 let claude_path = std::env::var_os("SQLITE_GRAPHRAG_CLAUDE_BINARY")
378 .map(std::path::PathBuf::from)
379 .or_else(|| which::which("claude").ok());
380 if let Some(path) = claude_path {
381 return Ok(Self {
382 flavour: EmbeddingFlavour::Claude,
383 binary: resolve_real_binary(&path),
384 model: claude_embed_model(),
385 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
386 timeout_override: None,
387 });
388 }
389 let opencode_path = std::env::var_os("SQLITE_GRAPHRAG_OPENCODE_BINARY")
391 .map(std::path::PathBuf::from)
392 .or_else(|| which::which("opencode").ok());
393 if let Some(path) = opencode_path {
394 return Ok(Self {
395 flavour: EmbeddingFlavour::Opencode,
396 binary: resolve_real_binary(&path),
397 model: opencode_embed_model(),
398 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
399 timeout_override: None,
400 });
401 }
402 Err(AppError::Embedding(
403 "no LLM CLI found on PATH: install `codex` (0.130+), `claude` (Claude Code 2.1+), or `opencode` (1.17+)"
404 .to_string(),
405 ))
406 }
407
408 fn instance_embed_timeout(&self) -> std::time::Duration {
411 if let Some(d) = self.timeout_override {
412 return d;
413 }
414 embed_timeout()
415 }
416
417 fn instance_embed_timeout_for_batch(&self, batch_size: usize) -> std::time::Duration {
419 let base = self.instance_embed_timeout();
420 let extra = std::time::Duration::from_secs(15) * batch_size.saturating_sub(1) as u32;
421 base + extra
422 }
423
424 pub fn with_codex() -> Result<Self, AppError> {
425 Self::with_codex_builder().build()
426 }
427
428 pub fn with_claude() -> Result<Self, AppError> {
429 Self::with_claude_builder().build()
430 }
431
432 pub fn with_codex_builder() -> LlmEmbeddingBuilder {
435 LlmEmbeddingBuilder {
436 flavour: EmbeddingFlavour::Codex,
437 binary_override: None,
438 model_override: None,
439 timeout_override: None,
440 }
441 }
442
443 pub fn with_claude_builder() -> LlmEmbeddingBuilder {
446 LlmEmbeddingBuilder {
447 flavour: EmbeddingFlavour::Claude,
448 binary_override: None,
449 model_override: None,
450 timeout_override: None,
451 }
452 }
453
454 pub fn with_opencode() -> Result<Self, AppError> {
455 Self::with_opencode_builder().build()
456 }
457
458 pub fn with_opencode_builder() -> LlmEmbeddingBuilder {
459 LlmEmbeddingBuilder {
460 flavour: EmbeddingFlavour::Opencode,
461 binary_override: None,
462 model_override: None,
463 timeout_override: None,
464 }
465 }
466 fn oauth_only_enforce() -> Result<(), AppError> {
471 if std::env::var("ANTHROPIC_API_KEY").is_ok() {
472 return Err(AppError::Validation(
473 "ANTHROPIC_API_KEY is set; v1.0.76 requires OAuth. \
474 unset it and use `claude login` instead."
475 .into(),
476 ));
477 }
478 if std::env::var("OPENAI_API_KEY").is_ok() {
479 return Err(AppError::Validation(
480 "OPENAI_API_KEY is set; v1.0.76 requires OAuth. \
481 unset it and use `codex login` instead."
482 .into(),
483 ));
484 }
485 Ok(())
486 }
487
488 pub fn embed_passage(&self, text: &str) -> Result<Vec<f32>, AppError> {
491 self.invoke_with_prefix(crate::constants::PASSAGE_PREFIX, text)
492 }
493
494 pub fn embed_query(&self, text: &str) -> Result<Vec<f32>, AppError> {
497 self.invoke_with_prefix(crate::constants::QUERY_PREFIX, text)
498 }
499
500 pub fn model_label(&self) -> String {
506 format!("{}:{}", self.flavour.as_str(), self.model)
507 }
508
509 pub fn flavour(&self) -> EmbeddingFlavour {
517 self.flavour
518 }
519
520 pub async fn embed_batch_async(
530 &self,
531 prefix: &str,
532 batch: &[(usize, String)],
533 ) -> Result<Vec<(usize, Vec<f32>)>, AppError> {
534 let dim = crate::constants::embedding_dim();
535 if batch.is_empty() {
536 return Ok(Vec::new());
537 }
538 if batch.len() == 1 {
539 let (idx, text) = (&batch[0].0, &batch[0].1);
540 let v = self.invoke_single_async(prefix, text, dim).await?;
541 return Ok(vec![(*idx, v)]);
542 }
543
544 let mut prompt = format!(
545 "Generate {dim}-dimensional semantic embedding vectors for each numbered text below.\n\
546 Return a JSON object with an \"items\" array containing EXACTLY {n} items.\n\
547 Each item has \"i\" (the 1-based index) and \"v\" (the {dim}-float vector, values between -1 and 1).\n\n",
548 n = batch.len()
549 );
550 for (pos, (_, text)) in batch.iter().enumerate() {
551 prompt.push_str(&format!("{}: {prefix}{text}\n", pos + 1));
552 }
553
554 let _batch_timeout = self.instance_embed_timeout_for_batch(batch.len());
557 let stdout = match self.flavour {
558 EmbeddingFlavour::Claude => {
559 self.invoke_claude(&prompt, &build_batch_schema(dim))
560 .await?
561 }
562 EmbeddingFlavour::Codex => {
563 let schema = self.codex_schema_file(dim, true)?;
564 self.invoke_codex(&prompt, schema.path()).await?
565 }
566 EmbeddingFlavour::Opencode => {
567 let opencode_prompt = format!(
568 "You are a batch embedding function. For each numbered text item below, \
569 generate an array of exactly {dim} floating-point numbers between -1 and 1 \
570 representing its semantic meaning. Output ONLY a JSON object with key \"items\" \
571 containing an array of objects, each with \"i\" (the 1-based index) and \
572 \"v\" (the {dim}-element float array). No markdown, no explanation.\n\n\
573 {prompt}"
574 );
575 self.invoke_opencode(&opencode_prompt).await?
576 }
577 };
578 let parsed: BatchEmbeddingResponse = parse_llm_json(&stdout).map_err(|e| {
579 AppError::Embedding(format!(
580 "LLM batch embedding response parse failed: {e}; raw={stdout}"
581 ))
582 })?;
583 if parsed.items.len() != batch.len() {
584 return Err(AppError::Embedding(format!(
585 "LLM batch returned {} items, expected {} (G42/S2 coverage check)",
586 parsed.items.len(),
587 batch.len()
588 )));
589 }
590 let mut out: Vec<Option<Vec<f32>>> = vec![None; batch.len()];
591 for item in parsed.items {
592 if item.i == 0 || item.i > batch.len() {
593 return Err(AppError::Embedding(format!(
594 "LLM batch item index {} out of range 1..={}",
595 item.i,
596 batch.len()
597 )));
598 }
599 if item.v.len() != dim {
600 return Err(AppError::Embedding(format!(
601 "LLM batch item {} returned {} dims, expected {dim}; \
602 refusing to truncate or pad silently (G42/C5)",
603 item.i,
604 item.v.len()
605 )));
606 }
607 out[item.i - 1] = Some(item.v);
608 }
609 let mut result = Vec::with_capacity(batch.len());
610 for (pos, slot) in out.into_iter().enumerate() {
611 let v = slot.ok_or_else(|| {
612 AppError::Embedding(format!(
613 "LLM batch response is missing item index {} (G42/S2 coverage check)",
614 pos + 1
615 ))
616 })?;
617 result.push((batch[pos].0, v));
618 }
619 Ok(result)
620 }
621
622 fn invoke_with_prefix(&self, prefix: &str, text: &str) -> Result<Vec<f32>, AppError> {
623 let dim = crate::constants::embedding_dim();
624 let inner = self.invoke_single_async(prefix, text, dim);
625 match tokio::runtime::Handle::try_current() {
630 Ok(handle) => tokio::task::block_in_place(|| handle.block_on(inner)),
631 Err(_) => crate::embedder::shared_runtime()?.block_on(inner),
632 }
633 }
634
635 async fn invoke_single_async(
636 &self,
637 prefix: &str,
638 text: &str,
639 dim: usize,
640 ) -> Result<Vec<f32>, AppError> {
641 let prompt = format!("{prefix}{text}");
642 let stdout = match self.flavour {
643 EmbeddingFlavour::Claude => {
644 self.invoke_claude(&prompt, &build_single_schema(dim))
645 .await?
646 }
647 EmbeddingFlavour::Codex => {
648 let schema = self.codex_schema_file(dim, false)?;
649 self.invoke_codex(&prompt, schema.path()).await?
650 }
651 EmbeddingFlavour::Opencode => {
652 let opencode_prompt = format!(
653 "You are an embedding function. Given the input text, output a JSON object \
654 with a single key \"embedding\" containing an array of exactly {dim} \
655 floating-point numbers between -1 and 1 that represent the semantic meaning \
656 of the text. Output ONLY the JSON object, nothing else.\n\n\
657 Input text: \"{prompt}\""
658 );
659 self.invoke_opencode(&opencode_prompt).await?
660 }
661 };
662 let parsed: EmbeddingResponse = parse_llm_json(&stdout).map_err(|e| {
663 AppError::Embedding(format!(
664 "LLM embedding response parse failed: {e}; raw={stdout}"
665 ))
666 })?;
667 if parsed.embedding.len() != dim {
668 return Err(AppError::Embedding(format!(
669 "LLM returned {} dims, expected {dim}; \
670 refusing to truncate or pad silently (G42/C5)",
671 parsed.embedding.len()
672 )));
673 }
674 Ok(parsed.embedding)
675 }
676
677 fn codex_schema_file(
682 &self,
683 dim: usize,
684 batch: bool,
685 ) -> Result<Arc<tempfile::NamedTempFile>, AppError> {
686 let mut guard = self.codex_schemas.lock();
687 let slot = if batch {
688 &mut guard.batch
689 } else {
690 &mut guard.single
691 };
692 if let Some((cached_dim, file)) = slot {
693 if *cached_dim == dim {
694 return Ok(Arc::clone(file));
695 }
696 }
697 let content = if batch {
698 build_batch_schema(dim)
699 } else {
700 build_single_schema(dim)
701 };
702 let file = tempfile::Builder::new()
703 .prefix("sqlite-graphrag-embed-schema-")
704 .suffix(".json")
705 .tempfile()
706 .map_err(|e| AppError::Embedding(format!("schema tempfile create failed: {e}")))?;
707 std::fs::write(file.path(), content)
708 .map_err(|e| AppError::Embedding(format!("schema tempfile write failed: {e}")))?;
709 let file = Arc::new(file);
710 *slot = Some((dim, Arc::clone(&file)));
711 Ok(file)
712 }
713
714 async fn invoke_claude(&self, prompt: &str, schema: &str) -> Result<String, AppError> {
715 let spawn_dir = crate::spawn::spawn_isolation_dir()?;
735 let mcp_config_path = crate::spawn::preflight::write_empty_mcp_config_tempfile()?;
736 let argv_refs: [std::ffi::OsString; 0] = [];
737 let preflight_args = crate::spawn::preflight::PreFlightArgs {
738 binary_path: &self.binary,
739 argv: &argv_refs,
740 workspace_root: &spawn_dir,
741 mcp_config_inline_json: None,
742 expected_output_bytes: 65_536,
743 spawner_name: "llm_embedding",
744 };
745 crate::spawn::preflight::preflight_check(&preflight_args)?;
746 let mut cmd = Command::new(&self.binary);
747 cmd.arg("-p")
748 .arg(prompt)
749 .arg("--model")
750 .arg(&self.model)
751 .arg("--json-schema")
752 .arg(schema)
753 .arg("--output-format")
754 .arg("json")
755 .arg("--strict-mcp-config")
756 .arg("--mcp-config")
757 .arg(mcp_config_path.as_os_str())
758 .arg("--settings")
759 .arg(r#"{"hooks":{}}"#)
760 .arg("--dangerously-skip-permissions")
761 .env_clear()
762 .env("PATH", std::env::var("PATH").unwrap_or_default())
763 .env("HOME", std::env::var("HOME").unwrap_or_default())
764 .stdin(Stdio::null())
765 .stdout(Stdio::piped())
766 .stderr(Stdio::piped())
767 .kill_on_drop(true);
769 cmd.current_dir(&spawn_dir);
771 cmd.env("CLAUDE_CONFIG_DIR", &spawn_dir);
772 if let Some(config_dir) = claude_embedding_config_dir() {
773 cmd.env("CLAUDE_CONFIG_DIR", &config_dir);
774 }
775 let binary_str = self.binary.to_string_lossy().into_owned();
776 let output = match tokio::time::timeout(self.instance_embed_timeout(), cmd.output()).await {
777 Err(_elapsed) => {
778 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
779 &crate::llm::exit_code_hints::LlmBackendError::Timeout {
780 secs: self.instance_embed_timeout().as_secs(),
781 binary: binary_str.clone(),
782 },
783 ));
784 }
785 Ok(Err(e)) => {
786 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
787 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
788 binary: binary_str.clone(),
789 source: e.to_string(),
790 },
791 ));
792 }
793 Ok(Ok(o)) => o,
794 };
795 let stdout_str = String::from_utf8_lossy(&output.stdout);
802 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stdout_str) {
803 let is_rate_limited = parsed
804 .get("is_error")
805 .and_then(|v| v.as_bool())
806 .unwrap_or(false)
807 && parsed
808 .get("result")
809 .and_then(|v| v.as_str())
810 .map(|s| {
811 s.contains("rate limit")
812 || s.contains("quota")
813 || s.contains("anthropic-ratelimit")
814 })
815 .unwrap_or(false);
816 if is_rate_limited {
817 return Err(AppError::Embedding(format!(
818 "OAuth usage quota exhausted: claude rate_limit detected in stdout: {}",
819 parsed
820 .get("result")
821 .and_then(|v| v.as_str())
822 .unwrap_or("")
823 .chars()
824 .take(120)
825 .collect::<String>()
826 )));
827 }
828 }
829 if !output.status.success() {
830 let (exit_code, signal) = if let Some(code) = output.status.code() {
831 (Some(code), None)
832 } else {
833 extract_exit_info(&output.status)
834 };
835 let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
836 &output.stdout,
837 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
838 );
839 let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
840 &output.stderr,
841 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
842 );
843 let mut hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
844 if stderr_tail.contains("401")
846 || stderr_tail.contains("Unauthorized")
847 || stderr_tail.contains("expired")
848 || stderr_tail.contains("login")
849 || stdout_tail.contains("401")
850 || stdout_tail.contains("Unauthorized")
851 {
852 hint.push_str(" | Claude OAuth token may be expired; run `claude login` to renew");
853 }
854 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
855 &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
856 exit_code,
857 signal,
858 stdout_tail,
859 stderr_tail,
860 binary: binary_str,
861 hint,
862 },
863 ));
864 }
865 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
866 }
867
868 async fn invoke_codex(
869 &self,
870 prompt: &str,
871 schema_path: &std::path::Path,
872 ) -> Result<String, AppError> {
873 let binary_str = self.binary.to_string_lossy().into_owned();
874 let mut cmd = build_codex_embedding_command(&self.binary, &self.model, schema_path)?;
875
876 let argv_refs: [std::ffi::OsString; 0] = [];
890 let preflight_args = crate::spawn::preflight::PreFlightArgs {
891 binary_path: &self.binary,
892 argv: &argv_refs,
893 workspace_root: std::path::Path::new("."),
894 mcp_config_inline_json: None,
895 expected_output_bytes: 65_536,
896 spawner_name: "llm_embedding",
897 };
898 crate::spawn::preflight::preflight_check(&preflight_args)?;
899 let _ = binary_str; let mut child = match cmd.spawn() {
902 Ok(c) => c,
903 Err(e) => {
904 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
905 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
906 binary: binary_str,
907 source: e.to_string(),
908 },
909 ));
910 }
911 };
912 if let Some(mut stdin) = child.stdin.take() {
913 stdin
914 .write_all(prompt.as_bytes())
915 .await
916 .map_err(|e| AppError::Embedding(format!("codex stdin write failed: {e}")))?;
917 drop(stdin);
918 }
919 let output =
920 match tokio::time::timeout(self.instance_embed_timeout(), child.wait_with_output())
921 .await
922 {
923 Err(_elapsed) => {
924 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
925 &crate::llm::exit_code_hints::LlmBackendError::Timeout {
926 secs: self.instance_embed_timeout().as_secs(),
927 binary: binary_str,
928 },
929 ));
930 }
931 Ok(Err(e)) => {
932 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
933 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
934 binary: binary_str,
935 source: format!("codex wait failed: {e}"),
936 },
937 ));
938 }
939 Ok(Ok(o)) => o,
940 };
941 if !output.status.success() {
942 let (exit_code, signal) = if let Some(code) = output.status.code() {
943 (Some(code), None)
944 } else {
945 extract_exit_info(&output.status)
946 };
947 let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
948 &output.stdout,
949 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
950 );
951 let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
952 &output.stderr,
953 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
954 );
955 let hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
956 let mut combined_hint = hint;
961 if stderr_tail.contains("request_user_input") {
962 combined_hint.push_str(
963 " | codex requested interactive input in a headless embedding call; \
964 upgrade codex (>= 0.134) or switch the embedding backend to claude",
965 );
966 }
967 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
968 &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
969 exit_code,
970 signal,
971 stdout_tail,
972 stderr_tail,
973 binary: binary_str,
974 hint: combined_hint,
975 },
976 ));
977 }
978 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
979 }
980
981 async fn invoke_opencode(&self, prompt: &str) -> Result<String, AppError> {
982 let binary_str = self.binary.to_string_lossy().into_owned();
983 let spawn_dir = crate::spawn::spawn_isolation_dir()?;
984 let mut cmd = Command::new(&self.binary);
985 cmd.current_dir(&spawn_dir);
986 cmd.arg("run")
987 .arg("--format")
988 .arg("json")
989 .arg("-m")
990 .arg(&self.model)
991 .arg("--dangerously-skip-permissions")
992 .arg(prompt)
993 .env_clear()
994 .env("PATH", std::env::var("PATH").unwrap_or_default())
995 .env("HOME", std::env::var("HOME").unwrap_or_default())
996 .stdin(Stdio::null())
997 .stdout(Stdio::piped())
998 .stderr(Stdio::piped())
999 .kill_on_drop(true);
1000 crate::commands::opencode_runner::propagate_opencode_env(&mut cmd);
1001
1002 let output = match tokio::time::timeout(self.instance_embed_timeout(), cmd.output()).await {
1003 Err(_elapsed) => {
1004 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
1005 &crate::llm::exit_code_hints::LlmBackendError::Timeout {
1006 secs: self.instance_embed_timeout().as_secs(),
1007 binary: binary_str.clone(),
1008 },
1009 ));
1010 }
1011 Ok(Err(e)) => {
1012 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
1013 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
1014 binary: binary_str.clone(),
1015 source: e.to_string(),
1016 },
1017 ));
1018 }
1019 Ok(Ok(o)) => o,
1020 };
1021 if !output.status.success() {
1022 let (exit_code, signal) = if let Some(code) = output.status.code() {
1023 (Some(code), None)
1024 } else {
1025 extract_exit_info(&output.status)
1026 };
1027 let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
1028 &output.stdout,
1029 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
1030 );
1031 let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
1032 &output.stderr,
1033 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
1034 );
1035 let hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
1036 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
1037 &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
1038 exit_code,
1039 signal,
1040 stdout_tail,
1041 stderr_tail,
1042 binary: binary_str,
1043 hint,
1044 },
1045 ));
1046 }
1047 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1048 }
1049}
1050
1051fn claude_embedding_config_dir() -> Option<std::path::PathBuf> {
1065 if let Ok(dir) = std::env::var("SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR") {
1066 let path = std::path::PathBuf::from(dir);
1067 if path.is_dir() {
1068 return Some(path);
1069 }
1070 tracing::warn!(
1071 target: "embedding",
1072 path = %path.display(),
1073 "SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR is set but not a directory; \
1074 falling back to the managed empty config dir"
1075 );
1076 }
1077 let home = std::env::var("HOME").ok()?;
1078 let dir = std::path::Path::new(&home)
1079 .join(".local/state/sqlite-graphrag")
1080 .join("claude-empty-config");
1081 if std::fs::create_dir_all(&dir).is_err() {
1082 return None;
1083 }
1084 #[cfg(unix)]
1085 {
1086 use std::os::unix::fs::PermissionsExt;
1087 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
1088 }
1089 let creds = std::path::Path::new(&home).join(".claude/.credentials.json");
1094 if creds.exists() {
1095 let target = dir.join(".credentials.json");
1096 let _ = std::fs::copy(&creds, &target);
1097 }
1098 Some(dir)
1099}
1100
1101fn build_codex_embedding_command(
1102 binary: &std::path::Path,
1103 model: &str,
1104 schema_path: &std::path::Path,
1105) -> Result<Command, AppError> {
1106 let spawn_dir = crate::spawn::spawn_isolation_dir()?;
1107 let mut cmd = Command::new(binary);
1108 cmd.current_dir(&spawn_dir);
1109 cmd.arg("exec")
1110 .arg("-c")
1111 .arg("sandbox_mode='read-only'")
1112 .arg("-c")
1113 .arg("approval_policy='never'")
1114 .arg("--json")
1115 .arg("--output-schema")
1116 .arg(schema_path)
1117 .arg("--ephemeral")
1118 .arg("--skip-git-repo-check")
1119 .arg("--sandbox")
1120 .arg("read-only")
1121 .arg("--ignore-user-config")
1122 .arg("--ignore-rules");
1123 if crate::extract::codex_compat::codex_supports_ask_for_approval() {
1124 cmd.arg("--ask-for-approval").arg("never");
1125 }
1126 cmd.arg("--model")
1132 .arg(model)
1133 .arg("-")
1134 .env_clear()
1135 .env("PATH", std::env::var("PATH").unwrap_or_default())
1136 .env("HOME", std::env::var("HOME").unwrap_or_default());
1137 if let Ok(codex_home) = std::env::var("CODEX_HOME") {
1138 cmd.env("CODEX_HOME", codex_home);
1139 } else if let Ok(home) = std::env::var("HOME") {
1140 let default_home = std::path::Path::new(&home).join(".codex");
1141 if default_home.exists() {
1142 cmd.env("CODEX_HOME", &default_home);
1143 }
1144 }
1145 cmd.stdin(Stdio::piped())
1146 .stdout(Stdio::piped())
1147 .stderr(Stdio::piped())
1148 .kill_on_drop(true);
1150 Ok(cmd)
1151}
1152
1153fn parse_llm_json<T: serde::de::DeserializeOwned>(stdout: &str) -> Result<T, String> {
1166 if let Ok(parsed) = serde_json::from_str::<T>(stdout) {
1168 return Ok(parsed);
1169 }
1170 let mut opencode_texts: Vec<String> = Vec::new();
1173 for line in stdout.lines() {
1174 let line = line.trim();
1175 if line.is_empty() {
1176 continue;
1177 }
1178 let Ok(event) = serde_json::from_str::<serde_json::Value>(line) else {
1179 continue;
1180 };
1181 if event.get("type").and_then(|t| t.as_str()) == Some("text") {
1182 if let Some(text) = event
1183 .get("part")
1184 .and_then(|p| p.get("text"))
1185 .and_then(|t| t.as_str())
1186 {
1187 opencode_texts.push(text.to_string());
1188 }
1189 }
1190 }
1191 if !opencode_texts.is_empty() {
1192 let combined = opencode_texts.concat();
1193 if let Ok(parsed) = serde_json::from_str::<T>(&combined) {
1194 return Ok(parsed);
1195 }
1196 }
1197 let mut last_agent_text: Option<String> = None;
1200 for line in stdout.lines() {
1201 let line = line.trim();
1202 if line.is_empty() {
1203 continue;
1204 }
1205 let Ok(event) = serde_json::from_str::<serde_json::Value>(line) else {
1206 continue;
1207 };
1208 if event.get("type").and_then(|t| t.as_str()) != Some("item.completed") {
1209 continue;
1210 }
1211 let item = match event.get("item") {
1212 Some(i) => i,
1213 None => continue,
1214 };
1215 if item.get("type").and_then(|t| t.as_str()) != Some("agent_message") {
1216 continue;
1217 }
1218 if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
1219 last_agent_text = Some(text.to_string());
1220 }
1221 }
1222 let text = last_agent_text
1223 .ok_or_else(|| "no agent_message found in codex JSONL output".to_string())?;
1224 serde_json::from_str::<T>(&text)
1225 .map_err(|e| format!("codex agent_message text does not match schema: {e}; raw={text}"))
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230 use super::*;
1231
1232 fn test_client(flavour: EmbeddingFlavour, binary: std::path::PathBuf) -> LlmEmbedding {
1233 LlmEmbedding {
1234 flavour,
1235 binary,
1236 model: "gpt-5.4".to_string(),
1237 codex_schemas: Arc::new(parking_lot::Mutex::new(CodexSchemaFiles::default())),
1238 timeout_override: None,
1239 }
1240 }
1241
1242 #[test]
1243 fn embed_timeout_default_is_120() {
1244 assert_eq!(DEFAULT_EMBED_TIMEOUT_SECS, 120);
1245 }
1246
1247 #[test]
1248 #[serial_test::serial(env)]
1249 fn oauth_only_enforce_blocks_api_keys() {
1250 unsafe {
1253 std::env::set_var("ANTHROPIC_API_KEY", "test");
1254 assert!(LlmEmbedding::oauth_only_enforce().is_err());
1255 std::env::remove_var("ANTHROPIC_API_KEY");
1256
1257 std::env::set_var("OPENAI_API_KEY", "test");
1258 assert!(LlmEmbedding::oauth_only_enforce().is_err());
1259 std::env::remove_var("OPENAI_API_KEY");
1260 }
1261 assert!(LlmEmbedding::oauth_only_enforce().is_ok());
1262 }
1263
1264 #[test]
1265 fn flavour_as_str_is_stable() {
1266 assert_eq!(EmbeddingFlavour::Claude.as_str(), "claude");
1267 assert_eq!(EmbeddingFlavour::Codex.as_str(), "codex");
1268 }
1269
1270 #[test]
1271 fn single_schema_embeds_active_dim() {
1272 let schema = build_single_schema(64);
1273 assert!(schema.contains(r#""minItems":64"#));
1274 assert!(schema.contains(r#""maxItems":64"#));
1275 let parsed: serde_json::Value =
1276 serde_json::from_str(&schema).expect("single schema must be valid JSON");
1277 assert_eq!(parsed["properties"]["embedding"]["minItems"], 64);
1278 }
1279
1280 #[test]
1281 fn batch_schema_is_valid_json_and_unbounded_items() {
1282 let schema = build_batch_schema(64);
1283 let parsed: serde_json::Value =
1284 serde_json::from_str(&schema).expect("batch schema must be valid JSON");
1285 assert!(parsed["properties"]["items"].get("minItems").is_none());
1288 assert_eq!(
1289 parsed["properties"]["items"]["items"]["properties"]["v"]["minItems"],
1290 64
1291 );
1292 }
1293
1294 #[test]
1295 fn parse_llm_json_accepts_claude_json() {
1296 let stdout = r#"{"embedding":[0.0,1.0,2.0]}"#;
1297
1298 let parsed: EmbeddingResponse = parse_llm_json(stdout).expect("claude JSON must parse");
1299
1300 assert_eq!(parsed.embedding, vec![0.0, 1.0, 2.0]);
1301 }
1302
1303 #[test]
1304 fn parse_llm_json_accepts_codex_jsonl() {
1305 let stdout = r#"{"type":"thread.started","thread_id":"mock-thread-0"}
1306{"type":"item.completed","item":{"type":"agent_message","text":"{\"embedding\":[0.0,1.0,2.0]}"}}
1307{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}"#;
1308
1309 let parsed: EmbeddingResponse = parse_llm_json(stdout).expect("codex JSONL must parse");
1310
1311 assert_eq!(parsed.embedding, vec![0.0, 1.0, 2.0]);
1312 }
1313
1314 #[test]
1315 fn parse_llm_json_rejects_jsonl_without_agent_message() {
1316 let stdout = r#"{"type":"thread.started","thread_id":"mock-thread-0"}"#;
1317
1318 let err = parse_llm_json::<EmbeddingResponse>(stdout)
1319 .expect_err("missing agent_message must fail");
1320
1321 assert!(err.contains("no agent_message"));
1322 }
1323
1324 #[test]
1325 fn parse_llm_json_accepts_batch_response() {
1326 let stdout = r#"{"items":[{"i":1,"v":[0.0,1.0]},{"i":2,"v":[2.0,3.0]}]}"#;
1327
1328 let parsed: BatchEmbeddingResponse = parse_llm_json(stdout).expect("batch JSON must parse");
1329
1330 assert_eq!(parsed.items.len(), 2);
1331 assert_eq!(parsed.items[0].i, 1);
1332 assert_eq!(parsed.items[1].v, vec![2.0, 3.0]);
1333 }
1334
1335 #[test]
1336 fn codex_schema_file_is_created_once_and_reused() {
1337 let client = test_client(
1338 EmbeddingFlavour::Codex,
1339 std::path::PathBuf::from("/bin/true"),
1340 );
1341 let first = client
1342 .codex_schema_file(64, false)
1343 .expect("schema file must be created");
1344 let second = client
1345 .codex_schema_file(64, false)
1346 .expect("schema file must be reused");
1347 assert_eq!(first.path(), second.path(), "same dim must reuse the file");
1348
1349 let batch = client
1350 .codex_schema_file(64, true)
1351 .expect("batch schema file must be created");
1352 assert_ne!(
1353 first.path(),
1354 batch.path(),
1355 "single and batch schemas are distinct files"
1356 );
1357
1358 let content = std::fs::read_to_string(first.path()).expect("schema file must be readable");
1359 assert!(content.contains(r#""minItems":64"#));
1360 }
1361
1362 #[test]
1363 fn codex_embedding_command_reads_prompt_from_stdin() {
1364 let schema_path = std::env::temp_dir().join("sqlite-graphrag-embed-schema-test.json");
1365 let cmd = build_codex_embedding_command(
1366 std::path::Path::new("/bin/true"),
1367 "gpt-5.4",
1368 &schema_path,
1369 )
1370 .expect("build_codex_embedding_command must succeed in test");
1371 let argv: Vec<String> = cmd
1372 .as_std()
1373 .get_args()
1374 .filter_map(|arg| arg.to_str().map(|s| s.to_string()))
1375 .collect();
1376
1377 assert!(
1378 argv.iter().any(|arg| arg == "-"),
1379 "codex embedding command must read prompt from stdin: {argv:?}"
1380 );
1381 assert!(
1382 !argv.iter().any(|arg| arg.starts_with("passage: ")),
1383 "prompt text must not be passed as argv: {argv:?}"
1384 );
1385 for required in &[
1386 "exec",
1387 "-c",
1388 "sandbox_mode='read-only'",
1389 "approval_policy='never'",
1390 "--json",
1391 "--output-schema",
1392 "--ephemeral",
1393 "--skip-git-repo-check",
1394 "--sandbox",
1395 "read-only",
1396 "--ignore-user-config",
1397 "--ignore-rules",
1398 "--model",
1399 "gpt-5.4",
1400 ] {
1401 assert!(
1402 argv.iter().any(|arg| arg == required),
1403 "missing flag {required} in {argv:?}"
1404 );
1405 }
1406 }
1407
1408 #[cfg(unix)]
1409 #[test]
1410 #[serial_test::serial(env)]
1411 fn embed_passage_sends_prompt_to_codex_stdin() {
1412 use std::os::unix::fs::PermissionsExt;
1413
1414 unsafe {
1418 std::env::set_var("SQLITE_GRAPHRAG_EMBEDDING_DIM", "64");
1419 }
1420
1421 let temp = tempfile::tempdir().expect("tempdir must exist");
1422 let binary = temp.path().join("codex-stdin-check");
1423 let script = r#"#!/usr/bin/env bash
1424set -euo pipefail
1425
1426prompt="$(cat)"
1427if [[ "$prompt" != "passage: codex-cli" ]]; then
1428 echo "unexpected stdin: $prompt" >&2
1429 exit 41
1430fi
1431
1432vals="0.0"
1433for _ in $(seq 2 64); do
1434 vals="$vals,0.0"
1435done
1436payload="{\"embedding\":[$vals]}"
1437escaped="${payload//\"/\\\"}"
1438echo "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"$escaped\"}}"
1439"#;
1440 std::fs::write(&binary, script).expect("mock codex script must be written");
1441 let mut perms = std::fs::metadata(&binary)
1442 .expect("mock codex metadata must exist")
1443 .permissions();
1444 perms.set_mode(0o755);
1445 std::fs::set_permissions(&binary, perms).expect("mock codex must be executable");
1446
1447 let embedding = test_client(EmbeddingFlavour::Codex, binary);
1448
1449 let vector = embedding
1450 .embed_passage("codex-cli")
1451 .expect("stdin-backed codex embedding must succeed");
1452
1453 unsafe {
1455 std::env::remove_var("SQLITE_GRAPHRAG_EMBEDDING_DIM");
1456 }
1457
1458 assert_eq!(vector.len(), 64);
1459 assert!(vector.iter().all(|value| *value == 0.0));
1460 }
1461
1462 #[test]
1472 fn claude_default_resolves_path() {
1473 let builder = LlmEmbeddingBuilder::claude_default();
1474 assert_eq!(builder.flavour, EmbeddingFlavour::Claude);
1475 assert!(builder.binary_override.is_none());
1476 assert!(builder.model_override.is_none());
1477 }
1478
1479 #[test]
1483 fn override_binary_uses_provided() {
1484 let path = std::path::PathBuf::from("/tmp/fake-claude-binary");
1485 let builder = LlmEmbeddingBuilder::claude_default().override_binary(path.clone());
1486 assert_eq!(builder.binary_override.as_ref(), Some(&path));
1487 }
1488
1489 #[test]
1493 fn override_model_uses_provided() {
1494 let builder =
1495 LlmEmbeddingBuilder::codex_default().override_model("gpt-5.4-custom".to_string());
1496 assert_eq!(builder.model_override.as_deref(), Some("gpt-5.4-custom"));
1497 }
1498
1499 #[test]
1504 fn embed_timeout_for_batch_scales_with_size() {
1505 let t1 = embed_timeout_for_batch(1);
1506 let t4 = embed_timeout_for_batch(4);
1507 let t8 = embed_timeout_for_batch(8);
1508 assert!(
1509 t1 < t4,
1510 "batch of 4 must have longer timeout than batch of 1"
1511 );
1512 assert!(
1513 t4 < t8,
1514 "batch of 8 must have longer timeout than batch of 4"
1515 );
1516 assert_eq!(t8 - t1, std::time::Duration::from_secs(15 * 7));
1517 }
1518
1519 #[test]
1520 fn embed_timeout_for_batch_single_equals_base() {
1521 let base = embed_timeout();
1522 let single = embed_timeout_for_batch(1);
1523 assert_eq!(base, single);
1524 }
1525
1526 #[test]
1527 fn opencode_flavour_as_str() {
1528 assert_eq!(EmbeddingFlavour::Opencode.as_str(), "opencode");
1529 }
1530
1531 #[test]
1532 #[serial_test::serial(env)]
1533 fn opencode_embed_model_uses_env_override() {
1534 unsafe {
1535 std::env::set_var(
1536 "SQLITE_GRAPHRAG_OPENCODE_EMBED_MODEL",
1537 "opencode/test-model",
1538 );
1539 let model = opencode_embed_model();
1540 std::env::remove_var("SQLITE_GRAPHRAG_OPENCODE_EMBED_MODEL");
1541 assert_eq!(model, "opencode/test-model");
1542 }
1543 }
1544
1545 #[test]
1546 #[serial_test::serial(env)]
1547 fn opencode_embed_model_falls_back_to_opencode_model() {
1548 unsafe {
1549 std::env::remove_var("SQLITE_GRAPHRAG_OPENCODE_EMBED_MODEL");
1550 std::env::set_var("SQLITE_GRAPHRAG_OPENCODE_MODEL", "opencode/fallback");
1551 let model = opencode_embed_model();
1552 std::env::remove_var("SQLITE_GRAPHRAG_OPENCODE_MODEL");
1553 assert_eq!(model, "opencode/fallback");
1554 }
1555 }
1556
1557 #[test]
1558 #[serial_test::serial(env)]
1559 fn opencode_embed_model_ignores_llm_model() {
1560 unsafe {
1561 std::env::remove_var("SQLITE_GRAPHRAG_OPENCODE_EMBED_MODEL");
1562 std::env::remove_var("SQLITE_GRAPHRAG_OPENCODE_MODEL");
1563 std::env::set_var("SQLITE_GRAPHRAG_LLM_MODEL", "gpt-5.4-mini");
1564 let model = opencode_embed_model();
1565 std::env::remove_var("SQLITE_GRAPHRAG_LLM_MODEL");
1566 assert_eq!(
1567 model, "opencode/big-pickle",
1568 "must NOT cross-contaminate with LLM_MODEL"
1569 );
1570 }
1571 }
1572
1573 #[test]
1574 fn parse_llm_json_accepts_opencode_ndjson() {
1575 let stdout = r#"{"type":"step_start","timestamp":1234,"sessionID":"ses_test","part":{"type":"step-start"}}
1576{"type":"text","timestamp":1235,"sessionID":"ses_test","part":{"type":"text","text":"{\"embedding\":[0.1,0.2,0.3]}"}}
1577{"type":"step_finish","timestamp":1236,"sessionID":"ses_test","part":{"type":"step-finish","tokens":{"total":100,"input":90,"output":10,"reasoning":0},"cost":0}}"#;
1578
1579 let parsed: EmbeddingResponse = parse_llm_json(stdout).expect("opencode NDJSON must parse");
1580 assert_eq!(parsed.embedding, vec![0.1, 0.2, 0.3]);
1581 }
1582
1583 #[test]
1584 fn parse_llm_json_accepts_opencode_batch_ndjson() {
1585 let stdout = r#"{"type":"step_start","timestamp":1234,"sessionID":"ses_test","part":{"type":"step-start"}}
1586{"type":"text","timestamp":1235,"sessionID":"ses_test","part":{"type":"text","text":"{\"items\":[{\"i\":1,\"v\":[0.1,0.2]},{\"i\":2,\"v\":[0.3,0.4]}]}"}}
1587{"type":"step_finish","timestamp":1236,"sessionID":"ses_test","part":{"type":"step-finish","tokens":{"total":100,"input":90,"output":10,"reasoning":0},"cost":0}}"#;
1588
1589 let parsed: BatchEmbeddingResponse =
1590 parse_llm_json(stdout).expect("opencode batch NDJSON must parse");
1591 assert_eq!(parsed.items.len(), 2);
1592 assert_eq!(parsed.items[0].i, 1);
1593 assert_eq!(parsed.items[1].v, vec![0.3, 0.4]);
1594 }
1595
1596 #[test]
1597 fn opencode_builder_default_has_correct_flavour() {
1598 let builder = LlmEmbeddingBuilder::opencode_default();
1599 assert_eq!(builder.flavour, EmbeddingFlavour::Opencode);
1600 assert!(builder.binary_override.is_none());
1601 assert!(builder.model_override.is_none());
1602 }
1603}