1use super::timeout::extract_exit_info;
4use super::types::EmbeddingFlavour;
5use super::wire::{
6 build_batch_schema, build_single_schema, parse_llm_json, BatchEmbeddingResponse,
7 EmbeddingResponse,
8};
9use super::LlmEmbedding;
10use crate::errors::AppError;
11use std::process::Stdio;
12use std::sync::Arc;
13use tokio::io::AsyncWriteExt;
14use tokio::process::Command;
15
16impl LlmEmbedding {
17 pub async fn embed_batch_async(
26 &self,
27 prefix: &str,
28 batch: &[(usize, String)],
29 ) -> Result<Vec<(usize, Vec<f32>)>, AppError> {
30 let dim = crate::constants::embedding_dim();
31 if batch.is_empty() {
32 return Ok(Vec::new());
33 }
34 if batch.len() == 1 {
35 let (idx, text) = (&batch[0].0, &batch[0].1);
36 let v = self.invoke_single_async(prefix, text, dim).await?;
37 return Ok(vec![(*idx, v)]);
38 }
39
40 let mut prompt = format!(
41 "Generate {dim}-dimensional semantic embedding vectors for each numbered text below.\n\
42 Return a JSON object with an \"items\" array containing EXACTLY {n} items.\n\
43 Each item has \"i\" (the 1-based index) and \"v\" (the {dim}-float vector, values between -1 and 1).\n\n",
44 n = batch.len()
45 );
46 for (pos, (_, text)) in batch.iter().enumerate() {
47 prompt.push_str(&format!("{}: {prefix}{text}\n", pos + 1));
48 }
49
50 let _batch_timeout = self.instance_embed_timeout_for_batch(batch.len());
53 let stdout = match self.flavour {
54 EmbeddingFlavour::Claude => {
55 self.invoke_claude(&prompt, &build_batch_schema(dim))
56 .await?
57 }
58 EmbeddingFlavour::Codex => {
59 let schema = self.codex_schema_file(dim, true)?;
60 self.invoke_codex(&prompt, schema.path()).await?
61 }
62 EmbeddingFlavour::Opencode => {
63 let opencode_prompt = format!(
64 "You are a batch embedding function. For each numbered text item below, \
65 generate an array of exactly {dim} floating-point numbers between -1 and 1 \
66 representing its semantic meaning. Output ONLY a JSON object with key \"items\" \
67 containing an array of objects, each with \"i\" (the 1-based index) and \
68 \"v\" (the {dim}-element float array). No markdown, no explanation.\n\n\
69 {prompt}"
70 );
71 self.invoke_opencode(&opencode_prompt).await?
72 }
73 };
74 let parsed: BatchEmbeddingResponse = parse_llm_json(&stdout).map_err(|e| {
75 AppError::Embedding(crate::i18n::validation::embedding_llm_batch_parse_failed(
76 e, &stdout,
77 ))
78 })?;
79 if parsed.items.len() != batch.len() {
80 return Err(AppError::Embedding(
81 crate::i18n::validation::embedding_llm_batch_item_count(
82 parsed.items.len(),
83 batch.len(),
84 ),
85 ));
86 }
87 let mut out: Vec<Option<Vec<f32>>> = vec![None; batch.len()];
88 for item in parsed.items {
89 if item.i == 0 || item.i > batch.len() {
90 return Err(AppError::Embedding(
91 crate::i18n::validation::embedding_llm_batch_index_out_of_range(
92 item.i,
93 batch.len(),
94 ),
95 ));
96 }
97 if item.v.len() != dim {
98 return Err(AppError::Embedding(
99 crate::i18n::validation::embedding_llm_batch_item_dims(
100 item.i,
101 item.v.len(),
102 dim,
103 ),
104 ));
105 }
106 out[item.i - 1] = Some(item.v);
107 }
108 let mut result = Vec::with_capacity(batch.len());
109 for (pos, slot) in out.into_iter().enumerate() {
110 let v = slot.ok_or_else(|| {
111 AppError::Embedding(crate::i18n::validation::embedding_llm_batch_missing_item(
112 pos + 1,
113 ))
114 })?;
115 result.push((batch[pos].0, v));
116 }
117 Ok(result)
118 }
119
120 pub(crate) fn invoke_with_prefix(
121 &self,
122 prefix: &str,
123 text: &str,
124 ) -> Result<Vec<f32>, AppError> {
125 let dim = crate::constants::embedding_dim();
126 let inner = self.invoke_single_async(prefix, text, dim);
127 match tokio::runtime::Handle::try_current() {
132 Ok(handle) => tokio::task::block_in_place(|| handle.block_on(inner)),
133 Err(_) => crate::embedder::shared_runtime()?.block_on(inner),
134 }
135 }
136
137 async fn invoke_single_async(
138 &self,
139 prefix: &str,
140 text: &str,
141 dim: usize,
142 ) -> Result<Vec<f32>, AppError> {
143 let prompt = format!("{prefix}{text}");
144 let stdout = match self.flavour {
145 EmbeddingFlavour::Claude => {
146 self.invoke_claude(&prompt, &build_single_schema(dim))
147 .await?
148 }
149 EmbeddingFlavour::Codex => {
150 let schema = self.codex_schema_file(dim, false)?;
151 self.invoke_codex(&prompt, schema.path()).await?
152 }
153 EmbeddingFlavour::Opencode => {
154 let opencode_prompt = format!(
155 "You are an embedding function. Given the input text, output a JSON object \
156 with a single key \"embedding\" containing an array of exactly {dim} \
157 floating-point numbers between -1 and 1 that represent the semantic meaning \
158 of the text. Output ONLY the JSON object, nothing else.\n\n\
159 Input text: \"{prompt}\""
160 );
161 self.invoke_opencode(&opencode_prompt).await?
162 }
163 };
164 let parsed: EmbeddingResponse = parse_llm_json(&stdout).map_err(|e| {
165 AppError::Embedding(crate::i18n::validation::embedding_llm_parse_failed(
166 e, &stdout,
167 ))
168 })?;
169 if parsed.embedding.len() != dim {
170 return Err(AppError::Embedding(
171 crate::i18n::validation::embedding_llm_returned_dims(parsed.embedding.len(), dim),
172 ));
173 }
174 Ok(parsed.embedding)
175 }
176
177 pub(crate) fn codex_schema_file(
182 &self,
183 dim: usize,
184 batch: bool,
185 ) -> Result<Arc<tempfile::NamedTempFile>, AppError> {
186 let mut guard = self.codex_schemas.lock();
187 let slot = if batch {
188 &mut guard.batch
189 } else {
190 &mut guard.single
191 };
192 if let Some((cached_dim, file)) = slot {
193 if *cached_dim == dim {
194 return Ok(Arc::clone(file));
195 }
196 }
197 let content = if batch {
198 build_batch_schema(dim)
199 } else {
200 build_single_schema(dim)
201 };
202 let file = tempfile::Builder::new()
203 .prefix("sqlite-graphrag-embed-schema-")
204 .suffix(".json")
205 .tempfile()
206 .map_err(|e| {
207 AppError::Embedding(
208 crate::i18n::validation::embedding_schema_tempfile_create_failed(e),
209 )
210 })?;
211 std::fs::write(file.path(), content).map_err(|e| {
212 AppError::Embedding(crate::i18n::validation::embedding_schema_tempfile_write_failed(e))
213 })?;
214 let file = Arc::new(file);
215 *slot = Some((dim, Arc::clone(&file)));
216 Ok(file)
217 }
218
219 async fn invoke_claude(&self, prompt: &str, schema: &str) -> Result<String, AppError> {
220 let spawn_dir = crate::spawn::spawn_isolation_dir()?;
240 let mcp_config_path = crate::spawn::preflight::write_empty_mcp_config_tempfile()?;
241 let argv_refs: [std::ffi::OsString; 0] = [];
242 let preflight_args = crate::spawn::preflight::PreFlightArgs {
243 binary_path: &self.binary,
244 argv: &argv_refs,
245 workspace_root: &spawn_dir,
246 mcp_config_inline_json: None,
247 expected_output_bytes: 65_536,
248 spawner_name: "llm_embedding",
249 };
250 crate::spawn::preflight::preflight_check(&preflight_args)?;
251 let mut cmd = Command::new(&self.binary);
252 cmd.arg("-p")
253 .arg(prompt)
254 .arg("--model")
255 .arg(&self.model)
256 .arg("--json-schema")
257 .arg(schema)
258 .arg("--output-format")
259 .arg("json")
260 .arg("--strict-mcp-config")
261 .arg("--mcp-config")
262 .arg(mcp_config_path.as_os_str())
263 .arg("--settings")
264 .arg(r#"{"hooks":{}}"#)
265 .arg("--dangerously-skip-permissions")
266 .env_clear()
267 .env("PATH", std::env::var("PATH").unwrap_or_default())
268 .env("HOME", std::env::var("HOME").unwrap_or_default())
269 .stdin(Stdio::null())
270 .stdout(Stdio::piped())
271 .stderr(Stdio::piped())
272 .kill_on_drop(true);
274 cmd.current_dir(&spawn_dir);
276 cmd.env("CLAUDE_CONFIG_DIR", &spawn_dir);
277 if let Some(config_dir) = claude_embedding_config_dir() {
278 cmd.env("CLAUDE_CONFIG_DIR", &config_dir);
279 }
280 let binary_str = self.binary.to_string_lossy().into_owned();
281 let output = match tokio::time::timeout(self.instance_embed_timeout(), cmd.output()).await {
282 Err(_elapsed) => {
283 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
284 &crate::llm::exit_code_hints::LlmBackendError::Timeout {
285 secs: self.instance_embed_timeout().as_secs(),
286 binary: binary_str.clone(),
287 },
288 ));
289 }
290 Ok(Err(e)) => {
291 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
292 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
293 binary: binary_str.clone(),
294 source: e.to_string(),
295 },
296 ));
297 }
298 Ok(Ok(o)) => o,
299 };
300 let stdout_str = String::from_utf8_lossy(&output.stdout);
307 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stdout_str) {
308 let is_rate_limited = parsed
309 .get("is_error")
310 .and_then(|v| v.as_bool())
311 .unwrap_or(false)
312 && parsed
313 .get("result")
314 .and_then(|v| v.as_str())
315 .map(|s| {
316 s.contains("rate limit")
317 || s.contains("quota")
318 || s.contains("anthropic-ratelimit")
319 })
320 .unwrap_or(false);
321 if is_rate_limited {
322 let snippet: String = parsed
323 .get("result")
324 .and_then(|v| v.as_str())
325 .unwrap_or("")
326 .chars()
327 .take(120)
328 .collect();
329 return Err(AppError::Embedding(
330 crate::i18n::validation::embedding_oauth_usage_quota_exhausted_claude(&snippet),
331 ));
332 }
333 }
334 if !output.status.success() {
335 let (exit_code, signal) = if let Some(code) = output.status.code() {
336 (Some(code), None)
337 } else {
338 extract_exit_info(&output.status)
339 };
340 let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
341 &output.stdout,
342 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
343 );
344 let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
345 &output.stderr,
346 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
347 );
348 let mut hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
349 if stderr_tail.contains("401")
351 || stderr_tail.contains("Unauthorized")
352 || stderr_tail.contains("expired")
353 || stderr_tail.contains("login")
354 || stdout_tail.contains("401")
355 || stdout_tail.contains("Unauthorized")
356 {
357 hint.push_str(" | Claude OAuth token may be expired; run `claude login` to renew");
358 }
359 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
360 &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
361 exit_code,
362 signal,
363 stdout_tail,
364 stderr_tail,
365 binary: binary_str,
366 hint,
367 },
368 ));
369 }
370 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
371 }
372
373 async fn invoke_codex(
374 &self,
375 prompt: &str,
376 schema_path: &std::path::Path,
377 ) -> Result<String, AppError> {
378 let binary_str = self.binary.to_string_lossy().into_owned();
379 let mut cmd = build_codex_embedding_command(&self.binary, &self.model, schema_path)?;
380
381 let argv_refs: [std::ffi::OsString; 0] = [];
395 let preflight_args = crate::spawn::preflight::PreFlightArgs {
396 binary_path: &self.binary,
397 argv: &argv_refs,
398 workspace_root: std::path::Path::new("."),
399 mcp_config_inline_json: None,
400 expected_output_bytes: 65_536,
401 spawner_name: "llm_embedding",
402 };
403 crate::spawn::preflight::preflight_check(&preflight_args)?;
404 let _ = binary_str; let mut child = match cmd.spawn() {
407 Ok(c) => c,
408 Err(e) => {
409 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
410 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
411 binary: binary_str,
412 source: e.to_string(),
413 },
414 ));
415 }
416 };
417 if let Some(mut stdin) = child.stdin.take() {
418 stdin.write_all(prompt.as_bytes()).await.map_err(|e| {
419 AppError::Embedding(crate::i18n::validation::embedding_codex_stdin_write_failed(
420 e,
421 ))
422 })?;
423 drop(stdin);
424 }
425 let output =
426 match tokio::time::timeout(self.instance_embed_timeout(), child.wait_with_output())
427 .await
428 {
429 Err(_elapsed) => {
430 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
431 &crate::llm::exit_code_hints::LlmBackendError::Timeout {
432 secs: self.instance_embed_timeout().as_secs(),
433 binary: binary_str,
434 },
435 ));
436 }
437 Ok(Err(e)) => {
438 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
439 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
440 binary: binary_str,
441 source: format!("codex wait failed: {e}"),
442 },
443 ));
444 }
445 Ok(Ok(o)) => o,
446 };
447 if !output.status.success() {
448 let (exit_code, signal) = if let Some(code) = output.status.code() {
449 (Some(code), None)
450 } else {
451 extract_exit_info(&output.status)
452 };
453 let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
454 &output.stdout,
455 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
456 );
457 let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
458 &output.stderr,
459 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
460 );
461 let hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
462 let mut combined_hint = hint;
467 if stderr_tail.contains("request_user_input") {
468 combined_hint.push_str(
469 " | codex requested interactive input in a headless embedding call; \
470 upgrade codex (>= 0.134) or switch the embedding backend to claude",
471 );
472 }
473 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
474 &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
475 exit_code,
476 signal,
477 stdout_tail,
478 stderr_tail,
479 binary: binary_str,
480 hint: combined_hint,
481 },
482 ));
483 }
484 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
485 }
486
487 async fn invoke_opencode(&self, prompt: &str) -> Result<String, AppError> {
488 let binary_str = self.binary.to_string_lossy().into_owned();
489 let spawn_dir = crate::spawn::spawn_isolation_dir()?;
490 let mut cmd = Command::new(&self.binary);
491 cmd.current_dir(&spawn_dir);
492 cmd.arg("run")
493 .arg("--format")
494 .arg("json")
495 .arg("-m")
496 .arg(&self.model)
497 .arg("--dangerously-skip-permissions")
498 .arg(prompt)
499 .env_clear()
500 .env("PATH", std::env::var("PATH").unwrap_or_default())
501 .env("HOME", std::env::var("HOME").unwrap_or_default())
502 .stdin(Stdio::null())
503 .stdout(Stdio::piped())
504 .stderr(Stdio::piped())
505 .kill_on_drop(true);
506 crate::commands::opencode_runner::propagate_opencode_env(&mut cmd);
507
508 let output = match tokio::time::timeout(self.instance_embed_timeout(), cmd.output()).await {
509 Err(_elapsed) => {
510 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
511 &crate::llm::exit_code_hints::LlmBackendError::Timeout {
512 secs: self.instance_embed_timeout().as_secs(),
513 binary: binary_str.clone(),
514 },
515 ));
516 }
517 Ok(Err(e)) => {
518 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
519 &crate::llm::exit_code_hints::LlmBackendError::SpawnFailed {
520 binary: binary_str.clone(),
521 source: e.to_string(),
522 },
523 ));
524 }
525 Ok(Ok(o)) => o,
526 };
527 if !output.status.success() {
528 let (exit_code, signal) = if let Some(code) = output.status.code() {
529 (Some(code), None)
530 } else {
531 extract_exit_info(&output.status)
532 };
533 let stdout_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
534 &output.stdout,
535 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
536 );
537 let stderr_tail = crate::llm::exit_code_hints::LlmBackendError::truncate_tail(
538 &output.stderr,
539 crate::llm::exit_code_hints::DIAG_TAIL_BYTES,
540 );
541 let hint = crate::llm::exit_code_hints::diagnose_exit_code(exit_code, signal);
542 return Err(crate::llm::exit_code_hints::into_legacy_embedding(
543 &crate::llm::exit_code_hints::LlmBackendError::NonZeroExit {
544 exit_code,
545 signal,
546 stdout_tail,
547 stderr_tail,
548 binary: binary_str,
549 hint,
550 },
551 ));
552 }
553 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
554 }
555}
556
557pub(super) fn claude_embedding_config_dir() -> Option<std::path::PathBuf> {
571 if let Ok(Some(dir)) = crate::config::get_setting("llm.claude_empty_config_dir") {
572 let path = std::path::PathBuf::from(dir);
573 if path.is_dir() {
574 return Some(path);
575 }
576 tracing::warn!(
577 target: "embedding",
578 path = %path.display(),
579 "llm.claude_empty_config_dir is set but not a directory; \
580 falling back to the managed empty config dir"
581 );
582 }
583 let home = std::env::var("HOME").ok()?;
584 let dir = std::path::Path::new(&home)
585 .join(".local/state/sqlite-graphrag")
586 .join("claude-empty-config");
587 if std::fs::create_dir_all(&dir).is_err() {
588 return None;
589 }
590 #[cfg(unix)]
591 {
592 use std::os::unix::fs::PermissionsExt;
593 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
594 }
595 let creds = std::path::Path::new(&home).join(".claude/.credentials.json");
600 if creds.exists() {
601 let target = dir.join(".credentials.json");
602 let _ = std::fs::copy(&creds, &target);
603 }
604 Some(dir)
605}
606
607pub(crate) fn build_codex_embedding_command(
608 binary: &std::path::Path,
609 model: &str,
610 schema_path: &std::path::Path,
611) -> Result<Command, AppError> {
612 let spawn_dir = crate::spawn::spawn_isolation_dir()?;
613 let mut cmd = Command::new(binary);
614 cmd.current_dir(&spawn_dir);
615 cmd.arg("exec")
616 .arg("-c")
617 .arg("sandbox_mode='read-only'")
618 .arg("-c")
619 .arg("approval_policy='never'")
620 .arg("--json")
621 .arg("--output-schema")
622 .arg(schema_path)
623 .arg("--ephemeral")
624 .arg("--skip-git-repo-check")
625 .arg("--sandbox")
626 .arg("read-only")
627 .arg("--ignore-user-config")
628 .arg("--ignore-rules");
629 if crate::extract::codex_compat::codex_supports_ask_for_approval() {
630 cmd.arg("--ask-for-approval").arg("never");
631 }
632 cmd.arg("--model")
638 .arg(model)
639 .arg("-")
640 .env_clear()
641 .env("PATH", std::env::var("PATH").unwrap_or_default())
642 .env("HOME", std::env::var("HOME").unwrap_or_default());
643 if let Ok(codex_home) = std::env::var("CODEX_HOME") {
644 cmd.env("CODEX_HOME", codex_home);
645 } else if let Ok(home) = std::env::var("HOME") {
646 let default_home = std::path::Path::new(&home).join(".codex");
647 if default_home.exists() {
648 cmd.env("CODEX_HOME", &default_home);
649 }
650 }
651 cmd.stdin(Stdio::piped())
652 .stdout(Stdio::piped())
653 .stderr(Stdio::piped())
654 .kill_on_drop(true);
656 Ok(cmd)
657}
658
659