1use super::mappers::{map_messages, map_tools};
2use super::oauth::CodexTokenManager;
3use super::streaming::process_response_stream;
4use crate::oauth::credential_store::OAuthCredentialStore;
5use crate::provider::{
6 LlmResponseStream, ProviderFactory, StreamingModelProvider, get_context_window,
7};
8use crate::{Context, LlmError, Result};
9use async_openai::types::responses::{
10 CreateResponse, IncludeEnum, InputParam, Reasoning, ReasoningEffort, ReasoningSummary,
11 ResponseStreamEvent, ResponseTextParam, TextResponseFormatConfiguration, Verbosity,
12};
13use eventsource_stream::Eventsource;
14use futures::StreamExt;
15use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
16use std::sync::Arc;
17use tracing::debug;
18
19const CODEX_API_BASE: &str = "https://chatgpt.com/backend-api/codex";
20
21#[derive(Clone)]
22pub struct CodexProvider {
23 client: reqwest::Client,
24 model: String,
25 token_manager: Arc<CodexTokenManager<OAuthCredentialStore>>,
26}
27
28impl CodexProvider {
29 pub fn new(token_manager: CodexTokenManager<OAuthCredentialStore>) -> Self {
30 Self {
31 client: reqwest::Client::new(),
32 model: "gpt-5.4".to_string(),
33 token_manager: Arc::new(token_manager),
34 }
35 }
36
37 fn build_request(&self, context: &Context) -> Result<CreateResponse> {
38 let (system_prompt, input) = map_messages(context.messages())?;
39 let tools = if context.tools().is_empty() {
40 None
41 } else {
42 Some(map_tools(context.tools())?)
43 };
44
45 let codex_effort = context
46 .reasoning_effort()
47 .map_or(ReasoningEffort::Medium, to_codex_effort);
48
49 Ok(CreateResponse {
50 model: Some(self.model.clone()),
51 input: InputParam::Items(input),
52 instructions: system_prompt,
53 tools,
54 store: Some(false),
55 stream: Some(true),
56 reasoning: Some(Reasoning {
57 effort: Some(codex_effort),
58 summary: Some(ReasoningSummary::Auto),
59 }),
60 include: Some(vec![IncludeEnum::ReasoningEncryptedContent]),
61 text: Some(ResponseTextParam {
62 format: TextResponseFormatConfiguration::Text,
63 verbosity: Some(Verbosity::Medium),
64 }),
65 prompt_cache_key: context.prompt_cache_key().map(String::from),
66 ..Default::default()
67 })
68 }
69
70 async fn build_headers(&self) -> Result<HeaderMap> {
71 let (access_token, account_id) = self.token_manager.get_valid_token().await?;
72
73 let mut headers = HeaderMap::new();
74 headers.insert(
75 AUTHORIZATION,
76 HeaderValue::from_str(&format!("Bearer {access_token}"))
77 .map_err(|e| LlmError::InvalidApiKey(e.to_string()))?,
78 );
79 headers.insert(
80 "chatgpt-account-id",
81 HeaderValue::from_str(&account_id)
82 .map_err(|e| LlmError::InvalidApiKey(e.to_string()))?,
83 );
84 headers.insert(
85 "OpenAI-Beta",
86 HeaderValue::from_static("responses=experimental"),
87 );
88 headers.insert("originator", HeaderValue::from_static("codex_cli_rs"));
89
90 Ok(headers)
91 }
92
93 async fn send_request(
99 &self,
100 request: CreateResponse,
101 headers: HeaderMap,
102 ) -> Result<impl futures::Stream<Item = Result<ResponseStreamEvent>>> {
103 let url = format!("{CODEX_API_BASE}/responses");
104
105 debug!("Sending request to Codex API: {url}");
106 debug!(
107 "Codex request body: {}",
108 serde_json::to_string(&request).unwrap_or_else(|_| "<failed to serialize>".to_string())
109 );
110
111 let response = self
112 .client
113 .post(&url)
114 .headers(headers)
115 .json(&request)
116 .send()
117 .await
118 .map_err(|e| LlmError::ApiRequest(e.to_string()))?;
119
120 if !response.status().is_success() {
121 let status = response.status();
122 let error_text = response
123 .text()
124 .await
125 .unwrap_or_else(|_| "Unknown error".to_string());
126
127 if status.as_u16() == 401 || status.as_u16() == 403 {
128 self.token_manager.clear_cache().await;
129 }
130
131 return Err(LlmError::ApiError(format!(
132 "Codex API request failed with status {status}: {error_text}"
133 )));
134 }
135
136 let event_stream = response.bytes_stream().eventsource().filter_map(|result| {
137 std::future::ready(match result {
138 Ok(event) if event.data == "[DONE]" => None,
139 Ok(event) => match serde_json::from_str::<ResponseStreamEvent>(&event.data) {
140 Ok(parsed) => Some(Ok(parsed)),
141 Err(e) => {
142 debug!(
143 "Failed to parse Codex SSE line: {} - Error: {e}",
144 event.data
145 );
146 None
147 }
148 },
149 Err(e) => Some(Err(LlmError::IoError(e.to_string()))),
150 })
151 });
152
153 Ok(event_stream)
154 }
155}
156
157impl ProviderFactory for CodexProvider {
158 fn from_env() -> Result<Self> {
159 let store = OAuthCredentialStore::new(super::PROVIDER_ID);
160 let token_manager = CodexTokenManager::new(store, super::PROVIDER_ID);
161 Ok(Self::new(token_manager))
162 }
163
164 fn with_model(mut self, model: &str) -> Self {
165 self.model = model.to_string();
166 self
167 }
168}
169
170impl StreamingModelProvider for CodexProvider {
171 fn model(&self) -> Option<crate::LlmModel> {
172 format!("{}:{}", super::PROVIDER_ID, self.model)
173 .parse()
174 .ok()
175 }
176
177 fn context_window(&self) -> Option<u32> {
178 get_context_window(super::PROVIDER_ID, &self.model)
179 }
180
181 fn stream_response(&self, context: &Context) -> LlmResponseStream {
182 let provider = self.clone();
183 let context = match self.model() {
184 Some(model) => context.filter_encrypted_reasoning(&model),
185 None => context.clone(),
186 };
187
188 Box::pin(async_stream::stream! {
189 let headers = match provider.build_headers().await {
190 Ok(h) => h,
191 Err(e) => {
192 yield Err(e);
193 return;
194 }
195 };
196
197 let request = match provider.build_request(&context) {
198 Ok(r) => r,
199 Err(e) => {
200 yield Err(e);
201 return;
202 }
203 };
204
205 let event_stream = match provider.send_request(request, headers).await {
206 Ok(s) => s,
207 Err(e) => {
208 yield Err(e);
209 return;
210 }
211 };
212
213 let mut response_stream = Box::pin(process_response_stream(event_stream));
214 while let Some(result) = response_stream.next().await {
215 yield result;
216 }
217 })
218 }
219
220 fn display_name(&self) -> String {
221 format!("Codex ({})", self.model)
222 }
223}
224
225fn to_codex_effort(effort: crate::ReasoningEffort) -> ReasoningEffort {
226 match effort {
227 crate::ReasoningEffort::Low => ReasoningEffort::Low,
228 crate::ReasoningEffort::Medium => ReasoningEffort::Medium,
229 crate::ReasoningEffort::High => ReasoningEffort::High,
230 crate::ReasoningEffort::Xhigh => ReasoningEffort::Xhigh,
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::ChatMessage;
238 use crate::ContentBlock;
239 use crate::ToolDefinition;
240 use crate::types::IsoString;
241
242 fn create_test_provider() -> CodexProvider {
243 let store = OAuthCredentialStore::new("codex-test");
244 let tm = CodexTokenManager::new(store, "codex-test");
245 CodexProvider::new(tm).with_model("gpt-5.4")
246 }
247
248 #[test]
249 fn build_request_simple() {
250 let provider = create_test_provider();
251 let context = Context::new(
252 vec![ChatMessage::User {
253 content: vec![ContentBlock::text("Hello")],
254 timestamp: IsoString::now(),
255 }],
256 vec![],
257 );
258
259 let request = provider.build_request(&context).unwrap();
260 assert_eq!(request.model.as_deref(), Some("gpt-5.4"));
261 assert_eq!(request.store, Some(false));
262 assert_eq!(request.stream, Some(true));
263 assert!(request.tools.is_none());
264 assert!(request.instructions.is_none());
265 if let InputParam::Items(items) = &request.input {
266 assert_eq!(items.len(), 1);
267 } else {
268 panic!("Expected InputParam::Items");
269 }
270 }
271
272 #[test]
273 fn build_request_with_system_and_tools() {
274 let provider = create_test_provider();
275 let context = Context::new(
276 vec![
277 ChatMessage::System {
278 content: "You are helpful".to_string(),
279 timestamp: IsoString::now(),
280 },
281 ChatMessage::User {
282 content: vec![ContentBlock::text("Hello")],
283 timestamp: IsoString::now(),
284 },
285 ],
286 vec![ToolDefinition {
287 name: "bash".to_string(),
288 description: "Run a command".to_string(),
289 parameters: r#"{"type": "object", "properties": {"cmd": {"type": "string"}}}"#
290 .to_string(),
291 server: None,
292 }],
293 );
294
295 let request = provider.build_request(&context).unwrap();
296 assert!(request.instructions.is_some());
297 if let InputParam::Items(items) = &request.input {
298 assert_eq!(items.len(), 1);
299 } else {
300 panic!("Expected InputParam::Items");
301 }
302 assert!(request.tools.is_some());
303 assert_eq!(request.tools.as_ref().unwrap().len(), 1);
304 }
305
306 #[test]
307 fn display_name_includes_model() {
308 let provider = create_test_provider();
309 assert_eq!(provider.display_name(), "Codex (gpt-5.4)");
310 }
311
312 #[test]
313 fn build_request_defaults_to_medium_effort() {
314 let provider = create_test_provider();
315 let context = Context::new(
316 vec![ChatMessage::User {
317 content: vec![ContentBlock::text("Hi")],
318 timestamp: IsoString::now(),
319 }],
320 vec![],
321 );
322
323 let request = provider.build_request(&context).unwrap();
324 let json = serde_json::to_value(&request).unwrap();
325 assert_eq!(json["reasoning"]["effort"], "medium");
326 }
327
328 #[test]
329 fn build_request_uses_context_reasoning_effort() {
330 let provider = create_test_provider();
331 let mut context = Context::new(
332 vec![ChatMessage::User {
333 content: vec![ContentBlock::text("Think hard")],
334 timestamp: IsoString::now(),
335 }],
336 vec![],
337 );
338 context.set_reasoning_effort(Some(crate::ReasoningEffort::High));
339
340 let request = provider.build_request(&context).unwrap();
341 let json = serde_json::to_value(&request).unwrap();
342 assert_eq!(json["reasoning"]["effort"], "high");
343 }
344
345 #[test]
346 fn build_request_serializes_correctly() {
347 let provider = create_test_provider();
348 let context = Context::new(
349 vec![ChatMessage::User {
350 content: vec![ContentBlock::text("Hi")],
351 timestamp: IsoString::now(),
352 }],
353 vec![],
354 );
355
356 let request = provider.build_request(&context).unwrap();
357 let json = serde_json::to_value(&request).unwrap();
358
359 assert_eq!(json["model"], "gpt-5.4");
360 assert_eq!(json["store"], false);
361 assert_eq!(json["stream"], true);
362 assert_eq!(json["reasoning"]["effort"], "medium");
363 assert_eq!(json["text"]["verbosity"], "medium");
364 assert_eq!(json["include"][0], "reasoning.encrypted_content");
365 }
366
367 #[test]
368 fn build_request_includes_prompt_cache_key_when_set() {
369 let provider = create_test_provider();
370 let mut context = Context::new(
371 vec![ChatMessage::User {
372 content: vec![ContentBlock::text("Hi")],
373 timestamp: IsoString::now(),
374 }],
375 vec![],
376 );
377 context.set_prompt_cache_key(Some("session-abc".to_string()));
378
379 let request = provider.build_request(&context).unwrap();
380 assert_eq!(request.prompt_cache_key.as_deref(), Some("session-abc"));
381 }
382
383 #[test]
384 fn build_request_omits_prompt_cache_key_when_unset() {
385 let provider = create_test_provider();
386 let context = Context::new(
387 vec![ChatMessage::User {
388 content: vec![ContentBlock::text("Hi")],
389 timestamp: IsoString::now(),
390 }],
391 vec![],
392 );
393
394 let request = provider.build_request(&context).unwrap();
395 assert!(request.prompt_cache_key.is_none());
396 }
397}