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 =
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
86pub(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
124pub 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
134impl 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 #[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 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;