Skip to main content

sqlite_graphrag/commands/
opencode_runner.rs

1//! OpenCode headless runner for ingest and enrich pipelines (v1.0.90).
2//!
3//! Symmetric to `claude_runner.rs` (claude -p) and `codex_spawn.rs`
4//! (codex exec). Builds the `opencode run` command, parses NDJSON
5//! output, and provides rate-limit backoff.
6
7use crate::errors::AppError;
8use std::path::{Path, PathBuf};
9use std::process::Stdio;
10use tokio::process::Command;
11
12/// Default timeout per opencode invocation in seconds.
13const DEFAULT_OPENCODE_TIMEOUT_SECS: u64 = 300;
14
15/// Minimum supported opencode version.
16const MIN_OPENCODE_VERSION: (u64, u64, u64) = (1, 17, 0);
17
18/// Resolve the opencode binary path.
19///
20/// Precedence: explicit `--opencode-binary` > XDG `llm.opencode_binary`
21/// (`config set`) > `which::which("opencode")`.
22pub 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
51/// Find opencode binary.
52pub fn find_opencode_binary() -> Result<PathBuf, AppError> {
53    find_opencode_binary_with_override(None)
54}
55
56/// Resolve the opencode model name.
57///
58/// Precedence: explicit `model` arg > XDG `llm.opencode_model` (`config set`)
59/// > default `opencode/big-pickle`.
60///
61/// NOTE: intentionally does NOT fall back to `llm.model` because that key
62/// typically holds a codex/claude model (e.g. "gpt-5.4-mini") that
63/// opencode does not recognise — cross-contamination caused
64/// ProviderModelNotFoundError (v1.0.90 audit).
65pub 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
72/// Resolve the opencode timeout in seconds.
73///
74/// Precedence: explicit arg > XDG `llm.opencode_timeout` (`config set`) > default 300s.
75pub 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
82/// Validate the installed opencode version meets the minimum requirement.
83pub 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    // opencode --version returns just the version number, e.g. "1.17.7"
118    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
137/// Propagate opencode-relevant env vars into a subprocess.
138///
139/// After `env_clear()`, the subprocess only has PATH and HOME. OpenCode
140/// may need provider API keys (OPENROUTER_API_KEY, ANTHROPIC_AUTH_TOKEN,
141/// etc.), XDG dirs, LANG/TERM for proper operation. This helper forwards
142/// any env var matching the OPENCODE_*, OPENROUTER_*, XDG_*, LANG, TERM
143/// prefixes from the parent process.
144pub 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
154/// Build the opencode run command with hardening flags.
155///
156/// Unlike codex (9 flags) and claude (7 flags), opencode has only
157/// `--dangerously-skip-permissions` for auto-approval.
158pub 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
183/// Parse the NDJSON output from `opencode run --format json`.
184///
185/// The output has 3 event types:
186/// - `step_start`: ignored
187/// - `text`: `.part.text` contains the LLM response text
188/// - `step_finish`: `.part.tokens` and `.part.cost` for accounting
189///
190/// Returns `(response_text, cost, tokens)`.
191pub 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
242/// Parse a JSON value from opencode output text.
243///
244/// Opencode has no `--output-schema`, so the LLM may include markdown
245/// fences or explanation text around the JSON. This function tries:
246/// 1. Direct JSON parse of the full text
247/// 2. Extract JSON from markdown code fences
248/// 3. Find the first `{` to last `}` substring
249pub fn parse_json_from_opencode_text<T: serde::de::DeserializeOwned>(
250    text: &str,
251) -> Result<T, String> {
252    // Strategy 1: direct parse
253    if let Ok(parsed) = serde_json::from_str::<T>(text) {
254        return Ok(parsed);
255    }
256
257    // Strategy 2: extract from markdown code fence
258    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    // Strategy 3: find first { to last }
278    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
293/// Call opencode headless and return the parsed JSON response.
294///
295/// Combines `build_opencode_command`, subprocess execution with timeout,
296/// `parse_opencode_output`, and `parse_json_from_opencode_text`.
297pub 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
341/// Propagate opencode-relevant env vars into a sync subprocess.
342///
343/// Same logic as `propagate_opencode_env` but for `std::process::Command`.
344pub 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
354/// Build a sync `std::process::Command` for opencode.
355///
356/// Mirror of `build_opencode_command` but returns `std::process::Command`
357/// for use in the enrich pipeline which uses `wait_timeout` (sync).
358pub 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/// Spawn opencode with setsid for process group isolation but WITHOUT
389/// RLIMIT_AS. The Bun runtime inside opencode uses aggressive virtual
390/// memory mappings that exceed the 4 GB limit applied to claude/codex.
391#[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        // We can't easily test with a real binary, so test the parse path
449        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        // When no override and no env var, should return default
462        let model = resolve_opencode_model(None);
463        // May be overridden by env in CI, so just check it's non-empty
464        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}