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