use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::timeout;
use tracing::{debug, warn};
use super::claude_cli::{build_prompt, parse_inner_response};
use crate::ai::{AiProvider, AiRequest, AiResponse, AiUsage, ProviderCapabilities};
pub struct CodexCliProvider {
pub model: String,
}
#[async_trait]
impl AiProvider for CodexCliProvider {
async fn generate_content(&self, request: AiRequest) -> Result<AiResponse> {
let prompt = build_prompt(&request);
debug!("codex-cli prompt length: {} chars", prompt.len());
let mut child = Command::new("codex")
.args([
"exec",
"--json",
"--sandbox",
"read-only",
"-m",
&self.model,
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to spawn codex CLI: {}. Is it installed?", e))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(prompt.as_bytes()).await?;
stdin.flush().await?;
}
let output = timeout(Duration::from_secs(600), child.wait_with_output())
.await
.map_err(|_| anyhow::anyhow!("codex CLI timed out after 10 minutes"))?
.map_err(|e| anyhow::anyhow!("codex CLI wait error: {}", e))?;
if !output.stderr.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
if !line.trim().is_empty() {
debug!("[codex-cli stderr] {}", line);
}
}
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("codex CLI exited with {}: {}", output.status, stderr.trim());
}
let raw = String::from_utf8_lossy(&output.stdout);
let mut text_parts = Vec::new();
let mut usage: Option<AiUsage> = None;
for line in raw.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(event) = serde_json::from_str::<Value>(trimmed) {
match event["type"].as_str() {
Some("item.completed") => {
if let Some(text) = event["item"]["text"].as_str() {
text_parts.push(text.to_string());
}
}
Some("turn.completed") => {
let u = &event["usage"];
if !u.is_null() {
let input = u["input_tokens"].as_u64().unwrap_or(0) as usize;
let output_tokens = u["output_tokens"].as_u64().unwrap_or(0) as usize;
let cached = u["cached_input_tokens"].as_u64().unwrap_or(0) as usize;
usage = Some(AiUsage {
prompt_tokens: input,
completion_tokens: output_tokens,
total_tokens: input + output_tokens,
cached_tokens: if cached > 0 { Some(cached) } else { None },
});
}
}
_ => {}
}
}
}
let response_text = text_parts.join("\n");
if response_text.is_empty() {
warn!("codex-cli: no item.completed events found, using raw output");
return parse_inner_response(&raw, usage);
}
parse_inner_response(&response_text, usage)
}
fn estimate_tokens(&self, request: &AiRequest) -> usize {
let chars: usize = request
.messages
.iter()
.filter_map(|m| m.content.as_ref())
.map(|c| c.len())
.sum();
chars / 4
}
fn get_capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
model_name: self.model.clone(),
context_window_size: 200_000,
}
}
}