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