1use super::mappers::{map_messages, map_tools};
2use super::oauth::CodexTokenManager;
3use super::streaming::{CodexStreamEvent, process_response_stream};
4use crate::provider::{LlmResponseStream, StreamingModelProvider, get_context_window};
5use crate::{Context, LlmError, Result};
6use aether_auth::OAuthCredentialStorage;
7use async_openai::types::responses::{
8 CreateResponse, IncludeEnum, InputParam, Reasoning, ReasoningSummary, ResponseTextParam,
9 TextResponseFormatConfiguration, Verbosity,
10};
11use eventsource_stream::Eventsource;
12use futures::StreamExt;
13use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
14use serde_json::Value;
15use std::sync::Arc;
16use tracing::debug;
17
18const CODEX_API_BASE: &str = "https://chatgpt.com/backend-api/codex";
19
20#[derive(Clone)]
21pub struct CodexProvider {
22 base_url: String,
23 client: reqwest::Client,
24 model: String,
25 token_manager: Arc<CodexTokenManager>,
26}
27
28impl CodexProvider {
29 pub fn new(store: Arc<dyn OAuthCredentialStorage>) -> Self {
30 let token_manager = CodexTokenManager::new(store, super::PROVIDER_ID);
31 Self {
32 base_url: CODEX_API_BASE.to_string(),
33 client: reqwest::Client::new(),
34 model: "gpt-5.5".to_string(),
35 token_manager: Arc::new(token_manager),
36 }
37 }
38
39 pub fn with_connection(mut self, connection: crate::ProviderConnectionConfig) -> Self {
40 if let Some(base_url) = connection.base_url {
41 self.base_url = base_url.trim_end_matches('/').to_string();
42 }
43 self
44 }
45
46 pub fn with_model(mut self, model: &str) -> Self {
47 self.model = model.to_string();
48 self
49 }
50
51 fn build_typed_request(&self, context: &Context) -> Result<CreateResponse> {
52 let (system_prompt, input) = map_messages(context.messages())?;
53 let tools = if context.tools().is_empty() { None } else { Some(map_tools(context.tools())?) };
54
55 Ok(CreateResponse {
56 model: Some(self.model.clone()),
57 input: InputParam::Items(input),
58 instructions: system_prompt,
59 tools,
60 store: Some(false),
61 stream: Some(true),
62 reasoning: Some(Reasoning { effort: None, summary: Some(ReasoningSummary::Auto) }),
63 include: Some(vec![IncludeEnum::ReasoningEncryptedContent]),
64 text: Some(ResponseTextParam {
65 format: TextResponseFormatConfiguration::Text,
66 verbosity: Some(Verbosity::Medium),
67 }),
68 prompt_cache_key: context.prompt_cache_key().map(String::from),
69 ..Default::default()
70 })
71 }
72
73 fn build_wire_request(&self, context: &Context) -> Result<Value> {
77 let mut body = serde_json::to_value(self.build_typed_request(context)?)?;
78 let effort = context.reasoning_effort().map_or("medium", crate::ReasoningEffort::as_str);
79 body["reasoning"]["effort"] = effort.into();
80 Ok(body)
81 }
82
83 async fn build_headers(&self) -> Result<HeaderMap> {
84 let (access_token, account_id) = self.token_manager.get_valid_token().await?;
85
86 let mut headers = HeaderMap::new();
87 headers.insert(
88 AUTHORIZATION,
89 HeaderValue::from_str(&format!("Bearer {access_token}"))
90 .map_err(|e| LlmError::InvalidApiKey(e.to_string()))?,
91 );
92 headers.insert(
93 "chatgpt-account-id",
94 HeaderValue::from_str(&account_id).map_err(|e| LlmError::InvalidApiKey(e.to_string()))?,
95 );
96 headers.insert("OpenAI-Beta", HeaderValue::from_static("responses=experimental"));
97 headers.insert("originator", HeaderValue::from_static("codex_cli_rs"));
98
99 Ok(headers)
100 }
101
102 async fn send_request(
108 &self,
109 request: Value,
110 headers: HeaderMap,
111 ) -> Result<impl futures::Stream<Item = Result<CodexStreamEvent>>> {
112 let url = format!("{}/responses", self.base_url);
113
114 debug!("Sending request to Codex API: {url}");
115 debug!(
116 "Codex request body: {}",
117 serde_json::to_string(&request).unwrap_or_else(|_| "<failed to serialize>".to_string())
118 );
119
120 let response = self.client.post(&url).headers(headers).json(&request).send().await?;
121
122 if !response.status().is_success() {
123 let status = response.status();
124 let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
125
126 if matches!(status.as_u16(), 401 | 403) {
127 self.token_manager.clear_cache().await;
128 }
129
130 let message = format!("Codex API request failed with status {status}: {error_text}");
131 return Err(match status.as_u16() {
132 429 => LlmError::RateLimited(message),
133 s if (500..600).contains(&s) => LlmError::ServerError { status: Some(s), message },
134 _ => LlmError::ApiError(message),
135 });
136 }
137
138 let event_stream = response.bytes_stream().eventsource().filter_map(|result| {
139 std::future::ready(match result {
140 Ok(event) if event.data == "[DONE]" => None,
141 Ok(event) => match serde_json::from_str::<CodexStreamEvent>(&event.data) {
142 Ok(parsed) => Some(Ok(parsed)),
143 Err(e) => {
144 debug!("Failed to parse Codex SSE line: {} - Error: {e}", event.data);
145 None
146 }
147 },
148 Err(e) => Some(Err(LlmError::StreamInterrupted(e.to_string()))),
149 })
150 });
151
152 Ok(event_stream)
153 }
154}
155
156impl StreamingModelProvider for CodexProvider {
157 fn model(&self) -> Option<crate::LlmModel> {
158 format!("{}:{}", super::PROVIDER_ID, self.model).parse().ok()
159 }
160
161 fn context_window(&self) -> Option<u32> {
162 get_context_window(super::PROVIDER_ID, &self.model)
163 }
164
165 fn stream_response(&self, context: &Context) -> LlmResponseStream {
166 let provider = self.clone();
167 let context = match self.model() {
168 Some(model) => context.filter_encrypted_reasoning(&model),
169 None => context.clone(),
170 };
171
172 Box::pin(async_stream::stream! {
173 let headers = match provider.build_headers().await {
174 Ok(h) => h,
175 Err(e) => {
176 yield Err(e);
177 return;
178 }
179 };
180
181 let request = match provider.build_wire_request(&context) {
182 Ok(r) => r,
183 Err(e) => {
184 yield Err(e);
185 return;
186 }
187 };
188
189 let event_stream = match provider.send_request(request, headers).await {
190 Ok(s) => s,
191 Err(e) => {
192 yield Err(e);
193 return;
194 }
195 };
196
197 let mut response_stream = Box::pin(process_response_stream(event_stream));
198 while let Some(result) = response_stream.next().await {
199 yield result;
200 }
201 })
202 }
203
204 fn display_name(&self) -> String {
205 format!("Codex ({})", self.model)
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::ChatMessage;
213 use crate::ContentBlock;
214 use crate::ToolDefinition;
215 use crate::providers::test_capture_server::CaptureServer;
216 use crate::types::IsoString;
217 use aether_auth::{FakeOAuthCredentialStore, OAuthCredential};
218 use base64::Engine;
219 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
220
221 #[test]
222 fn context_window_uses_codex_subscription_limit() {
223 let provider = create_test_provider();
224 assert_eq!(provider.context_window(), Some(272_000));
225 }
226
227 #[test]
228 fn display_name_includes_model() {
229 let provider = create_test_provider();
230 assert_eq!(provider.display_name(), "Codex (gpt-5.5)");
231 }
232
233 #[tokio::test]
234 async fn stream_response_sends_max_effort_on_the_wire() {
235 let mut server = CaptureServer::start().await;
236 let provider = server_backed_provider(&server).with_model("gpt-5.6-sol");
237 let mut context = Context::new(
238 vec![
239 ChatMessage::System { content: "You are helpful".to_string(), timestamp: IsoString::now() },
240 ChatMessage::User { content: vec![ContentBlock::text("Think harder")], timestamp: IsoString::now() },
241 ],
242 vec![ToolDefinition::new(
243 "bash",
244 "Run a command",
245 r#"{"type": "object", "properties": {"cmd": {"type": "string"}}}"#,
246 )],
247 );
248 context.set_reasoning_effort(Some(crate::ReasoningEffort::Max));
249 context.set_prompt_cache_key(Some("session-abc".to_string()));
250
251 let responses = provider.stream_response(&context).collect::<Vec<_>>().await;
252 let captured = server.captured().await;
253
254 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
255 assert_eq!(captured.body["reasoning"]["effort"], "max");
256 assert_eq!(captured.body["model"], "gpt-5.6-sol");
257 assert_eq!(captured.body["instructions"], "You are helpful");
258 assert_eq!(captured.body["tools"].as_array().unwrap().len(), 1);
259 assert_eq!(captured.body["prompt_cache_key"], "session-abc");
260 assert_eq!(captured.body["store"], false);
261 assert_eq!(captured.body["stream"], true);
262 assert_eq!(captured.headers["chatgpt-account-id"], "account-1");
263 }
264
265 #[tokio::test]
266 async fn stream_response_defaults_to_medium_effort_on_the_wire() {
267 let mut server = CaptureServer::start().await;
268 let provider = server_backed_provider(&server);
269 let context = Context::new(
270 vec![ChatMessage::User { content: vec![ContentBlock::text("Hello")], timestamp: IsoString::now() }],
271 vec![],
272 );
273
274 let responses = provider.stream_response(&context).collect::<Vec<_>>().await;
275 let captured = server.captured().await;
276
277 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
278 assert_eq!(captured.body["reasoning"]["effort"], "medium");
279 }
280
281 fn server_backed_provider(server: &CaptureServer) -> CodexProvider {
282 let credential = OAuthCredential {
283 client_id: "test".to_string(),
284 access_token: test_jwt("account-1"),
285 refresh_token: None,
286 expires_at: Some(u64::MAX),
287 granted_scopes: Vec::new(),
288 };
289 let store: Arc<dyn OAuthCredentialStorage> =
290 Arc::new(FakeOAuthCredentialStore::new().with_credential("codex", credential));
291 CodexProvider::new(store).with_connection(crate::ProviderConnectionConfig {
292 base_url: Some(server.base_url.clone()),
293 ..Default::default()
294 })
295 }
296
297 fn test_jwt(account_id: &str) -> String {
298 let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
299 let payload = URL_SAFE_NO_PAD
300 .encode(serde_json::json!({"https://api.openai.com/auth": {"chatgpt_account_id": account_id}}).to_string());
301 format!("{header}.{payload}.signature")
302 }
303
304 fn create_test_provider() -> CodexProvider {
305 let store: Arc<dyn OAuthCredentialStorage> = Arc::new(FakeOAuthCredentialStore::new());
306 CodexProvider::new(store).with_model("gpt-5.5")
307 }
308}