Skip to main content

sqlite_graphrag/commands/
dry_run_backend.rs

1//! v1.0.84 (ADR-0042 / GAP-002): resolve and emit the LLM backend that
2//! WOULD be invoked for embedding without actually spawning the
3//! subprocess. Used by `--dry-run-backend` for CI audit and pre-flight
4//! sanity-check of `--llm-backend` before long ingestion sessions.
5//!
6//! The output is a compact JSON envelope on stdout. stderr carries the
7//! human-friendly summary so operators can run `sqlite-graphrag --dry-run-backend`
8//! without piping through `jaq`.
9//!
10//! ## Schema (`dry-run-backend.schema.json`)
11//!
12//! ```json
13//! {
14//!   "action": "dry_run_backend",
15//!   "backend": "codex|claude|none",
16//!   "binary": "/usr/local/bin/codex",
17//!   "model": "gpt-5.5",
18//!   "flavour": "codex|claude",
19//!   "chain": "claude",
20//!   "strict_env_clear": false
21//! }
22//! ```
23//!
24//! ## Implementation notes
25//!
26//! - We deliberately do NOT depend on the private fields of
27//!   `LlmEmbedding`. The struct's `binary` and `flavour` fields are
28//!   private to `crate::extract::llm_embedding`, so we re-probe the
29//!   PATH here (cheap, idempotent) instead of forcing the core to add
30//!   `pub(crate)` getters just for this audit path.
31//! - `model` comes from `LlmEmbedding::model_label()` which already
32//!   exposes a stable public string of the form `<flavour>:<model>`.
33//!   We strip the `<flavour>:` prefix to keep the schema flat.
34//! - When `--llm-backend none` is selected the envelope still emits
35//!   the same shape with empty `binary` and `model`, so downstream
36//!   pipelines can parse a single schema unconditionally.
37
38use crate::cli::{Cli, LlmBackendChoice};
39use crate::errors::AppError;
40use crate::extract::llm_embedding::LlmEmbedding;
41use crate::output::emit_json_compact;
42use crate::spawn::env_whitelist::is_strict_env_clear;
43use serde::Serialize;
44
45/// Compact JSON envelope emitted by `--dry-run-backend`.
46///
47/// Field order matches the documented schema. `chain` reflects
48/// `--llm-fallback` so operators can audit the fallback order without
49/// spawning `embedder::embed_with_fallback`.
50#[derive(Serialize)]
51pub struct DryRunBackendOutput {
52    /// Action.
53    pub action: &'static str,
54    /// Backend.
55    pub backend: &'static str,
56    /// Binary.
57    pub binary: String,
58    /// Model name or identifier.
59    pub model: String,
60    /// Flavour.
61    pub flavour: &'static str,
62    /// Chain.
63    pub chain: String,
64    /// Strict ENV clear.
65    pub strict_env_clear: bool,
66}
67
68/// Resolve the LLM backend that would be used for embedding and emit
69/// the JSON envelope. Returns `Err(AppError::Embedding)` when the
70/// requested backend CLI is missing from PATH.
71pub fn emit_dry_run_backend(cli: &Cli) -> Result<(), AppError> {
72    let payload = match cli.llm_backend {
73        LlmBackendChoice::None => DryRunBackendOutput {
74            action: "dry_run_backend",
75            backend: "none",
76            binary: String::new(),
77            model: String::new(),
78            flavour: "none",
79            chain: cli.llm_fallback.clone(),
80            strict_env_clear: is_strict_env_clear(),
81        },
82        LlmBackendChoice::Auto => {
83            // ADR-0038: codex is preferred; claude is the fallback when codex
84            // is absent. Mirrors `LlmEmbedding::detect_available()` exactly
85            // so the audit output never disagrees with the real spawn path.
86            let resolved = LlmEmbedding::detect_available()?;
87            backend_payload(&resolved, "codex-first-then-claude", cli, true)
88        }
89        LlmBackendChoice::Codex => {
90            let resolved = LlmEmbedding::detect_available()?;
91            let flavour = resolved.model_label();
92            // Guard: the user explicitly asked for codex. If detect_available
93            // returned a claude-backed client (no codex on PATH), we MUST
94            // surface that as an error rather than silently substitute.
95            // v1.0.84 (ADR-0042): claude must NOT silently replace codex
96            // when the user opts in via `--llm-backend codex`.
97            if flavour.starts_with("claude:") {
98                return Err(AppError::Embedding(
99                    crate::i18n::validation::embedding_dry_run_codex_not_on_path(),
100                ));
101            }
102            backend_payload(&resolved, "codex-explicit", cli, false)
103        }
104        LlmBackendChoice::Claude => {
105            let resolved = LlmEmbedding::detect_available()?;
106            let flavour = resolved.model_label();
107            // Symmetric guard for `--llm-backend claude`.
108            if flavour.starts_with("codex:") {
109                return Err(AppError::Embedding(
110                    crate::i18n::validation::embedding_dry_run_claude_not_on_path(),
111                ));
112            }
113            backend_payload(&resolved, "claude-explicit", cli, false)
114        }
115        LlmBackendChoice::Opencode => {
116            let resolved = LlmEmbedding::detect_available()?;
117            let flavour = resolved.model_label();
118            if !flavour.starts_with("opencode:") {
119                let hint = if flavour.starts_with("codex:") || flavour.starts_with("claude:") {
120                    crate::i18n::validation::embedding_dry_run_opencode_resolved_other(&flavour)
121                } else {
122                    crate::i18n::validation::embedding_dry_run_opencode_not_on_path()
123                };
124                return Err(AppError::Embedding(hint));
125            }
126            backend_payload(&resolved, "opencode-explicit", cli, false)
127        }
128        LlmBackendChoice::OpenRouter => DryRunBackendOutput {
129            action: "dry_run_backend",
130            backend: "openrouter",
131            binary: String::new(),
132            model: String::new(),
133            flavour: "openrouter",
134            chain: cli.llm_fallback.clone(),
135            strict_env_clear: is_strict_env_clear(),
136        },
137    };
138
139    emit_json_compact(&payload)?;
140    Ok(())
141}
142
143/// Build the envelope from a successfully-resolved `LlmEmbedding`.
144///
145/// `chain_label` documents which CLI knob produced this payload
146/// (e.g. `codex-explicit` vs `codex-first-then-claude`) so the audit
147/// output is self-describing.
148fn backend_payload(
149    resolved: &LlmEmbedding,
150    chain_label: &str,
151    cli: &Cli,
152    is_auto: bool,
153) -> DryRunBackendOutput {
154    // `model_label()` returns `<flavour>:<model>` — split on the FIRST
155    // colon so model names with colons (rare but possible) survive.
156    // `flavour` must be a `&'static str` (the struct field type), so we
157    // leak the slice into a `Box<str>` to obtain a `'static` reference.
158    let label = resolved.model_label();
159    let (flavour, model) = match label.split_once(':') {
160        Some((f, m)) => (f, m.to_string()),
161        None => ("unknown", label.to_string()),
162    };
163    let flavour: &'static str = Box::leak(flavour.to_string().into_boxed_str());
164
165    // Re-probe PATH to surface the binary path the audit envelope
166    // promises. We prefer `which::which` over the private `LlmEmbedding`
167    // field so this file compiles independently of the `extract`
168    // module's internal layout. The result is canonicalized when
169    // possible so symlinks and shim wrappers don't leak location.
170    let binary = which::which(if is_auto {
171        // For Auto, prefer whichever the real spawn would pick first.
172        if which::which("codex").is_ok() {
173            "codex"
174        } else {
175            "claude"
176        }
177    } else {
178        flavour
179    })
180    .ok()
181    .and_then(|p| std::fs::canonicalize(&p).ok().or(Some(p)))
182    .map(|p| p.display().to_string())
183    .unwrap_or_default();
184
185    // Backend string is the `LlmBackendChoice` name for clarity in CI
186    // logs (operators filter on `backend == "codex"` etc.).
187    let backend = match cli.llm_backend {
188        LlmBackendChoice::Auto => {
189            if flavour == "codex" {
190                "codex"
191            } else if flavour == "opencode" {
192                "opencode"
193            } else {
194                "claude"
195            }
196        }
197        LlmBackendChoice::Codex => "codex",
198        LlmBackendChoice::Claude => "claude",
199        LlmBackendChoice::Opencode => "opencode",
200        LlmBackendChoice::OpenRouter => "openrouter",
201        LlmBackendChoice::None => "none",
202    };
203
204    DryRunBackendOutput {
205        action: "dry_run_backend",
206        backend,
207        binary,
208        model,
209        flavour,
210        chain: if chain_label == "codex-first-then-claude" {
211            cli.llm_fallback.clone()
212        } else {
213            chain_label.to_string()
214        },
215        strict_env_clear: is_strict_env_clear(),
216    }
217}