llm/providers/codex/
provider.rs1use super::oauth::CodexTokenManager;
2use crate::provider::{LlmResponseStream, StreamingModelProvider, get_context_window, stream_from};
3use crate::providers::openai_responses::mappers::{ResponsesRequestPolicy, build_wire_request};
4use crate::providers::openai_responses::transport::{ResponsesConnection, process_connection, send};
5use crate::{Context, LlmError, Result};
6use aether_auth::OAuthCredentialStorage;
7use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
8use std::sync::Arc;
9use tracing::debug;
10
11const CODEX_API_BASE: &str = "https://chatgpt.com/backend-api/codex";
12const CODEX_CLIENT_VERSION: &str = "0.144.0";
13
14#[derive(Clone)]
15pub struct CodexProvider {
16 base_url: String,
17 client: reqwest::Client,
18 model: String,
19 token_manager: Arc<CodexTokenManager>,
20}
21
22impl CodexProvider {
23 pub fn new(store: Arc<dyn OAuthCredentialStorage>) -> Self {
24 let token_manager = CodexTokenManager::new(store, super::PROVIDER_ID);
25 Self {
26 base_url: CODEX_API_BASE.to_string(),
27 client: reqwest::Client::new(),
28 model: "gpt-5.5".to_string(),
29 token_manager: Arc::new(token_manager),
30 }
31 }
32
33 pub fn with_connection(mut self, connection: crate::ProviderConnectionConfig) -> Self {
34 if let Some(base_url) = connection.base_url {
35 self.base_url = base_url.trim_end_matches('/').to_string();
36 }
37 self
38 }
39
40 pub fn with_model(mut self, model: &str) -> Self {
41 self.model = model.to_string();
42 self
43 }
44
45 fn build_wire_request(&self, context: &Context) -> Result<serde_json::Value> {
46 build_wire_request(&self.model, context, &ResponsesRequestPolicy::codex())
47 }
48
49 async fn build_headers(&self) -> Result<HeaderMap> {
50 let (access_token, account_id) = self.token_manager.get_valid_token().await?;
51
52 let mut headers = HeaderMap::new();
53 headers.insert(
54 AUTHORIZATION,
55 HeaderValue::from_str(&format!("Bearer {access_token}"))
56 .map_err(|e| LlmError::ProviderRequest(e.to_string()))?,
57 );
58 headers.insert(
59 "chatgpt-account-id",
60 HeaderValue::from_str(&account_id).map_err(|e| LlmError::ProviderRequest(e.to_string()))?,
61 );
62 headers.insert("originator", HeaderValue::from_static("codex_cli_rs"));
63 headers.insert("version", HeaderValue::from_static(CODEX_CLIENT_VERSION));
64
65 Ok(headers)
66 }
67
68 async fn send_request(&self, request: serde_json::Value, headers: HeaderMap) -> Result<ResponsesConnection> {
74 let url = format!("{}/responses", self.base_url);
75
76 debug!("Sending request to Codex API: {url}");
77 debug!(
78 "Codex request body: {}",
79 serde_json::to_string(&request).unwrap_or_else(|_| "<failed to serialize>".to_string())
80 );
81
82 match send(&self.client, &url, headers, request).await {
83 Ok(connection) => Ok(connection),
84 Err(error) => {
85 if error.provider().map(|provider| provider.kind) == Some(crate::ProviderErrorKind::Authentication) {
86 self.token_manager.clear_cache().await;
87 }
88 Err(error)
89 }
90 }
91 }
92}
93
94impl StreamingModelProvider for CodexProvider {
95 fn model(&self) -> Option<crate::LlmModel> {
96 format!("{}:{}", super::PROVIDER_ID, self.model).parse().ok()
97 }
98
99 fn context_window(&self) -> Option<u32> {
100 get_context_window(super::PROVIDER_ID, &self.model)
101 }
102
103 fn stream_response(&self, context: &Context) -> LlmResponseStream {
104 let provider = self.clone();
105 let context = context.clone();
106
107 stream_from(
108 async move {
109 let headers = provider.build_headers().await?;
110 let request = provider.build_wire_request(&context)?;
111 provider.send_request(request, headers).await
112 },
113 process_connection,
114 )
115 }
116
117 fn display_name(&self) -> String {
118 format!("Codex ({})", self.model)
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use crate::ChatMessage;
126 use crate::ToolDefinition;
127 use crate::providers::test_capture_server::CaptureServer;
128 use aether_auth::{FakeOAuthCredentialStore, OAuthCredential};
129 use base64::Engine;
130 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
131 use futures::StreamExt;
132
133 #[test]
134 fn context_window_uses_codex_subscription_limit() {
135 let provider = create_test_provider();
136 assert_eq!(provider.context_window(), Some(272_000));
137 }
138
139 #[test]
140 fn display_name_includes_model() {
141 let provider = create_test_provider();
142 assert_eq!(provider.display_name(), "Codex (gpt-5.5)");
143 }
144
145 #[tokio::test]
146 async fn stream_response_sends_supported_protocol_version_for_gpt_5_6_luna() {
147 let mut server = CaptureServer::start_responses().await;
148 let provider = server_backed_provider(&server).with_model("gpt-5.6-luna");
149 let mut context = Context::new(
150 vec![ChatMessage::system("You are helpful"), ChatMessage::user("Think harder")],
151 vec![ToolDefinition::new(
152 "bash",
153 "Run a command",
154 serde_json::from_str(r#"{"type": "object", "properties": {"cmd": {"type": "string"}}}"#).unwrap(),
155 )],
156 );
157 context.set_reasoning_effort(Some(crate::ReasoningEffort::Max));
158 context.set_prompt_cache_key(Some("session-abc".to_string()));
159
160 let responses = provider.stream_response(&context).collect::<Vec<_>>().await;
161 let captured = server.captured().await;
162
163 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
164 assert_eq!(captured.body["reasoning"]["effort"], "max");
165 assert!(captured.body["reasoning"].get("context").is_none());
166 assert_eq!(captured.body["model"], "gpt-5.6-luna");
167 assert_eq!(captured.body["instructions"], "You are helpful");
168 assert_eq!(captured.body["tools"].as_array().unwrap().len(), 1);
169 assert!(captured.body.get("parallel_tool_calls").is_none());
170 assert_eq!(captured.body["input"][0]["role"], "user");
171 assert_eq!(captured.body["prompt_cache_key"], "session-abc");
172 assert_eq!(captured.body["store"], false);
173 assert_eq!(captured.body["stream"], true);
174 assert_eq!(captured.headers["chatgpt-account-id"], "account-1");
175 assert_eq!(captured.headers["version"], "0.144.0");
176 assert_eq!(captured.headers["accept"], "text/event-stream");
177 assert!(captured.headers.get("x-openai-internal-codex-responses-lite").is_none());
178 assert!(captured.headers.get("OpenAI-Beta").is_none());
179 }
180
181 #[tokio::test]
182 async fn stream_response_defaults_to_medium_effort_on_the_wire() {
183 let mut server = CaptureServer::start_responses().await;
184 let provider = server_backed_provider(&server);
185 let context = Context::new(vec![ChatMessage::user("Hello")], vec![]);
186
187 let responses = provider.stream_response(&context).collect::<Vec<_>>().await;
188 let captured = server.captured().await;
189
190 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
191 assert_eq!(captured.body["reasoning"]["effort"], "medium");
192 }
193
194 fn server_backed_provider(server: &CaptureServer) -> CodexProvider {
195 let credential = OAuthCredential {
196 client_id: "test".to_string(),
197 access_token: test_jwt("account-1"),
198 refresh_token: None,
199 expires_at: Some(u64::MAX),
200 };
201 let store: Arc<dyn OAuthCredentialStorage> =
202 Arc::new(FakeOAuthCredentialStore::new().with_credential("codex", credential));
203 CodexProvider::new(store).with_connection(crate::ProviderConnectionConfig {
204 base_url: Some(server.base_url.clone()),
205 ..Default::default()
206 })
207 }
208
209 fn test_jwt(account_id: &str) -> String {
210 let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
211 let payload = URL_SAFE_NO_PAD
212 .encode(serde_json::json!({"https://api.openai.com/auth": {"chatgpt_account_id": account_id}}).to_string());
213 format!("{header}.{payload}.signature")
214 }
215
216 fn create_test_provider() -> CodexProvider {
217 let store: Arc<dyn OAuthCredentialStorage> = Arc::new(FakeOAuthCredentialStore::new());
218 CodexProvider::new(store).with_model("gpt-5.5")
219 }
220}