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}