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: `SQLITE_GRAPHRAG_OPENCODE_BINARY` env var > `which::which("opencode")`.
21pub fn find_opencode_binary_with_override(explicit: Option<&Path>) -> Result<PathBuf, AppError> {
22    if let Some(p) = explicit {
23        if p.exists() {
24            return Ok(p.to_path_buf());
25        }
26        return Err(AppError::Validation(format!(
27            "opencode binary not found at explicit path: {}",
28            p.display()
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> {
52    find_opencode_binary_with_override(None)
53}
54
55/// Resolve the opencode model name.
56///
57/// Precedence: explicit `model` arg > `SQLITE_GRAPHRAG_OPENCODE_MODEL` env var
58/// > default `opencode/big-pickle`.
59///
60/// NOTE: intentionally does NOT fall back to `SQLITE_GRAPHRAG_LLM_MODEL` because
61/// that var typically holds a codex/claude model (e.g. "gpt-5.4-mini") that
62/// opencode does not recognise — cross-contamination caused
63/// ProviderModelNotFoundError (v1.0.90 audit).
64pub fn resolve_opencode_model(model_override: Option<&str>) -> String {
65    if let Some(m) = model_override {
66        return m.to_string();
67    }
68    crate::runtime_config::resolve_string(None, "llm.opencode_model", "opencode/big-pickle")
69}
70
71/// Resolve the opencode timeout in seconds.
72///
73/// Precedence: explicit arg > `SQLITE_GRAPHRAG_OPENCODE_TIMEOUT` env var > default 300s.
74pub fn resolve_opencode_timeout(timeout_override: Option<u64>) -> u64 {
75    if let Some(t) = timeout_override {
76        return t;
77    }
78    crate::runtime_config::resolve_u64(None, "llm.opencode_timeout", DEFAULT_OPENCODE_TIMEOUT_SECS)
79}
80
81/// Validate the installed opencode version meets the minimum requirement.
82pub fn validate_opencode_version(binary: &Path) -> Result<(u64, u64, u64), AppError> {
83    let output = std::process::Command::new(binary)
84        .arg("--version")
85        .output()
86        .map_err(|e| AppError::Validation(format!("failed to run opencode --version: {e}")))?;
87
88    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
89    let raw = if raw.is_empty() {
90        String::from_utf8_lossy(&output.stderr).trim().to_string()
91    } else {
92        raw
93    };
94
95    parse_version(&raw).and_then(|v| {
96        if v >= MIN_OPENCODE_VERSION {
97            Ok(v)
98        } else {
99            Err(AppError::Validation(format!(
100                "opencode version {}.{}.{} is below minimum {}.{}.{}",
101                v.0,
102                v.1,
103                v.2,
104                MIN_OPENCODE_VERSION.0,
105                MIN_OPENCODE_VERSION.1,
106                MIN_OPENCODE_VERSION.2,
107            )))
108        }
109    })
110}
111
112fn parse_version(raw: &str) -> Result<(u64, u64, u64), AppError> {
113    // opencode --version returns just the version number, e.g. "1.17.7"
114    let digits: String = raw
115        .chars()
116        .filter(|c| c.is_ascii_digit() || *c == '.')
117        .collect();
118    let parts: Vec<&str> = digits.split('.').collect();
119    if parts.len() >= 3 {
120        if let (Ok(major), Ok(minor), Ok(patch)) = (
121            parts[0].parse::<u64>(),
122            parts[1].parse::<u64>(),
123            parts[2].parse::<u64>(),
124        ) {
125            return Ok((major, minor, patch));
126        }
127    }
128    Err(AppError::Validation(format!(
129        "could not parse opencode version from: {raw}"
130    )))
131}
132
133/// Propagate opencode-relevant env vars into a subprocess.
134///
135/// After `env_clear()`, the subprocess only has PATH and HOME. OpenCode
136/// may need provider API keys (OPENROUTER_API_KEY, ANTHROPIC_AUTH_TOKEN,
137/// etc.), XDG dirs, LANG/TERM for proper operation. This helper forwards
138/// any env var matching the OPENCODE_*, OPENROUTER_*, XDG_*, LANG, TERM
139/// prefixes from the parent process.
140pub fn propagate_opencode_env(cmd: &mut Command) {
141    const PREFIXES: &[&str] = &["OPENCODE_", "OPENROUTER_", "XDG_"];
142    const EXACT: &[&str] = &["LANG", "TERM", "USER", "LOGNAME", "TMPDIR"];
143    for (key, val) in std::env::vars() {
144        if PREFIXES.iter().any(|p| key.starts_with(p)) || EXACT.contains(&key.as_str()) {
145            cmd.env(&key, &val);
146        }
147    }
148}
149
150/// Build the opencode run command with hardening flags.
151///
152/// Unlike codex (9 flags) and claude (7 flags), opencode has only
153/// `--dangerously-skip-permissions` for auto-approval.
154pub fn build_opencode_command(
155    binary: &Path,
156    model: &str,
157    prompt: &str,
158) -> Result<Command, AppError> {
159    let mut cmd = Command::new(binary);
160    cmd.arg("run")
161        .arg("--format")
162        .arg("json")
163        .arg("-m")
164        .arg(model)
165        .arg("--dangerously-skip-permissions")
166        .arg(prompt)
167        .env_clear()
168        .env("PATH", std::env::var("PATH").unwrap_or_default())
169        .env("HOME", std::env::var("HOME").unwrap_or_default())
170        .stdin(Stdio::null())
171        .stdout(Stdio::piped())
172        .stderr(Stdio::piped())
173        .kill_on_drop(true);
174    propagate_opencode_env(&mut cmd);
175    crate::spawn::apply_cwd_isolation_tokio(&mut cmd)?;
176    Ok(cmd)
177}
178
179/// Parse the NDJSON output from `opencode run --format json`.
180///
181/// The output has 3 event types:
182/// - `step_start`: ignored
183/// - `text`: `.part.text` contains the LLM response text
184/// - `step_finish`: `.part.tokens` and `.part.cost` for accounting
185///
186/// Returns `(response_text, cost, tokens)`.
187pub fn parse_opencode_output(stdout: &str) -> Result<(String, f64, u64), AppError> {
188    let mut texts: Vec<String> = Vec::new();
189    let mut cost: f64 = 0.0;
190    let mut tokens: u64 = 0;
191
192    for line in stdout.lines() {
193        let trimmed = line.trim();
194        if trimmed.is_empty() {
195            continue;
196        }
197        let Ok(event) = serde_json::from_str::<serde_json::Value>(trimmed) else {
198            continue;
199        };
200        let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or("");
201        match event_type {
202            "text" => {
203                if let Some(text) = event
204                    .get("part")
205                    .and_then(|p| p.get("text"))
206                    .and_then(|t| t.as_str())
207                {
208                    texts.push(text.to_string());
209                }
210            }
211            "step_finish" => {
212                if let Some(part) = event.get("part") {
213                    if let Some(c) = part.get("cost").and_then(|c| c.as_f64()) {
214                        cost = c;
215                    }
216                    if let Some(t) = part
217                        .get("tokens")
218                        .and_then(|t| t.get("total"))
219                        .and_then(|t| t.as_u64())
220                    {
221                        tokens = t;
222                    }
223                }
224            }
225            _ => {}
226        }
227    }
228
229    if texts.is_empty() {
230        return Err(AppError::Embedding(
231            "opencode returned no text events in NDJSON output".to_string(),
232        ));
233    }
234
235    Ok((texts.concat(), cost, tokens))
236}
237
238/// Parse a JSON value from opencode output text.
239///
240/// Opencode has no `--output-schema`, so the LLM may include markdown
241/// fences or explanation text around the JSON. This function tries:
242/// 1. Direct JSON parse of the full text
243/// 2. Extract JSON from markdown code fences
244/// 3. Find the first `{` to last `}` substring
245pub fn parse_json_from_opencode_text<T: serde::de::DeserializeOwned>(
246    text: &str,
247) -> Result<T, String> {
248    // Strategy 1: direct parse
249    if let Ok(parsed) = serde_json::from_str::<T>(text) {
250        return Ok(parsed);
251    }
252
253    // Strategy 2: extract from markdown code fence
254    if let Some(start) = text.find("```json") {
255        let after_fence = &text[start + 7..];
256        if let Some(end) = after_fence.find("```") {
257            let json_str = after_fence[..end].trim();
258            if let Ok(parsed) = serde_json::from_str::<T>(json_str) {
259                return Ok(parsed);
260            }
261        }
262    }
263    if let Some(start) = text.find("```") {
264        let after_fence = &text[start + 3..];
265        if let Some(end) = after_fence.find("```") {
266            let json_str = after_fence[..end].trim();
267            if let Ok(parsed) = serde_json::from_str::<T>(json_str) {
268                return Ok(parsed);
269            }
270        }
271    }
272
273    // Strategy 3: find first { to last }
274    if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) {
275        if start < end {
276            let json_str = &text[start..=end];
277            if let Ok(parsed) = serde_json::from_str::<T>(json_str) {
278                return Ok(parsed);
279            }
280        }
281    }
282
283    Err(format!(
284        "could not extract valid JSON from opencode response: {}",
285        &text[..text.len().min(200)]
286    ))
287}
288
289/// Call opencode headless and return the parsed JSON response.
290///
291/// Combines `build_opencode_command`, subprocess execution with timeout,
292/// `parse_opencode_output`, and `parse_json_from_opencode_text`.
293pub async fn call_opencode<T: serde::de::DeserializeOwned>(
294    binary: &Path,
295    model: &str,
296    prompt: &str,
297    timeout_secs: u64,
298) -> Result<(T, f64, u64), AppError> {
299    let mut cmd = build_opencode_command(binary, model, prompt)?;
300    let timeout = std::time::Duration::from_secs(timeout_secs);
301
302    let output = match tokio::time::timeout(timeout, cmd.output()).await {
303        Err(_elapsed) => {
304            return Err(AppError::Embedding(format!(
305                "opencode timed out after {timeout_secs}s"
306            )));
307        }
308        Ok(Err(e)) => {
309            return Err(AppError::Embedding(format!(
310                "failed to spawn opencode: {e}"
311            )));
312        }
313        Ok(Ok(o)) => o,
314    };
315
316    if !output.status.success() {
317        let stderr = String::from_utf8_lossy(&output.stderr);
318        let stdout = String::from_utf8_lossy(&output.stdout);
319        return Err(AppError::Embedding(format!(
320            "opencode exited with {}: stderr={}, stdout={}",
321            output.status,
322            &stderr[..stderr.len().min(500)],
323            &stdout[..stdout.len().min(500)],
324        )));
325    }
326
327    let stdout_str = String::from_utf8_lossy(&output.stdout);
328    let (text, _cost, _tokens) = parse_opencode_output(&stdout_str)?;
329    let parsed: T = parse_json_from_opencode_text(&text)
330        .map_err(|e| AppError::Embedding(format!("opencode JSON parse failed: {e}")))?;
331
332    Ok((parsed, _cost, _tokens))
333}
334
335/// Propagate opencode-relevant env vars into a sync subprocess.
336///
337/// Same logic as `propagate_opencode_env` but for `std::process::Command`.
338pub fn propagate_opencode_env_sync(cmd: &mut std::process::Command) {
339    const PREFIXES: &[&str] = &["OPENCODE_", "OPENROUTER_", "XDG_"];
340    const EXACT: &[&str] = &["LANG", "TERM", "USER", "LOGNAME", "TMPDIR"];
341    for (key, val) in std::env::vars() {
342        if PREFIXES.iter().any(|p| key.starts_with(p)) || EXACT.contains(&key.as_str()) {
343            cmd.env(&key, &val);
344        }
345    }
346}
347
348/// Build a sync `std::process::Command` for opencode.
349///
350/// Mirror of `build_opencode_command` but returns `std::process::Command`
351/// for use in the enrich pipeline which uses `wait_timeout` (sync).
352pub fn build_opencode_command_sync(
353    binary: &Path,
354    model: &str,
355    prompt: &str,
356    input_text: &str,
357) -> Result<std::process::Command, AppError> {
358    let full_prompt = if input_text.is_empty() {
359        prompt.to_string()
360    } else {
361        format!("{prompt}\n\n{input_text}")
362    };
363    let mut cmd = std::process::Command::new(binary);
364    cmd.arg("run")
365        .arg("--format")
366        .arg("json")
367        .arg("-m")
368        .arg(model)
369        .arg("--dangerously-skip-permissions")
370        .arg(&full_prompt)
371        .env_clear()
372        .env("PATH", std::env::var("PATH").unwrap_or_default())
373        .env("HOME", std::env::var("HOME").unwrap_or_default())
374        .stdin(std::process::Stdio::null())
375        .stdout(std::process::Stdio::piped())
376        .stderr(std::process::Stdio::piped());
377    propagate_opencode_env_sync(&mut cmd);
378    crate::spawn::apply_cwd_isolation(&mut cmd)?;
379    Ok(cmd)
380}
381
382/// Spawn opencode with setsid for process group isolation but WITHOUT
383/// RLIMIT_AS. The Bun runtime inside opencode uses aggressive virtual
384/// memory mappings that exceed the 4 GB limit applied to claude/codex.
385#[cfg(target_os = "linux")]
386pub fn spawn_opencode(cmd: &mut std::process::Command) -> std::io::Result<std::process::Child> {
387    use std::os::unix::process::CommandExt;
388    unsafe {
389        cmd.pre_exec(|| {
390            let sid = libc::setsid();
391            if sid == -1 {
392                let err = std::io::Error::last_os_error();
393                if err.raw_os_error() != Some(libc::EPERM) {
394                    return Err(err);
395                }
396            }
397            Ok(())
398        });
399    }
400    cmd.spawn()
401}
402
403#[cfg(not(target_os = "linux"))]
404pub fn spawn_opencode(cmd: &mut std::process::Command) -> std::io::Result<std::process::Child> {
405    #[cfg(unix)]
406    {
407        use std::os::unix::process::CommandExt;
408        unsafe {
409            cmd.pre_exec(|| {
410                let _ = libc::setsid();
411                Ok(())
412            });
413        }
414    }
415    cmd.spawn()
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn parse_version_valid() {
424        assert_eq!(parse_version("1.17.7").unwrap(), (1, 17, 7));
425        assert_eq!(parse_version("2.0.0").unwrap(), (2, 0, 0));
426    }
427
428    #[test]
429    fn parse_version_with_prefix() {
430        assert_eq!(parse_version("v1.17.7").unwrap(), (1, 17, 7));
431        assert_eq!(parse_version("opencode 1.17.7").unwrap(), (1, 17, 7));
432    }
433
434    #[test]
435    fn parse_version_invalid() {
436        assert!(parse_version("unknown").is_err());
437        assert!(parse_version("").is_err());
438    }
439
440    #[test]
441    fn validate_version_rejects_old() {
442        // We can't easily test with a real binary, so test the parse path
443        let v = parse_version("1.16.0").unwrap();
444        assert!(v < MIN_OPENCODE_VERSION);
445    }
446
447    #[test]
448    fn validate_version_accepts_minimum() {
449        let v = parse_version("1.17.0").unwrap();
450        assert!(v >= MIN_OPENCODE_VERSION);
451    }
452
453    #[test]
454    fn resolve_model_uses_default() {
455        // When no override and no env var, should return default
456        let model = resolve_opencode_model(None);
457        // May be overridden by env in CI, so just check it's non-empty
458        assert!(!model.is_empty());
459    }
460
461    #[test]
462    fn resolve_model_uses_override() {
463        let model = resolve_opencode_model(Some("opencode/test-model"));
464        assert_eq!(model, "opencode/test-model");
465    }
466
467    #[test]
468    fn resolve_timeout_uses_default() {
469        let t = resolve_opencode_timeout(None);
470        assert!(t > 0);
471    }
472
473    #[test]
474    fn resolve_timeout_uses_override() {
475        assert_eq!(resolve_opencode_timeout(Some(600)), 600);
476    }
477
478    #[test]
479    fn parse_opencode_output_extracts_text() {
480        let stdout = r#"{"type":"step_start","timestamp":1234,"sessionID":"ses_test","part":{"type":"step-start"}}
481{"type":"text","timestamp":1235,"sessionID":"ses_test","part":{"type":"text","text":"{\"entities\":[]}"}}
482{"type":"step_finish","timestamp":1236,"sessionID":"ses_test","part":{"type":"step-finish","tokens":{"total":100,"input":90,"output":10,"reasoning":0},"cost":0.0}}"#;
483
484        let (text, cost, tokens) = parse_opencode_output(stdout).unwrap();
485        assert_eq!(text, "{\"entities\":[]}");
486        assert_eq!(cost, 0.0);
487        assert_eq!(tokens, 100);
488    }
489
490    #[test]
491    fn parse_opencode_output_concatenates_multiple_text_events() {
492        let stdout = r#"{"type":"step_start","timestamp":1234,"sessionID":"s","part":{"type":"step-start"}}
493{"type":"text","timestamp":1235,"sessionID":"s","part":{"type":"text","text":"{\"ent"}}
494{"type":"text","timestamp":1236,"sessionID":"s","part":{"type":"text","text":"ities\":[]}"}}
495{"type":"step_finish","timestamp":1237,"sessionID":"s","part":{"type":"step-finish","tokens":{"total":50,"input":40,"output":10,"reasoning":0},"cost":0}}"#;
496
497        let (text, _, _) = parse_opencode_output(stdout).unwrap();
498        assert_eq!(text, "{\"entities\":[]}");
499    }
500
501    #[test]
502    fn parse_opencode_output_empty_fails() {
503        assert!(parse_opencode_output("").is_err());
504        assert!(parse_opencode_output("{\"type\":\"step_start\"}").is_err());
505    }
506
507    #[test]
508    fn parse_json_from_opencode_text_direct() {
509        let text = r#"{"entities":[],"relationships":[]}"#;
510        let parsed: serde_json::Value = parse_json_from_opencode_text(text).unwrap();
511        assert!(parsed.get("entities").is_some());
512    }
513
514    #[test]
515    fn parse_json_from_opencode_text_markdown_fence() {
516        let text = "Here is the result:\n```json\n{\"entities\":[]}\n```\nDone.";
517        let parsed: serde_json::Value = parse_json_from_opencode_text(text).unwrap();
518        assert!(parsed.get("entities").is_some());
519    }
520
521    #[test]
522    fn parse_json_from_opencode_text_extract_braces() {
523        let text = "The answer is {\"entities\":[]} and that's it.";
524        let parsed: serde_json::Value = parse_json_from_opencode_text(text).unwrap();
525        assert!(parsed.get("entities").is_some());
526    }
527
528    #[test]
529    fn parse_json_from_opencode_text_invalid() {
530        assert!(parse_json_from_opencode_text::<serde_json::Value>("no json here").is_err());
531    }
532
533    #[test]
534    fn build_command_has_correct_args() {
535        let cmd = build_opencode_command(
536            Path::new("/usr/bin/opencode"),
537            "opencode/big-pickle",
538            "test prompt",
539        )
540        .unwrap();
541        let argv: Vec<String> = cmd
542            .as_std()
543            .get_args()
544            .filter_map(|a| a.to_str().map(|s| s.to_string()))
545            .collect();
546
547        assert!(argv.contains(&"run".to_string()));
548        assert!(argv.contains(&"--format".to_string()));
549        assert!(argv.contains(&"json".to_string()));
550        assert!(argv.contains(&"-m".to_string()));
551        assert!(argv.contains(&"opencode/big-pickle".to_string()));
552        assert!(argv.contains(&"--dangerously-skip-permissions".to_string()));
553        assert!(argv.contains(&"test prompt".to_string()));
554    }
555}