Skip to main content

lean_ctx/proxy/
chatgpt.rs

1use axum::{
2    body::Body,
3    extract::State,
4    http::{HeaderName, Request, StatusCode},
5    response::Response,
6};
7
8use super::{ProxyState, forward, openai_responses};
9
10/// Codex subscription model turns hit ChatGPT's Responses-compatible rail:
11/// `/backend-api/codex/responses`. Forward through the same compressor/metering
12/// path as OpenAI Responses, but target `https://chatgpt.com`.
13pub async fn codex_responses_handler(
14    State(state): State<ProxyState>,
15    mut req: Request<Body>,
16) -> Result<Response, StatusCode> {
17    // Drop the "responses-lite" marker before forwarding. Codex requests the
18    // reduced lite transport on its HTTP path (`supports_websockets = false`),
19    // but chatgpt.com rejects newer subscription models there
20    // ("This model is not supported when using X-OpenAI-Internal-Codex-Responses-Lite",
21    // seen with gpt-5.5). Stripping it makes chatgpt.com serve the full Responses
22    // rail every model supports; Codex parses the full stream identically
23    // (verified single- + multi-turn `previous_response_id` continuation). #623
24    req.headers_mut().remove(CODEX_RESPONSES_LITE_HEADER);
25    let upstream = state.chatgpt_upstream();
26    forward::forward_request(
27        State(state),
28        req,
29        &upstream,
30        "/backend-api/codex/responses",
31        openai_responses::compress_request_body,
32        "ChatGPT",
33        &[],
34    )
35    .await
36}
37
38/// Codex's HTTP fallback marks the reduced "responses-lite" transport with this
39/// header. chatgpt.com gates newer subscription models behind the full rail, so
40/// the Codex ChatGPT handler strips it (see [`codex_responses_handler`]).
41const CODEX_RESPONSES_LITE_HEADER: &str = "x-openai-internal-codex-responses-lite";
42
43/// ChatGPT's Codex rail rejects WS-only continuation fields such as
44/// `previous_response_id`; ask Codex to retry through the HTTP/SSE path.
45pub async fn codex_responses_ws_handler(
46    State(_state): State<ProxyState>,
47    _headers: axum::http::HeaderMap,
48    _ws: axum::extract::ws::WebSocketUpgrade,
49) -> Response {
50    chatgpt_responses_ws_fallback_response()
51}
52
53fn chatgpt_responses_ws_fallback_response() -> Response {
54    Response::builder()
55        .status(StatusCode::UPGRADE_REQUIRED)
56        .header("content-type", "application/json")
57        .body(Body::from(
58            r#"{"error":{"type":"unsupported_transport","message":"ChatGPT codex responses use HTTP/SSE; retry without WebSocket."}}"#,
59        ))
60        .expect("static response is valid")
61}
62
63/// ChatGPT backend calls outside the model rail are not model JSON and must not be
64/// compressed or cost-metered. They are credential-preserving passthroughs.
65///
66/// A WebSocket upgrade (Codex Desktop remote-control pairing, #597) is tunnelled
67/// verbatim to chatgpt.com by [`super::chatgpt_ws`]; plain HTTP/SSE falls through
68/// to the reqwest forward below.
69pub async fn backend_api_handler(
70    State(state): State<ProxyState>,
71    req: Request<Body>,
72) -> Result<Response, StatusCode> {
73    if super::chatgpt_ws::is_websocket_upgrade(req.headers()) {
74        return Ok(super::chatgpt_ws::passthrough(state, req).await);
75    }
76
77    let (parts, body) = req.into_parts();
78    let body_bytes = axum::body::to_bytes(body, forward::max_body_bytes())
79        .await
80        .map_err(|_| StatusCode::PAYLOAD_TOO_LARGE)?;
81    let upstream = state.chatgpt_upstream();
82    let path = parts
83        .uri
84        .path_and_query()
85        .map_or("/backend-api", axum::http::uri::PathAndQuery::as_str);
86    let url = format!("{upstream}{path}");
87
88    let mut upstream_req = state.client.request(parts.method.clone(), &url);
89    for (key, value) in &parts.headers {
90        if is_backend_passthrough_request_header(key) {
91            upstream_req = upstream_req.header(key.clone(), value.clone());
92        }
93    }
94
95    let response = upstream_req
96        .body(body_bytes.to_vec())
97        .send()
98        .await
99        .map_err(|e| {
100            tracing::error!("lean-ctx proxy: ChatGPT backend upstream error: {e}");
101            StatusCode::BAD_GATEWAY
102        })?;
103
104    let status = StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::OK);
105    let headers = response.headers().clone();
106    let is_stream = headers
107        .get("content-type")
108        .and_then(|v| v.to_str().ok())
109        .is_some_and(|ct| ct.contains("text/event-stream"));
110
111    let mut out = Response::builder().status(status);
112    for (key, value) in &headers {
113        if is_backend_passthrough_response_header(key) {
114            out = out.header(key, value);
115        }
116    }
117
118    if is_stream {
119        return out
120            .body(Body::from_stream(response.bytes_stream()))
121            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR);
122    }
123
124    let bytes = response
125        .bytes()
126        .await
127        .map_err(|_| StatusCode::BAD_GATEWAY)?;
128
129    out.body(Body::from(bytes))
130        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
131}
132
133fn is_backend_passthrough_request_header(name: &HeaderName) -> bool {
134    let lower = name.as_str().to_ascii_lowercase();
135    !matches!(
136        lower.as_str(),
137        "host"
138            | "connection"
139            | "content-length"
140            | "transfer-encoding"
141            | "upgrade"
142            | "keep-alive"
143            | "proxy-authenticate"
144            | "proxy-authorization"
145            | "te"
146            | "trailer"
147            | "accept-encoding"
148    )
149}
150
151fn is_backend_passthrough_response_header(name: &HeaderName) -> bool {
152    let lower = name.as_str().to_ascii_lowercase();
153    !matches!(
154        lower.as_str(),
155        "connection"
156            | "content-length"
157            | "transfer-encoding"
158            | "upgrade"
159            | "keep-alive"
160            | "proxy-authenticate"
161            | "proxy-authorization"
162            | "te"
163            | "trailer"
164    )
165}
166
167#[cfg(test)]
168mod tests {
169    use std::sync::Arc;
170    use std::time::Duration;
171
172    use tokio::io::{AsyncReadExt, AsyncWriteExt};
173
174    use super::*;
175    use crate::core::config::Upstreams;
176
177    fn proxy_state(chatgpt_upstream: String) -> ProxyState {
178        let (_tx, rx) = tokio::sync::watch::channel(Arc::new(Upstreams {
179            anthropic: "https://api.anthropic.com".into(),
180            openai: "https://api.openai.com".into(),
181            chatgpt: chatgpt_upstream,
182            gemini: "https://generativelanguage.googleapis.com".into(),
183        }));
184        ProxyState {
185            client: reqwest::Client::new(),
186            port: 0,
187            stats: Arc::new(crate::proxy::ProxyStats::default()),
188            introspect: Arc::new(crate::proxy::introspect::IntrospectState::default()),
189            upstreams: rx,
190            chatgpt_cookies: crate::proxy::chatgpt_cookies::shared_chatgpt_cloudflare_cookie_store(
191            ),
192        }
193    }
194
195    #[test]
196    fn codex_responses_ws_requests_trigger_http_fallback() {
197        let response = chatgpt_responses_ws_fallback_response();
198        assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
199        assert_eq!(
200            response
201                .headers()
202                .get(axum::http::header::CONTENT_TYPE)
203                .unwrap(),
204            "application/json"
205        );
206    }
207
208    async fn spawn_streaming_upstream() -> (String, tokio::sync::oneshot::Receiver<String>) {
209        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
210        let addr = listener.local_addr().unwrap();
211        let (tx, rx) = tokio::sync::oneshot::channel();
212        tokio::spawn(async move {
213            let (mut socket, _) = listener.accept().await.unwrap();
214            let mut buf = Vec::new();
215            loop {
216                let mut chunk = [0_u8; 1024];
217                let n = socket.read(&mut chunk).await.unwrap();
218                if n == 0 {
219                    break;
220                }
221                buf.extend_from_slice(&chunk[..n]);
222                if buf.windows(4).any(|w| w == b"\r\n\r\n") {
223                    break;
224                }
225            }
226            let _ = tx.send(String::from_utf8_lossy(&buf).into_owned());
227            socket
228                .write_all(
229                    b"HTTP/1.1 200 OK\r\n\
230                      content-type: text/event-stream\r\n\
231                      mcp-session-id: server-session\r\n\
232                      cache-control: no-cache\r\n\
233                      x-custom-backend-state: passthrough\r\n\
234                      \r\n\
235                      event: message\n\
236                      data: {\"jsonrpc\":\"2.0\"}\n\n",
237                )
238                .await
239                .unwrap();
240            tokio::time::sleep(Duration::from_secs(2)).await;
241        });
242        (format!("http://{addr}"), rx)
243    }
244
245    #[tokio::test]
246    async fn backend_api_streams_mcp_sse_and_preserves_session_headers() {
247        let (upstream, seen_request) = spawn_streaming_upstream().await;
248        let state = proxy_state(upstream);
249        let req = Request::builder()
250            .method("POST")
251            .uri("/backend-api/ps/mcp?transport=streamable")
252            .header("Authorization", "Bearer codex-token")
253            .header("Mcp-Session-Id", "client-session")
254            .header("Last-Event-ID", "event-7")
255            .header("X-OpenAI-Product-Sku", "codex")
256            .header("X-OpenAI-Internal-Codex-Residency", "us")
257            .header("Originator", "codex_cli_rs")
258            .header("Accept", "application/json, text/event-stream")
259            .body(Body::empty())
260            .unwrap();
261
262        let response = tokio::time::timeout(
263            Duration::from_millis(500),
264            backend_api_handler(State(state), req),
265        )
266        .await
267        .expect("SSE passthrough must return after upstream headers")
268        .expect("backend request should succeed");
269
270        assert_eq!(response.status(), StatusCode::OK);
271        assert_eq!(
272            response.headers().get("mcp-session-id").unwrap(),
273            "server-session"
274        );
275        assert_eq!(
276            response.headers().get("x-custom-backend-state").unwrap(),
277            "passthrough"
278        );
279
280        let request = seen_request.await.unwrap().to_ascii_lowercase();
281        assert!(request.contains("post /backend-api/ps/mcp?transport=streamable http/1.1"));
282        assert!(request.contains("authorization: bearer codex-token"));
283        assert!(request.contains("mcp-session-id: client-session"));
284        assert!(request.contains("last-event-id: event-7"));
285        assert!(request.contains("x-openai-product-sku: codex"));
286        assert!(request.contains("x-openai-internal-codex-residency: us"));
287        assert!(request.contains("originator: codex_cli_rs"));
288    }
289}