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