Skip to main content

llm/providers/codex/
provider.rs

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    /// Send the request and return a stream of SSE lines parsed into typed events.
94    ///
95    /// Uses manual SSE parsing because the Codex API does not return a
96    /// `Content-Type: text/event-stream` header, which `reqwest_eventsource`
97    /// (used by `async-openai`'s `create_stream`) requires.
98    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}