sqlite_graphrag/commands/
opencode_runner.rs1use crate::errors::AppError;
8use std::path::{Path, PathBuf};
9use std::process::Stdio;
10use tokio::process::Command;
11
12const DEFAULT_OPENCODE_TIMEOUT_SECS: u64 = 300;
14
15const MIN_OPENCODE_VERSION: (u64, u64, u64) = (1, 17, 0);
17
18pub fn find_opencode_binary_with_override(explicit: Option<&Path>) -> Result<PathBuf, AppError> {
23 if let Some(p) = explicit {
24 if p.exists() {
25 return Ok(p.to_path_buf());
26 }
27 return Err(AppError::Validation(
28 crate::i18n::validation::binary_not_found_at_path("opencode", &p.display().to_string()),
29 ));
30 }
31 if let Some(path) = crate::runtime_config::opencode_binary() {
32 let p = PathBuf::from(path);
33 if p.exists() {
34 return Ok(p);
35 }
36 tracing::warn!(
37 target: "opencode_runner",
38 path = %p.display(),
39 "llm.opencode_binary is set but file does not exist; falling back to PATH"
40 );
41 }
42 which::which("opencode").map_err(|_| {
43 AppError::Validation(
44 "`opencode` not found on PATH. Install opencode (>= 1.17) or set \
45 via `config set llm.opencode_binary <path>` or `--opencode-binary`."
46 .into(),
47 )
48 })
49}
50
51pub fn find_opencode_binary() -> Result<PathBuf, AppError> {
53 find_opencode_binary_with_override(None)
54}
55
56pub fn resolve_opencode_model(model_override: Option<&str>) -> String {
66 if let Some(m) = model_override {
67 return m.to_string();
68 }
69 crate::runtime_config::resolve_string(None, "llm.opencode_model", "opencode/big-pickle")
70}
71
72pub fn resolve_opencode_timeout(timeout_override: Option<u64>) -> u64 {
76 if let Some(t) = timeout_override {
77 return t;
78 }
79 crate::runtime_config::resolve_u64(None, "llm.opencode_timeout", DEFAULT_OPENCODE_TIMEOUT_SECS)
80}
81
82pub fn validate_opencode_version(binary: &Path) -> Result<(u64, u64, u64), AppError> {
84 let output = std::process::Command::new(binary)
85 .arg("--version")
86 .output()
87 .map_err(|e| {
88 AppError::Validation(crate::i18n::validation::failed_to_run_opencode_version(&e))
89 })?;
90
91 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
92 let raw = if raw.is_empty() {
93 String::from_utf8_lossy(&output.stderr).trim().to_string()
94 } else {
95 raw
96 };
97
98 parse_version(&raw).and_then(|v| {
99 if v >= MIN_OPENCODE_VERSION {
100 Ok(v)
101 } else {
102 Err(AppError::Validation(
103 crate::i18n::validation::version_below_minimum(
104 "opencode",
105 &format!("{}.{}.{}", v.0, v.1, v.2),
106 &format!(
107 "{}.{}.{}",
108 MIN_OPENCODE_VERSION.0, MIN_OPENCODE_VERSION.1, MIN_OPENCODE_VERSION.2
109 ),
110 ),
111 ))
112 }
113 })
114}
115
116fn parse_version(raw: &str) -> Result<(u64, u64, u64), AppError> {
117 let digits: String = raw
119 .chars()
120 .filter(|c| c.is_ascii_digit() || *c == '.')
121 .collect();
122 let parts: Vec<&str> = digits.split('.').collect();
123 if parts.len() >= 3 {
124 if let (Ok(major), Ok(minor), Ok(patch)) = (
125 parts[0].parse::<u64>(),
126 parts[1].parse::<u64>(),
127 parts[2].parse::<u64>(),
128 ) {
129 return Ok((major, minor, patch));
130 }
131 }
132 Err(AppError::Validation(
133 crate::i18n::validation::could_not_parse_opencode_version(raw),
134 ))
135}
136
137pub fn propagate_opencode_env(cmd: &mut Command) {
145 const PREFIXES: &[&str] = &["OPENCODE_", "OPENROUTER_", "XDG_"];
146 const EXACT: &[&str] = &["LANG", "TERM", "USER", "LOGNAME", "TMPDIR"];
147 for (key, val) in std::env::vars() {
148 if PREFIXES.iter().any(|p| key.starts_with(p)) || EXACT.contains(&key.as_str()) {
149 cmd.env(&key, &val);
150 }
151 }
152}
153
154pub fn build_opencode_command(
159 binary: &Path,
160 model: &str,
161 prompt: &str,
162) -> Result<Command, AppError> {
163 let mut cmd = Command::new(binary);
164 cmd.arg("run")
165 .arg("--format")
166 .arg("json")
167 .arg("-m")
168 .arg(model)
169 .arg("--dangerously-skip-permissions")
170 .arg(prompt)
171 .env_clear()
172 .env("PATH", std::env::var("PATH").unwrap_or_default())
173 .env("HOME", std::env::var("HOME").unwrap_or_default())
174 .stdin(Stdio::null())
175 .stdout(Stdio::piped())
176 .stderr(Stdio::piped())
177 .kill_on_drop(true);
178 propagate_opencode_env(&mut cmd);
179 crate::spawn::apply_cwd_isolation_tokio(&mut cmd)?;
180 Ok(cmd)
181}
182
183pub fn parse_opencode_output(stdout: &str) -> Result<(String, f64, u64), AppError> {
192 let mut texts: Vec<String> = Vec::new();
193 let mut cost: f64 = 0.0;
194 let mut tokens: u64 = 0;
195
196 for line in stdout.lines() {
197 let trimmed = line.trim();
198 if trimmed.is_empty() {
199 continue;
200 }
201 let Ok(event) = serde_json::from_str::<serde_json::Value>(trimmed) else {
202 continue;
203 };
204 let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or("");
205 match event_type {
206 "text" => {
207 if let Some(text) = event
208 .get("part")
209 .and_then(|p| p.get("text"))
210 .and_then(|t| t.as_str())
211 {
212 texts.push(text.to_string());
213 }
214 }
215 "step_finish" => {
216 if let Some(part) = event.get("part") {
217 if let Some(c) = part.get("cost").and_then(|c| c.as_f64()) {
218 cost = c;
219 }
220 if let Some(t) = part
221 .get("tokens")
222 .and_then(|t| t.get("total"))
223 .and_then(|t| t.as_u64())
224 {
225 tokens = t;
226 }
227 }
228 }
229 _ => {}
230 }
231 }
232
233 if texts.is_empty() {
234 return Err(AppError::Embedding(
235 crate::i18n::validation::embedding_opencode_no_text_events(),
236 ));
237 }
238
239 Ok((texts.concat(), cost, tokens))
240}
241
242pub fn parse_json_from_opencode_text<T: serde::de::DeserializeOwned>(
250 text: &str,
251) -> Result<T, String> {
252 if let Ok(parsed) = serde_json::from_str::<T>(text) {
254 return Ok(parsed);
255 }
256
257 if let Some(start) = text.find("```json") {
259 let after_fence = &text[start + 7..];
260 if let Some(end) = after_fence.find("```") {
261 let json_str = after_fence[..end].trim();
262 if let Ok(parsed) = serde_json::from_str::<T>(json_str) {
263 return Ok(parsed);
264 }
265 }
266 }
267 if let Some(start) = text.find("```") {
268 let after_fence = &text[start + 3..];
269 if let Some(end) = after_fence.find("```") {
270 let json_str = after_fence[..end].trim();
271 if let Ok(parsed) = serde_json::from_str::<T>(json_str) {
272 return Ok(parsed);
273 }
274 }
275 }
276
277 if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) {
279 if start < end {
280 let json_str = &text[start..=end];
281 if let Ok(parsed) = serde_json::from_str::<T>(json_str) {
282 return Ok(parsed);
283 }
284 }
285 }
286
287 Err(format!(
288 "could not extract valid JSON from opencode response: {}",
289 &text[..text.len().min(200)]
290 ))
291}
292
293pub async fn call_opencode<T: serde::de::DeserializeOwned>(
298 binary: &Path,
299 model: &str,
300 prompt: &str,
301 timeout_secs: u64,
302) -> Result<(T, f64, u64), AppError> {
303 let mut cmd = build_opencode_command(binary, model, prompt)?;
304 let timeout = std::time::Duration::from_secs(timeout_secs);
305
306 let output = match tokio::time::timeout(timeout, cmd.output()).await {
307 Err(_elapsed) => {
308 return Err(AppError::Embedding(
309 crate::i18n::validation::embedding_opencode_timed_out(timeout_secs),
310 ));
311 }
312 Ok(Err(e)) => {
313 return Err(AppError::Embedding(
314 crate::i18n::validation::embedding_failed_to_spawn_opencode(e),
315 ));
316 }
317 Ok(Ok(o)) => o,
318 };
319
320 if !output.status.success() {
321 let stderr = String::from_utf8_lossy(&output.stderr);
322 let stdout = String::from_utf8_lossy(&output.stdout);
323 return Err(AppError::Embedding(
324 crate::i18n::validation::embedding_opencode_exited(
325 output.status,
326 &stderr[..stderr.len().min(500)],
327 &stdout[..stdout.len().min(500)],
328 ),
329 ));
330 }
331
332 let stdout_str = String::from_utf8_lossy(&output.stdout);
333 let (text, _cost, _tokens) = parse_opencode_output(&stdout_str)?;
334 let parsed: T = parse_json_from_opencode_text(&text).map_err(|e| {
335 AppError::Embedding(crate::i18n::validation::embedding_opencode_json_parse_failed(e))
336 })?;
337
338 Ok((parsed, _cost, _tokens))
339}
340
341pub fn propagate_opencode_env_sync(cmd: &mut std::process::Command) {
345 const PREFIXES: &[&str] = &["OPENCODE_", "OPENROUTER_", "XDG_"];
346 const EXACT: &[&str] = &["LANG", "TERM", "USER", "LOGNAME", "TMPDIR"];
347 for (key, val) in std::env::vars() {
348 if PREFIXES.iter().any(|p| key.starts_with(p)) || EXACT.contains(&key.as_str()) {
349 cmd.env(&key, &val);
350 }
351 }
352}
353
354pub fn build_opencode_command_sync(
359 binary: &Path,
360 model: &str,
361 prompt: &str,
362 input_text: &str,
363) -> Result<std::process::Command, AppError> {
364 let full_prompt = if input_text.is_empty() {
365 prompt.to_string()
366 } else {
367 format!("{prompt}\n\n{input_text}")
368 };
369 let mut cmd = std::process::Command::new(binary);
370 cmd.arg("run")
371 .arg("--format")
372 .arg("json")
373 .arg("-m")
374 .arg(model)
375 .arg("--dangerously-skip-permissions")
376 .arg(&full_prompt)
377 .env_clear()
378 .env("PATH", std::env::var("PATH").unwrap_or_default())
379 .env("HOME", std::env::var("HOME").unwrap_or_default())
380 .stdin(std::process::Stdio::null())
381 .stdout(std::process::Stdio::piped())
382 .stderr(std::process::Stdio::piped());
383 propagate_opencode_env_sync(&mut cmd);
384 crate::spawn::apply_cwd_isolation(&mut cmd)?;
385 Ok(cmd)
386}
387
388#[cfg(target_os = "linux")]
392pub fn spawn_opencode(cmd: &mut std::process::Command) -> std::io::Result<std::process::Child> {
393 use std::os::unix::process::CommandExt;
394 unsafe {
395 cmd.pre_exec(|| {
396 let sid = libc::setsid();
397 if sid == -1 {
398 let err = std::io::Error::last_os_error();
399 if err.raw_os_error() != Some(libc::EPERM) {
400 return Err(err);
401 }
402 }
403 Ok(())
404 });
405 }
406 cmd.spawn()
407}
408
409#[cfg(not(target_os = "linux"))]
410pub fn spawn_opencode(cmd: &mut std::process::Command) -> std::io::Result<std::process::Child> {
411 #[cfg(unix)]
412 {
413 use std::os::unix::process::CommandExt;
414 unsafe {
415 cmd.pre_exec(|| {
416 let _ = libc::setsid();
417 Ok(())
418 });
419 }
420 }
421 cmd.spawn()
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn parse_version_valid() {
430 assert_eq!(parse_version("1.17.7").unwrap(), (1, 17, 7));
431 assert_eq!(parse_version("2.0.0").unwrap(), (2, 0, 0));
432 }
433
434 #[test]
435 fn parse_version_with_prefix() {
436 assert_eq!(parse_version("v1.17.7").unwrap(), (1, 17, 7));
437 assert_eq!(parse_version("opencode 1.17.7").unwrap(), (1, 17, 7));
438 }
439
440 #[test]
441 fn parse_version_invalid() {
442 assert!(parse_version("unknown").is_err());
443 assert!(parse_version("").is_err());
444 }
445
446 #[test]
447 fn validate_version_rejects_old() {
448 let v = parse_version("1.16.0").unwrap();
450 assert!(v < MIN_OPENCODE_VERSION);
451 }
452
453 #[test]
454 fn validate_version_accepts_minimum() {
455 let v = parse_version("1.17.0").unwrap();
456 assert!(v >= MIN_OPENCODE_VERSION);
457 }
458
459 #[test]
460 fn resolve_model_uses_default() {
461 let model = resolve_opencode_model(None);
463 assert!(!model.is_empty());
465 }
466
467 #[test]
468 fn resolve_model_uses_override() {
469 let model = resolve_opencode_model(Some("opencode/test-model"));
470 assert_eq!(model, "opencode/test-model");
471 }
472
473 #[test]
474 fn resolve_timeout_uses_default() {
475 let t = resolve_opencode_timeout(None);
476 assert!(t > 0);
477 }
478
479 #[test]
480 fn resolve_timeout_uses_override() {
481 assert_eq!(resolve_opencode_timeout(Some(600)), 600);
482 }
483
484 #[test]
485 fn parse_opencode_output_extracts_text() {
486 let stdout = r#"{"type":"step_start","timestamp":1234,"sessionID":"ses_test","part":{"type":"step-start"}}
487{"type":"text","timestamp":1235,"sessionID":"ses_test","part":{"type":"text","text":"{\"entities\":[]}"}}
488{"type":"step_finish","timestamp":1236,"sessionID":"ses_test","part":{"type":"step-finish","tokens":{"total":100,"input":90,"output":10,"reasoning":0},"cost":0.0}}"#;
489
490 let (text, cost, tokens) = parse_opencode_output(stdout).unwrap();
491 assert_eq!(text, "{\"entities\":[]}");
492 assert_eq!(cost, 0.0);
493 assert_eq!(tokens, 100);
494 }
495
496 #[test]
497 fn parse_opencode_output_concatenates_multiple_text_events() {
498 let stdout = r#"{"type":"step_start","timestamp":1234,"sessionID":"s","part":{"type":"step-start"}}
499{"type":"text","timestamp":1235,"sessionID":"s","part":{"type":"text","text":"{\"ent"}}
500{"type":"text","timestamp":1236,"sessionID":"s","part":{"type":"text","text":"ities\":[]}"}}
501{"type":"step_finish","timestamp":1237,"sessionID":"s","part":{"type":"step-finish","tokens":{"total":50,"input":40,"output":10,"reasoning":0},"cost":0}}"#;
502
503 let (text, _, _) = parse_opencode_output(stdout).unwrap();
504 assert_eq!(text, "{\"entities\":[]}");
505 }
506
507 #[test]
508 fn parse_opencode_output_empty_fails() {
509 assert!(parse_opencode_output("").is_err());
510 assert!(parse_opencode_output("{\"type\":\"step_start\"}").is_err());
511 }
512
513 #[test]
514 fn parse_json_from_opencode_text_direct() {
515 let text = r#"{"entities":[],"relationships":[]}"#;
516 let parsed: serde_json::Value = parse_json_from_opencode_text(text).unwrap();
517 assert!(parsed.get("entities").is_some());
518 }
519
520 #[test]
521 fn parse_json_from_opencode_text_markdown_fence() {
522 let text = "Here is the result:\n```json\n{\"entities\":[]}\n```\nDone.";
523 let parsed: serde_json::Value = parse_json_from_opencode_text(text).unwrap();
524 assert!(parsed.get("entities").is_some());
525 }
526
527 #[test]
528 fn parse_json_from_opencode_text_extract_braces() {
529 let text = "The answer is {\"entities\":[]} and that's it.";
530 let parsed: serde_json::Value = parse_json_from_opencode_text(text).unwrap();
531 assert!(parsed.get("entities").is_some());
532 }
533
534 #[test]
535 fn parse_json_from_opencode_text_invalid() {
536 assert!(parse_json_from_opencode_text::<serde_json::Value>("no json here").is_err());
537 }
538
539 #[test]
540 fn build_command_has_correct_args() {
541 let cmd = build_opencode_command(
542 Path::new("/usr/bin/opencode"),
543 "opencode/big-pickle",
544 "test prompt",
545 )
546 .unwrap();
547 let argv: Vec<String> = cmd
548 .as_std()
549 .get_args()
550 .filter_map(|a| a.to_str().map(|s| s.to_string()))
551 .collect();
552
553 assert!(argv.contains(&"run".to_string()));
554 assert!(argv.contains(&"--format".to_string()));
555 assert!(argv.contains(&"json".to_string()));
556 assert!(argv.contains(&"-m".to_string()));
557 assert!(argv.contains(&"opencode/big-pickle".to_string()));
558 assert!(argv.contains(&"--dangerously-skip-permissions".to_string()));
559 assert!(argv.contains(&"test prompt".to_string()));
560 }
561}