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(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
52/// Find opencode binary.
53pub fn find_opencode_binary() -> Result<PathBuf, AppError> {
54    find_opencode_binary_with_override(None)
55}
56
57/// Resolve the opencode model name.
58///
59/// Precedence: explicit `model` arg > XDG `llm.opencode_model` (`config set`)
60/// > default `opencode/big-pickle`.
61///
62/// NOTE: intentionally does NOT fall back to `llm.model` because that key
63/// typically holds a codex/claude model (e.g. "gpt-5.4-mini") that
64/// opencode does not recognise — cross-contamination caused
65/// ProviderModelNotFoundError (v1.0.90 audit).
66pub 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
73/// Resolve the opencode timeout in seconds.
74///
75/// Precedence: explicit arg > XDG `llm.opencode_timeout` (`config set`) > default 300s.
76pub 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
83/// Validate the installed opencode version meets the minimum requirement.
84pub 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    // opencode --version returns just the version number, e.g. "1.17.7"
117    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
134/// Propagate opencode-relevant env vars into a subprocess.
135///
136/// After `env_clear()`, the subprocess only has PATH and HOME. OpenCode
137/// may need provider API keys (OPENROUTER_API_KEY, ANTHROPIC_AUTH_TOKEN,
138/// etc.), XDG dirs, LANG/TERM for proper operation. This helper forwards
139/// any env var matching the OPENCODE_*, OPENROUTER_*, XDG_*, LANG, TERM
140/// prefixes from the parent process.
141pub 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
151/// Build the opencode run command with hardening flags.
152///
153/// Unlike codex (9 flags) and claude (7 flags), opencode has only
154/// `--dangerously-skip-permissions` for auto-approval.
155pub 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
180/// Parse the NDJSON output from `opencode run --format json`.
181///
182/// The output has 3 event types:
183/// - `step_start`: ignored
184/// - `text`: `.part.text` contains the LLM response text
185/// - `step_finish`: `.part.tokens` and `.part.cost` for accounting
186///
187/// Returns `(response_text, cost, tokens)`.
188pub 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
239/// Parse a JSON value from opencode output text.
240///
241/// Opencode has no `--output-schema`, so the LLM may include markdown
242/// fences or explanation text around the JSON. This function tries:
243/// 1. Direct JSON parse of the full text
244/// 2. Extract JSON from markdown code fences
245/// 3. Find the first `{` to last `}` substring
246pub fn parse_json_from_opencode_text<T: serde::de::DeserializeOwned>(
247    text: &str,
248) -> Result<T, String> {
249    // Strategy 1: direct parse
250    if let Ok(parsed) = serde_json::from_str::<T>(text) {
251        return Ok(parsed);
252    }
253
254    // Strategy 2: extract from markdown code fence
255    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    // Strategy 3: find first { to last }
275    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
290/// Call opencode headless and return the parsed JSON response.
291///
292/// Combines `build_opencode_command`, subprocess execution with timeout,
293/// `parse_opencode_output`, and `parse_json_from_opencode_text`.
294pub 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
339/// Propagate opencode-relevant env vars into a sync subprocess.
340///
341/// Same logic as `propagate_opencode_env` but for `std::process::Command`.
342pub 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
352/// Build a sync `std::process::Command` for opencode.
353///
354/// Mirror of `build_opencode_command` but returns `std::process::Command`
355/// for use in the enrich pipeline which uses `wait_timeout` (sync).
356pub 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/// Spawn opencode with setsid for process group isolation but WITHOUT
387/// RLIMIT_AS. The Bun runtime inside opencode uses aggressive virtual
388/// memory mappings that exceed the 4 GB limit applied to claude/codex.
389#[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        // We can't easily test with a real binary, so test the parse path
447        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        // When no override and no env var, should return default
460        let model = resolve_opencode_model(None);
461        // May be overridden by env in CI, so just check it's non-empty
462        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}