1mod 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#[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
36pub(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#[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
85pub(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
123pub 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 #[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 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;