Skip to main content

drep/llm/codex/
mod.rs

1//! ChatGPT-subscription reviews through the separately installed Codex CLI.
2
3mod capture;
4mod command;
5mod diagnostics;
6mod events;
7mod process;
8
9use std::path::PathBuf;
10use std::time::Duration;
11
12use crate::config::{BackendKind, LlmConfig, ReasoningEffort};
13use crate::llm::error::{BackendErrorKind, LlmError};
14use crate::llm::json_parsing::Extracted;
15
16use command::ChildEnvironment;
17
18/// Redacted readiness facts safe for diagnostics and cache identity.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub(crate) struct CodexStatus {
21    cli_version: String,
22}
23
24impl CodexStatus {
25    pub(crate) fn new(cli_version: impl Into<String>) -> Self {
26        Self {
27            cli_version: cli_version.into(),
28        }
29    }
30
31    pub(crate) fn cli_version(&self) -> &str {
32        &self.cli_version
33    }
34}
35
36/// Verify that the installed Codex CLI is using ChatGPT-managed credentials.
37///
38/// The underlying diagnostic may include account paths and identifiers. Only
39/// the CLI version and the successful authentication classification cross this
40/// boundary.
41pub(crate) fn current_status() -> Result<CodexStatus, String> {
42    CodexRuntime::probe_current()
43        .map(|runtime| CodexStatus::new(runtime.cli_version))
44        .map_err(|err| err.to_string())
45}
46
47/// Process state shared by every Codex provider in one configured chain.
48///
49/// Authentication is account-wide, not model-specific. Probing once avoids
50/// launching the CLI repeatedly when the chain names more than one Codex
51/// model, while keeping the redacted result local to this run.
52#[derive(Debug, Clone)]
53pub(crate) struct CodexRuntime {
54    executable: PathBuf,
55    environment: ChildEnvironment,
56    cli_version: String,
57}
58
59impl CodexRuntime {
60    pub(crate) fn current() -> Result<Self, LlmError> {
61        Self::probe_current().map_err(|err| LlmError::NotConfigured(err.to_string()))
62    }
63
64    fn probe_current() -> Result<Self, diagnostics::DiagnosticError> {
65        let executable = PathBuf::from("codex");
66        let environment = ChildEnvironment::current();
67        let status =
68            diagnostics::probe(&executable, &environment, diagnostics::DIAGNOSTIC_TIMEOUT)?;
69        Ok(Self {
70            executable,
71            environment,
72            cli_version: status.cli_version,
73        })
74    }
75
76    pub(crate) fn client(&self, settings: CodexSettings) -> CodexClient {
77        CodexClient::from_settings(
78            settings,
79            self.executable.clone(),
80            self.environment.clone(),
81            self.cli_version.clone(),
82        )
83    }
84}
85
86/// Provider fields validated before any Codex process is started.
87pub(crate) struct CodexSettings {
88    model: String,
89    reasoning_effort: Option<ReasoningEffort>,
90    timeout_secs: u64,
91}
92
93impl CodexSettings {
94    pub(crate) fn from_config(cfg: &LlmConfig) -> Result<Self, LlmError> {
95        if !cfg.enabled {
96            return Err(LlmError::NotConfigured(
97                "LLM is disabled in config (set `enabled = true`)".to_owned(),
98            ));
99        }
100        if cfg.backend != BackendKind::Codex {
101            return Err(LlmError::NotConfigured(
102                "Codex client requires `backend = \"codex\"`".to_owned(),
103            ));
104        }
105        let model = cfg
106            .model
107            .clone()
108            .filter(|model| !model.trim().is_empty())
109            .ok_or_else(|| LlmError::NotConfigured("LLM model is not set in config".to_owned()))?;
110        let reasoning_effort = cfg.reasoning_effort.clone();
111        if matches!(reasoning_effort, Some(ReasoningEffort::Unknown(_))) {
112            return Err(LlmError::NotConfigured(
113                "Codex reasoning_effort is not recognised".to_owned(),
114            ));
115        }
116        Ok(Self {
117            model,
118            reasoning_effort,
119            timeout_secs: cfg.timeout_secs,
120        })
121    }
122}
123
124/// A configured ChatGPT-subscription client.
125pub struct CodexClient {
126    executable: PathBuf,
127    model: String,
128    reasoning_effort: Option<ReasoningEffort>,
129    timeout_secs: u64,
130    cli_version: String,
131    environment: ChildEnvironment,
132}
133
134// Executable and environment may contain private account paths.
135impl std::fmt::Debug for CodexClient {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.debug_struct("CodexClient")
138            .field("model", &self.model)
139            .field("reasoning_effort", &self.reasoning_effort)
140            .field("timeout_secs", &self.timeout_secs)
141            .field("cli_version", &self.cli_version)
142            .finish()
143    }
144}
145
146impl CodexClient {
147    /// Inject process state for tests without changing PATH or the environment.
148    #[cfg(test)]
149    pub(crate) fn at(
150        cfg: &LlmConfig,
151        executable: PathBuf,
152        environment: ChildEnvironment,
153        cli_version: impl Into<String>,
154    ) -> Result<Self, LlmError> {
155        let settings = CodexSettings::from_config(cfg)?;
156        let cli_version = cli_version.into();
157        if cli_version.is_empty() {
158            return Err(LlmError::NotConfigured(
159                "Codex CLI diagnostic did not report a version".to_owned(),
160            ));
161        }
162        Ok(Self::from_settings(
163            settings,
164            executable,
165            environment,
166            cli_version,
167        ))
168    }
169
170    fn from_settings(
171        settings: CodexSettings,
172        executable: PathBuf,
173        environment: ChildEnvironment,
174        cli_version: String,
175    ) -> Self {
176        Self {
177            executable,
178            model: settings.model,
179            reasoning_effort: settings.reasoning_effort,
180            timeout_secs: settings.timeout_secs,
181            cli_version,
182            environment,
183        }
184    }
185
186    #[cfg(test)]
187    pub(crate) fn for_test(
188        cfg: &LlmConfig,
189        executable: impl Into<PathBuf>,
190        environment: impl IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
191        cli_version: impl Into<String>,
192    ) -> Result<Self, LlmError> {
193        Self::at(
194            cfg,
195            executable.into(),
196            ChildEnvironment::from_iter(environment),
197            cli_version,
198        )
199    }
200
201    pub fn model(&self) -> &str {
202        &self.model
203    }
204
205    #[cfg(test)]
206    pub(crate) fn cli_version(&self) -> &str {
207        &self.cli_version
208    }
209
210    #[cfg(test)]
211    pub(crate) fn reasoning_effort(&self) -> Option<&ReasoningEffort> {
212        self.reasoning_effort.as_ref()
213    }
214
215    /// Stable, non-personal identity for cache and reporting.
216    pub fn identity(&self) -> String {
217        format!(
218            "codex:chatgpt:cli={}:effort={}",
219            self.cli_version,
220            self.reasoning_effort
221                .as_ref()
222                .map_or("default", ReasoningEffort::as_str)
223        )
224    }
225
226    pub async fn complete_json(
227        &self,
228        system_prompt: &str,
229        user_content: &str,
230    ) -> Result<Extracted, LlmError> {
231        let workspace = tempfile::Builder::new()
232            .prefix("drep-codex-")
233            .tempdir()
234            .map_err(|err| {
235                LlmError::NotConfigured(format!("could not create Codex workspace: {err}"))
236            })?;
237        let instructions = workspace.path().join("instructions.md");
238        let schema = workspace.path().join("schema.json");
239        let cwd = workspace.path().join("cwd");
240        std::fs::create_dir(&cwd).map_err(|err| {
241            LlmError::NotConfigured(format!("could not create empty Codex cwd: {err}"))
242        })?;
243        std::fs::write(&instructions, command::instructions_text(system_prompt)).map_err(
244            |err| LlmError::NotConfigured(format!("could not write Codex instructions: {err}")),
245        )?;
246        std::fs::write(
247            &schema,
248            crate::analysis::response_contract::output_schema_bytes(),
249        )
250        .map_err(|err| {
251            LlmError::NotConfigured(format!("could not write Codex response schema: {err}"))
252        })?;
253
254        let args = command::invocation_args(
255            &self.model,
256            self.reasoning_effort.as_ref(),
257            &instructions,
258            &schema,
259            &cwd,
260        )
261        .map_err(|err| LlmError::NotConfigured(err.to_string()))?;
262        let output = process::run(
263            &self.executable,
264            &args,
265            &self.environment,
266            &cwd,
267            user_content,
268            Duration::from_secs(self.timeout_secs),
269        )
270        .await?;
271        if output.status.code().is_none() {
272            return Err(LlmError::Transport {
273                status: None,
274                message: format!(
275                    "Codex CLI terminated without an exit status: {}",
276                    output.stderr_excerpt()
277                ),
278            });
279        }
280        if !output.status.success() {
281            let detail = match events::parse_jsonl(output.stdout.as_slice()) {
282                Err(events::EventError::ReportedError(message)) => message,
283                _ => output.stderr_excerpt(),
284            };
285            return Err(LlmError::Backend {
286                kind: BackendErrorKind::UnknownExit,
287                message: format!(
288                    "Codex CLI exited with status {}: {}",
289                    output.status.code().expect("checked above"),
290                    detail
291                ),
292            });
293        }
294        let value = events::parse_jsonl(output.stdout.as_slice()).map_err(map_event_error)?;
295        Ok(Extracted::Complete(value))
296    }
297}
298
299fn map_event_error(err: events::EventError) -> LlmError {
300    match err {
301        events::EventError::ReportedError(message) => LlmError::Backend {
302            kind: BackendErrorKind::UnknownExit,
303            message,
304        },
305        events::EventError::MalformedFinal(message) => LlmError::Unparseable(message),
306        events::EventError::Read(_)
307        | events::EventError::MissingFinalMessage
308        | events::EventError::MissingTurnCompletion => LlmError::Transport {
309            status: None,
310            message: err.to_string(),
311        },
312        other => LlmError::Backend {
313            kind: BackendErrorKind::Contract,
314            message: other.to_string(),
315        },
316    }
317}
318
319#[cfg(test)]
320mod tests;