Skip to main content

llm/providers/codex/
provider.rs

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