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