1use super::oauth::CodexTokenManager;
2use crate::catalog::Provider;
3use crate::provider::{LlmResponseStream, StreamingModelProvider, display_name, validate_reasoning};
4use crate::provider_connection::DEFAULT_STREAM_IDLE_TIMEOUT;
5use crate::providers::http::{SseData, http_client, post_sse, responses_code};
6use crate::providers::openai::responses::mappers::{ResponsesRequestPolicy, build_request};
7use crate::providers::openai::responses::streaming::decode_responses;
8use crate::providers::openai::responses::types::ResponsesRequest;
9use crate::providers::response_stream::{OpenedStream, response_stream};
10use crate::{Context, LlmModel, Result};
11use aether_auth::OAuthCredentialStorage;
12use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
13use std::sync::Arc;
14use std::time::Duration;
15use tracing::debug;
16
17const CODEX_API_BASE: &str = "https://chatgpt.com/backend-api/codex";
18const CODEX_CLIENT_VERSION: &str = "0.159.0";
19
20#[derive(Clone)]
21pub struct CodexProvider {
22 base_url: String,
23 client: reqwest::Client,
24 model: String,
25 request_model: Option<String>,
26 token_manager: Arc<CodexTokenManager>,
27 idle_timeout: Duration,
28}
29
30impl CodexProvider {
31 pub fn new(store: Arc<dyn OAuthCredentialStorage>) -> Self {
32 Self::from_token_manager(CodexTokenManager::new(store, super::PROVIDER_ID))
33 }
34
35 pub(crate) fn from_token_manager(token_manager: CodexTokenManager) -> Self {
36 Self {
37 base_url: CODEX_API_BASE.to_string(),
38 client: http_client(),
39 model: "gpt-5.5".to_string(),
40 request_model: None,
41 token_manager: Arc::new(token_manager),
42 idle_timeout: DEFAULT_STREAM_IDLE_TIMEOUT,
43 }
44 }
45
46 pub fn with_connection(mut self, connection: crate::ProviderConnectionConfig) -> Self {
47 if let Some(base_url) = connection.base_url {
48 self.base_url = base_url.trim_end_matches('/').to_string();
49 }
50 self.request_model = connection.request_model;
51 self.idle_timeout = connection.idle_timeout;
52 self
53 }
54
55 pub fn with_model(mut self, model: &str) -> Self {
56 self.model = model.to_string();
57 self
58 }
59
60 pub async fn forget_cached_token(&self) {
61 self.token_manager.clear_cache().await;
62 }
63
64 fn build_wire_request(&self, context: &Context) -> Result<ResponsesRequest> {
65 let identity = self.model();
66 validate_reasoning(context, identity.as_ref())?;
67 let wire_model = self.request_model.as_deref().unwrap_or(&self.model);
68 build_request(identity.as_ref(), wire_model, context, &ResponsesRequestPolicy::CODEX)
69 }
70
71 async fn build_headers(&self) -> Result<HeaderMap> {
72 let (access_token, account_id) = self.token_manager.get_valid_token().await?;
73
74 let mut authorization = HeaderValue::from_str(&format!("Bearer {access_token}"))?;
75 authorization.set_sensitive(true);
76
77 let mut headers = HeaderMap::new();
78 headers.insert(AUTHORIZATION, authorization);
79 headers.insert("chatgpt-account-id", HeaderValue::from_str(&account_id)?);
80 headers.insert("originator", HeaderValue::from_static("codex_cli_rs"));
81 headers.insert("version", HeaderValue::from_static(CODEX_CLIENT_VERSION));
82
83 Ok(headers)
84 }
85
86 async fn send_request(&self, request: ResponsesRequest, headers: HeaderMap) -> Result<OpenedStream<SseData>> {
88 let url = format!("{}/responses", self.base_url);
89
90 debug!("Sending request to Codex API: {url}");
91 debug!(?request, "Codex request body");
92
93 match post_sse(&self.client, &url, headers, &request, responses_code).await {
94 Ok(opened) => Ok(opened),
95 Err(error) => {
96 if error.provider().map(|provider| provider.kind) == Some(crate::ProviderErrorKind::Authentication) {
97 self.token_manager.clear_cache().await;
98 }
99 Err(error)
100 }
101 }
102 }
103}
104
105impl StreamingModelProvider for CodexProvider {
106 fn display_name(&self) -> String {
107 display_name(Provider::Codex, &self.model)
108 }
109
110 fn model(&self) -> Option<LlmModel> {
111 LlmModel::parse_for(Provider::Codex, &self.model).ok()
112 }
113
114 fn stream_response(&self, context: &Context) -> LlmResponseStream {
115 let provider = self.clone();
116 let context = context.clone();
117
118 response_stream(
119 async move {
120 let request = provider.build_wire_request(&context)?;
121 let headers = provider.build_headers().await?;
122 provider.send_request(request, headers).await
123 },
124 decode_responses(),
125 self.idle_timeout,
126 )
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::ChatMessage;
134 use crate::LlmError;
135 use crate::ToolDefinition;
136 use crate::providers::test_capture_server::{CaptureServer, hello_context};
137 use aether_auth::{MemoryCredentialStore, OAuthCredential};
138 use base64::Engine;
139 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
140 use futures::StreamExt;
141
142 #[tokio::test]
143 async fn all_codex_models_reject_disabled_before_authentication() {
144 for model in
145 crate::LlmModel::all().iter().filter(|model| model.provider_enum() == crate::catalog::Provider::Codex)
146 {
147 assert!(!model.effective_reasoning_levels().contains(&crate::ReasoningEffort::Disabled));
148 let provider = CodexProvider::new(Arc::new(MemoryCredentialStore::new())).with_model(&model.model_id());
149 let mut context = Context::new(vec![], vec![]);
150 context.set_reasoning_effort(crate::ReasoningEffort::Disabled);
151 let responses = provider.stream_response(&context).collect::<Vec<_>>().await;
152 assert_eq!(responses.len(), 1);
153 assert!(matches!(&responses[0], Err(LlmError::ReasoningValidation(_))));
154 assert!(!responses[0].as_ref().unwrap_err().is_retryable());
155 }
156 }
157
158 #[test]
159 fn context_window_uses_codex_subscription_limit() {
160 let provider = create_test_provider();
161 assert_eq!(provider.context_window(), Some(272_000));
162 }
163
164 #[test]
165 fn display_name_includes_model() {
166 let provider = create_test_provider();
167 assert_eq!(provider.display_name(), "Codex (gpt-5.5)");
168 assert_eq!(create_test_provider().with_model("gpt-custom").display_name(), "Codex (gpt-custom)");
169 }
170
171 #[tokio::test]
172 async fn stream_response_sends_supported_protocol_version_for_gpt_6_luna() {
173 let mut server = CaptureServer::start_responses().await;
174 let provider = server_backed_provider(&server).with_model("gpt-6-luna");
175 let mut context = Context::new(
176 vec![ChatMessage::system("You are helpful"), ChatMessage::user("Think harder")],
177 vec![ToolDefinition::new(
178 "bash",
179 "Run a command",
180 serde_json::from_str(r#"{"type": "object", "properties": {"cmd": {"type": "string"}}}"#).unwrap(),
181 )],
182 );
183 context.set_reasoning_effort(crate::ReasoningEffort::Max);
184 context.set_prompt_cache_key(Some("session-abc".to_string()));
185
186 let responses = provider.stream_response(&context).collect::<Vec<_>>().await;
187 let captured = server.captured().await;
188
189 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
190 assert_eq!(captured.body["reasoning"]["effort"], "max");
191 assert!(captured.body["reasoning"].get("context").is_none());
192 assert_eq!(captured.body["model"], "gpt-6-luna");
193 assert_eq!(captured.body["instructions"], "You are helpful");
194 assert_eq!(captured.body["tools"].as_array().unwrap().len(), 1);
195 assert!(captured.body["tools"][0].get("strict").is_none());
196 assert_eq!(captured.body["parallel_tool_calls"], true);
197 assert_eq!(captured.body["input"][0]["role"], "user");
198 assert_eq!(captured.body["prompt_cache_key"], "session-abc");
199 assert_eq!(captured.body["store"], false);
200 assert_eq!(captured.body["stream"], true);
201 assert_eq!(captured.headers["chatgpt-account-id"], "account-1");
202 assert_eq!(captured.headers["version"], "0.159.0");
203 assert_eq!(captured.headers["accept"], "text/event-stream");
204 assert!(captured.headers.get("x-openai-internal-codex-responses-lite").is_none());
205 assert!(captured.headers.get("OpenAI-Beta").is_none());
206 }
207
208 #[tokio::test]
209 async fn stream_response_defaults_to_medium_effort_on_the_wire() {
210 let mut server = CaptureServer::start_responses().await;
211 let provider = server_backed_provider(&server);
212 let context = Context::new(vec![ChatMessage::user("Hello")], vec![]);
213
214 let responses = provider.stream_response(&context).collect::<Vec<_>>().await;
215 let captured = server.captured().await;
216
217 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
218 assert_eq!(captured.body["reasoning"]["effort"], "medium");
219 assert_eq!(captured.body["reasoning"]["summary"], "auto");
220 }
221
222 #[tokio::test]
223 async fn request_model_routes_the_request_without_changing_catalog_identity() {
224 let mut server = CaptureServer::start_responses().await;
225 let provider = authenticated_provider().with_connection(crate::ProviderConnectionConfig {
226 base_url: Some(server.base_url.clone()),
227 request_model: Some("codex-deployment".to_string()),
228 ..Default::default()
229 });
230
231 let responses = provider.stream_response(&hello_context()).collect::<Vec<_>>().await;
232 let captured = server.captured().await;
233
234 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
235 assert_eq!(captured.body["model"], "codex-deployment");
236 assert_eq!(provider.model().unwrap().to_string(), "codex:gpt-5.5");
237 }
238
239 #[tokio::test]
240 async fn a_logout_reaches_a_provider_that_forgets_its_cached_token() {
241 let mut server = CaptureServer::start_responses().await;
242 let store = authenticated_store();
243 let provider = CodexProvider::new(Arc::clone(&store)).with_connection(crate::ProviderConnectionConfig {
244 base_url: Some(server.base_url.clone()),
245 ..Default::default()
246 });
247
248 let before = provider.stream_response(&hello_context()).collect::<Vec<_>>().await;
249 server.captured().await;
250 store.delete("codex").await.unwrap();
251 provider.forget_cached_token().await;
252 let after = provider.stream_response(&hello_context()).collect::<Vec<_>>().await;
253
254 assert!(before.iter().all(Result::is_ok), "{before:?}");
255 assert!(matches!(after.as_slice(), [Err(LlmError::OAuthError(_))]), "{after:?}");
256 }
257
258 fn server_backed_provider(server: &CaptureServer) -> CodexProvider {
259 authenticated_provider().with_connection(crate::ProviderConnectionConfig {
260 base_url: Some(server.base_url.clone()),
261 ..Default::default()
262 })
263 }
264
265 fn authenticated_provider() -> CodexProvider {
266 CodexProvider::new(authenticated_store())
267 }
268
269 fn authenticated_store() -> Arc<dyn OAuthCredentialStorage> {
270 let credential = OAuthCredential {
271 client_id: "test".to_string(),
272 access_token: test_jwt("account-1"),
273 refresh_token: None,
274 expires_at: Some(u64::MAX),
275 };
276 Arc::new(MemoryCredentialStore::new().with_credential("codex", credential))
277 }
278
279 fn test_jwt(account_id: &str) -> String {
280 let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
281 let payload = URL_SAFE_NO_PAD
282 .encode(serde_json::json!({"https://api.openai.com/auth": {"chatgpt_account_id": account_id}}).to_string());
283 format!("{header}.{payload}.signature")
284 }
285
286 fn create_test_provider() -> CodexProvider {
287 let store: Arc<dyn OAuthCredentialStorage> = Arc::new(MemoryCredentialStore::new());
288 CodexProvider::new(store).with_model("gpt-5.5")
289 }
290}