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 = diagnostics::probe(&executable, &environment)?;
68        Ok(Self {
69            executable,
70            environment,
71            cli_version: status.cli_version,
72        })
73    }
74
75    pub(crate) fn client(&self, settings: CodexSettings) -> CodexClient {
76        CodexClient::from_settings(
77            settings,
78            self.executable.clone(),
79            self.environment.clone(),
80            self.cli_version.clone(),
81        )
82    }
83}
84
85/// Provider fields validated before any Codex process is started.
86pub(crate) struct CodexSettings {
87    model: String,
88    reasoning_effort: Option<ReasoningEffort>,
89    timeout_secs: u64,
90}
91
92impl CodexSettings {
93    pub(crate) fn from_config(cfg: &LlmConfig) -> Result<Self, LlmError> {
94        if !cfg.enabled {
95            return Err(LlmError::NotConfigured(
96                "LLM is disabled in config (set `enabled = true`)".to_owned(),
97            ));
98        }
99        if cfg.backend != BackendKind::Codex {
100            return Err(LlmError::NotConfigured(
101                "Codex client requires `backend = \"codex\"`".to_owned(),
102            ));
103        }
104        let model = cfg
105            .model
106            .clone()
107            .filter(|model| !model.trim().is_empty())
108            .ok_or_else(|| LlmError::NotConfigured("LLM model is not set in config".to_owned()))?;
109        let reasoning_effort = cfg.reasoning_effort.clone();
110        if matches!(reasoning_effort, Some(ReasoningEffort::Unknown(_))) {
111            return Err(LlmError::NotConfigured(
112                "Codex reasoning_effort is not recognised".to_owned(),
113            ));
114        }
115        Ok(Self {
116            model,
117            reasoning_effort,
118            timeout_secs: cfg.timeout_secs,
119        })
120    }
121}
122
123/// A configured ChatGPT-subscription client.
124pub struct CodexClient {
125    executable: PathBuf,
126    model: String,
127    reasoning_effort: Option<ReasoningEffort>,
128    timeout_secs: u64,
129    cli_version: String,
130    environment: ChildEnvironment,
131}
132
133impl std::fmt::Debug for CodexClient {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_struct("CodexClient")
136            .field("model", &self.model)
137            .field("reasoning_effort", &self.reasoning_effort)
138            .field("timeout_secs", &self.timeout_secs)
139            .field("cli_version", &self.cli_version)
140            .finish()
141    }
142}
143
144impl CodexClient {
145    /// Inject process state for tests without changing PATH or the environment.
146    #[cfg(test)]
147    pub(crate) fn at(
148        cfg: &LlmConfig,
149        executable: PathBuf,
150        environment: ChildEnvironment,
151        cli_version: impl Into<String>,
152    ) -> Result<Self, LlmError> {
153        let settings = CodexSettings::from_config(cfg)?;
154        let cli_version = cli_version.into();
155        if cli_version.is_empty() {
156            return Err(LlmError::NotConfigured(
157                "Codex CLI diagnostic did not report a version".to_owned(),
158            ));
159        }
160        Ok(Self::from_settings(
161            settings,
162            executable,
163            environment,
164            cli_version,
165        ))
166    }
167
168    fn from_settings(
169        settings: CodexSettings,
170        executable: PathBuf,
171        environment: ChildEnvironment,
172        cli_version: String,
173    ) -> Self {
174        Self {
175            executable,
176            model: settings.model,
177            reasoning_effort: settings.reasoning_effort,
178            timeout_secs: settings.timeout_secs,
179            cli_version,
180            environment,
181        }
182    }
183
184    #[cfg(test)]
185    pub(crate) fn for_test(
186        cfg: &LlmConfig,
187        executable: impl Into<PathBuf>,
188        environment: impl IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
189        cli_version: impl Into<String>,
190    ) -> Result<Self, LlmError> {
191        Self::at(
192            cfg,
193            executable.into(),
194            ChildEnvironment::from_iter(environment),
195            cli_version,
196        )
197    }
198
199    pub fn model(&self) -> &str {
200        &self.model
201    }
202
203    #[cfg(test)]
204    pub(crate) fn cli_version(&self) -> &str {
205        &self.cli_version
206    }
207
208    #[cfg(test)]
209    pub(crate) fn reasoning_effort(&self) -> Option<&ReasoningEffort> {
210        self.reasoning_effort.as_ref()
211    }
212
213    /// Stable, non-personal identity for cache and reporting.
214    pub fn identity(&self) -> String {
215        format!(
216            "codex:chatgpt:cli={}:effort={}",
217            self.cli_version,
218            self.reasoning_effort
219                .as_ref()
220                .map_or("default", ReasoningEffort::as_str)
221        )
222    }
223
224    pub async fn complete_json(
225        &self,
226        system_prompt: &str,
227        user_content: &str,
228    ) -> Result<Extracted, LlmError> {
229        let workspace = tempfile::Builder::new()
230            .prefix("drep-codex-")
231            .tempdir()
232            .map_err(|err| {
233                LlmError::NotConfigured(format!("could not create Codex workspace: {err}"))
234            })?;
235        let instructions = workspace.path().join("instructions.md");
236        let schema = workspace.path().join("schema.json");
237        let cwd = workspace.path().join("cwd");
238        std::fs::create_dir(&cwd).map_err(|err| {
239            LlmError::NotConfigured(format!("could not create empty Codex cwd: {err}"))
240        })?;
241        std::fs::write(&instructions, command::instructions_text(system_prompt)).map_err(
242            |err| LlmError::NotConfigured(format!("could not write Codex instructions: {err}")),
243        )?;
244        std::fs::write(
245            &schema,
246            crate::analysis::response_contract::output_schema_bytes(),
247        )
248        .map_err(|err| {
249            LlmError::NotConfigured(format!("could not write Codex response schema: {err}"))
250        })?;
251
252        let args = command::invocation_args(
253            &self.model,
254            self.reasoning_effort.as_ref(),
255            &instructions,
256            &schema,
257            &cwd,
258        )
259        .map_err(|err| LlmError::NotConfigured(err.to_string()))?;
260        let output = process::run(
261            &self.executable,
262            &args,
263            &self.environment,
264            &cwd,
265            user_content,
266            Duration::from_secs(self.timeout_secs),
267        )
268        .await?;
269        if output.status.code().is_none() {
270            return Err(LlmError::Transport {
271                status: None,
272                message: format!(
273                    "Codex CLI terminated without an exit status: {}",
274                    output.stderr_excerpt()
275                ),
276            });
277        }
278        if !output.status.success() {
279            let detail = match events::parse_jsonl(output.stdout.as_slice()) {
280                Err(events::EventError::ReportedError(message)) => message,
281                _ => output.stderr_excerpt(),
282            };
283            return Err(LlmError::Backend {
284                kind: BackendErrorKind::UnknownExit,
285                message: format!(
286                    "Codex CLI exited with status {}: {}",
287                    output.status.code().expect("checked above"),
288                    detail
289                ),
290            });
291        }
292        let value = events::parse_jsonl(output.stdout.as_slice()).map_err(map_event_error)?;
293        Ok(Extracted::Complete(value))
294    }
295}
296
297fn map_event_error(err: events::EventError) -> LlmError {
298    match err {
299        events::EventError::ReportedError(message) => LlmError::Backend {
300            kind: BackendErrorKind::UnknownExit,
301            message,
302        },
303        events::EventError::MalformedFinal(message) => LlmError::Unparseable(message),
304        events::EventError::Read(_)
305        | events::EventError::MissingFinalMessage
306        | events::EventError::MissingTurnCompletion => LlmError::Transport {
307            status: None,
308            message: err.to_string(),
309        },
310        other => LlmError::Backend {
311            kind: BackendErrorKind::Contract,
312            message: other.to_string(),
313        },
314    }
315}
316
317#[cfg(test)]
318mod tests;