Skip to main content

claude_codex/providers/codex/
client.rs

1use std::sync::Arc;
2use std::time::{Duration, Instant};
3
4use crate::anthropic::sse::parse_sse_events;
5use crate::config;
6use crate::logging::create_logger;
7use crate::provider::RequestContext;
8use crate::retry::{compute_backoff_delay, should_retry_status, sleep};
9use crate::traffic::TrafficCapture;
10
11use super::auth::constants::{CODEX_API_ENDPOINT, ORIGINATOR, RESPONSES_LITE_ORIGINATOR};
12use super::auth::manager::CodexAuthManager;
13use super::auth::token_store::{DefaultCodexAuthStore, StoredAuth, file_store};
14use super::translate::request::ResponsesRequest;
15
16// ---------------------------------------------------------------------------
17// Errors
18// ---------------------------------------------------------------------------
19
20#[derive(Debug)]
21pub struct CodexError {
22    pub status: u16,
23    pub message: String,
24    pub detail: Option<String>,
25    pub retry_after: Option<String>,
26    pub origin: CodexErrorOrigin,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum CodexErrorOrigin {
31    Http,
32    WebSocket,
33    WebSocketHandshake,
34    Auth,
35    BufferedHttp,
36    BufferedWebSocket,
37}
38
39impl CodexError {
40    pub fn new(status: u16, message: String) -> Self {
41        Self {
42            status,
43            message,
44            detail: None,
45            retry_after: None,
46            origin: CodexErrorOrigin::Http,
47        }
48    }
49}
50
51impl std::fmt::Display for CodexError {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        write!(f, "Codex error {}: {}", self.status, self.message)
54    }
55}
56
57#[derive(Debug)]
58pub struct CodexHeaderTimeoutError {
59    pub timeout_ms: u64,
60}
61
62impl std::fmt::Display for CodexHeaderTimeoutError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(
65            f,
66            "Timed out waiting {}ms for Codex response headers",
67            self.timeout_ms
68        )
69    }
70}
71
72#[derive(Debug)]
73pub struct CodexTransportError {
74    pub message: String,
75}
76
77impl std::fmt::Display for CodexTransportError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        write!(f, "Codex transport error: {}", self.message)
80    }
81}
82
83// ---------------------------------------------------------------------------
84// Header builder
85// ---------------------------------------------------------------------------
86
87pub fn build_codex_headers(
88    auth: &StoredAuth,
89    ctx: &RequestContext,
90    use_responses_lite: bool,
91) -> Result<http::HeaderMap, CodexError> {
92    let mut headers = http::HeaderMap::new();
93    headers.insert(
94        http::header::CONTENT_TYPE,
95        header_value("content-type", "application/json")?,
96    );
97    headers.insert(
98        http::header::ACCEPT,
99        header_value("accept", "text/event-stream")?,
100    );
101    let bearer = format!("Bearer {}", auth.access);
102    headers.insert(
103        http::header::AUTHORIZATION,
104        header_value("authorization", &bearer)?,
105    );
106    let originator = if use_responses_lite {
107        RESPONSES_LITE_ORIGINATOR.to_string()
108    } else {
109        config::codex_originator(ORIGINATOR)
110    };
111    headers.insert("originator", header_value("originator", &originator)?);
112    headers.insert(
113        "openai-beta",
114        header_value("openai-beta", "responses=experimental")?,
115    );
116    if use_responses_lite {
117        headers.insert(
118            "x-openai-internal-codex-responses-lite",
119            header_value("x-openai-internal-codex-responses-lite", "true")?,
120        );
121    }
122    if let Some(ref account_id) = auth.account_id {
123        headers.insert(
124            "ChatGPT-Account-Id",
125            header_value("ChatGPT-Account-Id", account_id)?,
126        );
127    }
128    if let Some(ref session_id) = ctx.session_id {
129        headers.insert("session_id", header_value("session_id", session_id)?);
130        headers.insert(
131            "x-client-request-id",
132            header_value("x-client-request-id", session_id)?,
133        );
134        let window_id = format!("{session_id}:0");
135        headers.insert(
136            "x-codex-window-id",
137            header_value("x-codex-window-id", &window_id)?,
138        );
139    }
140    let user_agent =
141        config::codex_user_agent(&format!("claude-code-proxy/{}", env!("CARGO_PKG_VERSION")));
142    if !user_agent.is_empty() {
143        headers.insert(
144            http::header::USER_AGENT,
145            header_value("user-agent", &user_agent)?,
146        );
147    }
148    Ok(headers)
149}
150
151fn header_value(name: &str, value: &str) -> Result<http::HeaderValue, CodexError> {
152    http::HeaderValue::from_str(value).map_err(|e| CodexError {
153        status: 500,
154        message: format!("Failed to parse {name} header"),
155        detail: Some(e.to_string()),
156        retry_after: None,
157        origin: CodexErrorOrigin::Http,
158    })
159}
160
161// ---------------------------------------------------------------------------
162// WebSocket request shaping
163// ---------------------------------------------------------------------------
164
165pub fn build_websocket_request(
166    body: &ResponsesRequest,
167    continuation: Option<&super::continuation::ContinuationCandidate>,
168) -> serde_json::Value {
169    let mut payload = serde_json::to_value(body).unwrap_or_default();
170    let obj = payload.as_object_mut().expect("request must be an object");
171
172    // Omit the stream field for WebSocket transport
173    obj.remove("stream");
174    obj.insert("type".to_string(), serde_json::json!("response.create"));
175
176    // Apply continuation if available
177    if let Some(candidate) = continuation {
178        if let Some(ref prev_id) = candidate.previous_response_id {
179            obj.insert(
180                "previous_response_id".to_string(),
181                serde_json::json!(prev_id),
182            );
183        }
184        if let Some(ref delta) = candidate.input_delta {
185            obj.insert(
186                "input".to_string(),
187                serde_json::to_value(delta).unwrap_or_default(),
188            );
189        }
190    }
191
192    payload
193}
194
195// ---------------------------------------------------------------------------
196// Response
197// ---------------------------------------------------------------------------
198
199pub struct CodexResponse {
200    pub body: Vec<u8>,
201    pub status: u16,
202    pub headers: Vec<(String, String)>,
203}
204
205// ---------------------------------------------------------------------------
206// Client
207// ---------------------------------------------------------------------------
208
209const MAX_BUFFERED_TRANSPORT_RETRIES: u32 = 3;
210const MAX_BUFFERED_TRANSPORT_ATTEMPTS: u32 = MAX_BUFFERED_TRANSPORT_RETRIES + 1;
211const HTTP_RESPONSE_BODY_IDLE_TIMEOUT_MS: u64 = 300_000;
212
213pub struct CodexHttpClient {
214    client: reqwest::Client,
215    auth_manager: CodexAuthManager<DefaultCodexAuthStore>,
216    base_url: String,
217    header_timeout_ms: u64,
218    body_idle_timeout_ms: u64,
219    #[allow(dead_code)]
220    header_timeout_retries: u32,
221}
222
223impl Default for CodexHttpClient {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229impl CodexHttpClient {
230    pub fn new() -> Self {
231        let timeout_ms = 60_000;
232        Self {
233            client: reqwest::Client::builder()
234                .connect_timeout(Duration::from_secs(15))
235                .build()
236                .expect("failed to create HTTP client"),
237            auth_manager: CodexAuthManager::new(file_store()),
238            base_url: config::codex_base_url(CODEX_API_ENDPOINT),
239            header_timeout_ms: timeout_ms,
240            body_idle_timeout_ms: HTTP_RESPONSE_BODY_IDLE_TIMEOUT_MS,
241            header_timeout_retries: 1,
242        }
243    }
244
245    pub fn new_with_client(
246        client: reqwest::Client,
247        auth_manager: CodexAuthManager<DefaultCodexAuthStore>,
248        base_url: String,
249    ) -> Self {
250        Self {
251            client,
252            auth_manager,
253            base_url,
254            header_timeout_ms: 60_000,
255            body_idle_timeout_ms: HTTP_RESPONSE_BODY_IDLE_TIMEOUT_MS,
256            header_timeout_retries: 1,
257        }
258    }
259
260    #[cfg(test)]
261    pub fn new_for_test(
262        client: reqwest::Client,
263        base_url: String,
264        header_timeout_ms: u64,
265        body_idle_timeout_ms: u64,
266        header_timeout_retries: u32,
267    ) -> Self {
268        Self {
269            client,
270            auth_manager: CodexAuthManager::new(file_store()),
271            base_url,
272            header_timeout_ms,
273            body_idle_timeout_ms,
274            header_timeout_retries,
275        }
276    }
277
278    pub fn auth_manager(&self) -> &CodexAuthManager<DefaultCodexAuthStore> {
279        &self.auth_manager
280    }
281
282    pub async fn post_codex(
283        &self,
284        body: &ResponsesRequest,
285        ctx: &RequestContext,
286        continuation: Option<&super::continuation::ContinuationCandidate>,
287    ) -> Result<CodexResponse, CodexError> {
288        self.post_codex_with_transport(body, ctx, continuation, crate::config::codex_transport())
289            .await
290    }
291
292    async fn post_codex_with_transport(
293        &self,
294        body: &ResponsesRequest,
295        ctx: &RequestContext,
296        continuation: Option<&super::continuation::ContinuationCandidate>,
297        transport: crate::config::CodexTransport,
298    ) -> Result<CodexResponse, CodexError> {
299        use super::continuation::clear_continuation;
300        use crate::config::CodexTransport;
301
302        let mut auth = self.auth_manager.get_auth().await.map_err(|e| CodexError {
303            status: 401,
304            message: "Auth error".to_string(),
305            detail: Some(e.to_string()),
306            retry_after: None,
307            origin: CodexErrorOrigin::Auth,
308        })?;
309
310        let initial_pool_key = websocket_pool_key(ctx, continuation);
311        if should_reset_websocket_pool(continuation)
312            && let Some(key) = initial_pool_key
313        {
314            super::websocket::invalidate_codex_websocket_pool_key(key);
315        }
316
317        let mut active_continuation = continuation;
318        let mut auth_refresh_attempted = false;
319        let mut transport_failures = 0u32;
320        loop {
321            let pool_key = websocket_pool_key(ctx, active_continuation);
322            let result = match transport {
323                CodexTransport::Http => {
324                    let body_json = serde_json::to_string(body).map_err(|e| CodexError {
325                        status: 500,
326                        message: "Failed to serialize request".to_string(),
327                        detail: Some(e.to_string()),
328                        retry_after: None,
329                        origin: CodexErrorOrigin::Http,
330                    })?;
331                    self.attempt_post_http(&auth, &body_json, ctx, body.client_metadata.is_some())
332                        .await
333                }
334                CodexTransport::WebSocket => {
335                    let ws_headers =
336                        build_codex_headers(&auth, ctx, body.client_metadata.is_some())?;
337                    let ws_headers = super::websocket::codex_websocket_headers(&ws_headers);
338                    let ws_body = build_websocket_request(body, active_continuation);
339
340                    super::websocket::codex_websocket_request(
341                        &self.base_url,
342                        &ws_headers,
343                        &ws_body,
344                        ctx,
345                        ctx.traffic.as_deref(),
346                        pool_key,
347                        super::websocket::WEBSOCKET_CONNECT_TIMEOUT_MS,
348                        super::websocket::WEBSOCKET_IDLE_TIMEOUT_MS,
349                        active_continuation,
350                    )
351                    .await
352                }
353                CodexTransport::Auto => {
354                    let ws_headers =
355                        build_codex_headers(&auth, ctx, body.client_metadata.is_some())?;
356                    let ws_headers = super::websocket::codex_websocket_headers(&ws_headers);
357                    let ws_body = build_websocket_request(body, active_continuation);
358
359                    // Try WebSocket first
360                    let ws_result = super::websocket::codex_websocket_request(
361                        &self.base_url,
362                        &ws_headers,
363                        &ws_body,
364                        ctx,
365                        ctx.traffic.as_deref(),
366                        pool_key,
367                        super::websocket::WEBSOCKET_CONNECT_TIMEOUT_MS,
368                        super::websocket::WEBSOCKET_IDLE_TIMEOUT_MS,
369                        active_continuation,
370                    )
371                    .await;
372
373                    match ws_result {
374                        Ok(response) => Ok(response),
375                        Err(err) if should_fallback_to_http(&err) => {
376                            // Fall back to HTTP only if WebSocket failed before sending
377                            let body_json =
378                                serde_json::to_string(body).map_err(|e| CodexError {
379                                    status: 500,
380                                    message: "Failed to serialize request".to_string(),
381                                    detail: Some(e.to_string()),
382                                    retry_after: None,
383                                    origin: CodexErrorOrigin::Http,
384                                })?;
385                            self.attempt_post_http(
386                                &auth,
387                                &body_json,
388                                ctx,
389                                body.client_metadata.is_some(),
390                            )
391                            .await
392                        }
393                        Err(err) => Err(err),
394                    }
395                }
396            };
397
398            if should_refresh_after_unauthorized(&result, auth_refresh_attempted, transport) {
399                auth_refresh_attempted = true;
400                match self.auth_manager.force_refresh(&auth.access).await {
401                    Ok(new_auth) => {
402                        auth = new_auth;
403                        if let Some(key) = pool_key {
404                            super::websocket::invalidate_codex_websocket_pool_key(key);
405                        }
406                        continue;
407                    }
408                    Err(e) => {
409                        return Err(CodexError {
410                            status: 401,
411                            message: "Unauthorized".to_string(),
412                            detail: Some(e.to_string()),
413                            retry_after: None,
414                            origin: CodexErrorOrigin::Http,
415                        });
416                    }
417                }
418            }
419
420            if let Ok(response) = &result
421                && (200..300).contains(&response.status)
422                && let Some(failure) = super::events::first_retryable_failure(&response.body)
423            {
424                if transport_failures < MAX_BUFFERED_TRANSPORT_RETRIES {
425                    let delay =
426                        compute_backoff_delay(transport_failures, failure.retry_after.as_deref());
427                    if delay.exceeds_budget {
428                        return Err(CodexError {
429                            status: failure.status,
430                            message: failure.message.clone(),
431                            detail: Some(failure.message),
432                            retry_after: failure.retry_after,
433                            origin: buffered_origin(transport),
434                        });
435                    }
436                    log_buffered_retry(
437                        ctx,
438                        transport,
439                        transport_failures + 1,
440                        delay.wait_ms,
441                        failure.status,
442                        "upstream_event",
443                        &failure.message,
444                    );
445                    transport_failures += 1;
446                    sleep(delay.wait_ms).await;
447                    continue;
448                }
449
450                log_buffered_retry_exhausted(
451                    ctx,
452                    transport,
453                    failure.status,
454                    "upstream_event",
455                    &failure.message,
456                );
457                return Err(CodexError {
458                    status: failure.status,
459                    message: failure.message.clone(),
460                    detail: Some(failure.message),
461                    retry_after: failure.retry_after,
462                    origin: CodexErrorOrigin::Http,
463                });
464            }
465
466            match result {
467                Ok(response) if response.status == 401 => {
468                    let detail = String::from_utf8_lossy(&response.body).to_string();
469                    return Err(CodexError {
470                        status: 401,
471                        message: "Unauthorized".to_string(),
472                        detail: Some(detail),
473                        retry_after: None,
474                        origin: CodexErrorOrigin::Http,
475                    });
476                }
477                Ok(response) if response.status == 403 => {
478                    let detail = String::from_utf8_lossy(&response.body).to_string();
479                    return Err(CodexError {
480                        status: 403,
481                        message: "Forbidden".to_string(),
482                        detail: Some(detail),
483                        retry_after: None,
484                        origin: CodexErrorOrigin::Http,
485                    });
486                }
487                Ok(response) if response.status == 429 => {
488                    let retry_after = response
489                        .headers
490                        .iter()
491                        .find(|(k, _)| k.to_lowercase() == "retry-after")
492                        .map(|(_, v)| v.clone());
493                    if transport_failures < MAX_BUFFERED_TRANSPORT_RETRIES {
494                        let delay =
495                            compute_backoff_delay(transport_failures, retry_after.as_deref());
496                        if delay.exceeds_budget {
497                            let detail = String::from_utf8_lossy(&response.body).to_string();
498                            return Err(CodexError {
499                                status: 429,
500                                message: "Rate limited".to_string(),
501                                detail: Some(detail),
502                                retry_after,
503                                origin: CodexErrorOrigin::Http,
504                            });
505                        }
506                        log_buffered_retry(
507                            ctx,
508                            transport,
509                            transport_failures + 1,
510                            delay.wait_ms,
511                            response.status,
512                            "upstream",
513                            "rate limited",
514                        );
515                        transport_failures += 1;
516                        sleep(delay.wait_ms).await;
517                        continue;
518                    }
519                    let detail = String::from_utf8_lossy(&response.body).to_string();
520                    log_buffered_retry_exhausted(
521                        ctx,
522                        transport,
523                        response.status,
524                        "upstream",
525                        "rate limited",
526                    );
527                    return Err(CodexError {
528                        status: 429,
529                        message: "Rate limited".to_string(),
530                        detail: Some(detail),
531                        retry_after,
532                        origin: CodexErrorOrigin::Http,
533                    });
534                }
535                Ok(response) if should_retry_codex_status(response.status) => {
536                    if transport_failures < MAX_BUFFERED_TRANSPORT_RETRIES {
537                        let retry_after = response
538                            .headers
539                            .iter()
540                            .find(|(key, _)| key.eq_ignore_ascii_case("retry-after"))
541                            .map(|(_, value)| value.as_str());
542                        let delay = compute_backoff_delay(transport_failures, retry_after);
543                        if delay.exceeds_budget {
544                            return Err(codex_status_error(response, transport));
545                        }
546                        log_buffered_retry(
547                            ctx,
548                            transport,
549                            transport_failures + 1,
550                            delay.wait_ms,
551                            response.status,
552                            "upstream",
553                            "retryable upstream status",
554                        );
555                        transport_failures += 1;
556                        sleep(delay.wait_ms).await;
557                        continue;
558                    }
559                    log_buffered_retry_exhausted(
560                        ctx,
561                        transport,
562                        response.status,
563                        "upstream",
564                        "retryable upstream status",
565                    );
566                    return Err(codex_status_error(response, transport));
567                }
568                Ok(response) if !(200..300).contains(&response.status) => {
569                    return Err(codex_status_error(response, transport));
570                }
571                Ok(response) => return Ok(response),
572                Err(err) if should_retry_without_continuation(&err, active_continuation) => {
573                    clear_continuation(ctx.session_id.as_deref());
574                    if let Some(key) = pool_key {
575                        super::websocket::invalidate_codex_websocket_pool_key(key);
576                    }
577                    active_continuation = None;
578                    continue;
579                }
580                Err(err) => {
581                    // Determine if retryable
582                    let retryable = is_retryable_transport_error(&err);
583                    if retryable && transport_failures < MAX_BUFFERED_TRANSPORT_RETRIES {
584                        let delay =
585                            compute_backoff_delay(transport_failures, err.retry_after.as_deref());
586                        if delay.exceeds_budget {
587                            return Err(err);
588                        }
589                        log_buffered_retry(
590                            ctx,
591                            transport,
592                            transport_failures + 1,
593                            delay.wait_ms,
594                            err.status,
595                            codex_error_origin_name(err.origin),
596                            &err.message,
597                        );
598                        transport_failures += 1;
599                        sleep(delay.wait_ms).await;
600                        continue;
601                    }
602                    if retryable {
603                        log_buffered_retry_exhausted(
604                            ctx,
605                            transport,
606                            err.status,
607                            codex_error_origin_name(err.origin),
608                            &err.message,
609                        );
610                    }
611                    return Err(err);
612                }
613            }
614        }
615    }
616
617    pub async fn stream_codex_websocket_events(
618        self: &Arc<Self>,
619        body: &ResponsesRequest,
620        ctx: &RequestContext,
621        continuation: Option<&super::continuation::ContinuationCandidate>,
622    ) -> Result<super::websocket::CodexWebSocketEventReceiver, CodexError> {
623        let auth = self.auth_manager.get_auth().await.map_err(|e| CodexError {
624            status: 401,
625            message: "Auth error".to_string(),
626            detail: Some(e.to_string()),
627            retry_after: None,
628            origin: CodexErrorOrigin::Auth,
629        })?;
630
631        let pool_key = websocket_pool_key(ctx, continuation).map(str::to_string);
632        if should_reset_websocket_pool(continuation)
633            && let Some(key) = pool_key.as_deref()
634        {
635            super::websocket::invalidate_codex_websocket_pool_key(key);
636        }
637
638        let client = self.clone();
639        let body = body.clone();
640        let ctx = ctx.clone();
641        let continuation = continuation.cloned();
642        let (tx, rx) = tokio::sync::mpsc::channel(64);
643        tokio::spawn(async move {
644            client
645                .coordinate_live_websocket_events(body, ctx, continuation, auth, pool_key, tx)
646                .await;
647        });
648
649        Ok(rx)
650    }
651
652    async fn coordinate_live_websocket_events(
653        &self,
654        body: ResponsesRequest,
655        ctx: RequestContext,
656        mut continuation: Option<super::continuation::ContinuationCandidate>,
657        mut auth: StoredAuth,
658        pool_key: Option<String>,
659        tx: tokio::sync::mpsc::Sender<Result<serde_json::Value, CodexError>>,
660    ) {
661        let mut auth_refresh_attempted = false;
662        let mut continuation_retry_available = continuation
663            .as_ref()
664            .and_then(|candidate| candidate.previous_response_id.as_deref())
665            .is_some();
666        let mut forwarded_any = false;
667
668        'attempt: loop {
669            let ws_headers = match build_codex_headers(&auth, &ctx, body.client_metadata.is_some())
670            {
671                Ok(headers) => super::websocket::codex_websocket_headers(&headers),
672                Err(err) => {
673                    let _ = tx.send(Err(err)).await;
674                    return;
675                }
676            };
677            let ws_body = build_websocket_request(&body, continuation.as_ref());
678            let start = super::websocket::codex_websocket_event_stream(
679                &self.base_url,
680                &ws_headers,
681                &ws_body,
682                &ctx,
683                ctx.traffic.clone(),
684                pool_key.as_deref(),
685                super::websocket::WEBSOCKET_CONNECT_TIMEOUT_MS,
686                super::websocket::WEBSOCKET_IDLE_TIMEOUT_MS,
687                continuation.as_ref(),
688            );
689            let mut stream = tokio::select! {
690                _ = tx.closed() => {
691                    if let Some(key) = pool_key.as_deref() {
692                        super::websocket::invalidate_codex_websocket_pool_key(key);
693                    }
694                    return;
695                }
696                result = start => match result {
697                    Ok(stream) => stream,
698                    Err(err) if err.status == 401 && !auth_refresh_attempted && !forwarded_any => {
699                        auth_refresh_attempted = true;
700                        if let Some(key) = pool_key.as_deref() {
701                            super::websocket::invalidate_codex_websocket_pool_key(key);
702                        }
703                        let refresh = self.auth_manager.force_refresh(&auth.access);
704                        auth = match refresh.await {
705                            Ok(auth) => {
706                                if tx.is_closed() {
707                                    return;
708                                }
709                                auth
710                            },
711                            Err(refresh_err) => {
712                                let _ = tx.send(Err(auth_refresh_error(refresh_err))).await;
713                                return;
714                            }
715                        };
716                        continue 'attempt;
717                    }
718                    Err(err) if continuation_retry_available && is_continuation_retry_error(&err) => {
719                        continuation_retry_available = false;
720                        super::continuation::clear_continuation(ctx.session_id.as_deref());
721                        if let Some(key) = pool_key.as_deref() {
722                            super::websocket::invalidate_codex_websocket_pool_key(key);
723                        }
724                        continuation = None;
725                        continue 'attempt;
726                    }
727                    Err(err) => {
728                        let _ = tx.send(Err(err)).await;
729                        return;
730                    }
731                }
732            };
733
734            loop {
735                let item = tokio::select! {
736                    _ = tx.closed() => {
737                        if let Some(key) = pool_key.as_deref() {
738                            super::websocket::invalidate_codex_websocket_pool_key(key);
739                        }
740                        return;
741                    }
742                    item = stream.recv() => item,
743                };
744                let Some(item) = item else {
745                    return;
746                };
747
748                let unauthorized = match &item {
749                    Err(err) => err.status == 401,
750                    Ok(payload) => super::websocket::event_error_status(payload) == Some(401),
751                };
752                if unauthorized && !auth_refresh_attempted && !forwarded_any {
753                    auth_refresh_attempted = true;
754                    if let Some(key) = pool_key.as_deref() {
755                        super::websocket::invalidate_codex_websocket_pool_key(key);
756                    }
757                    let refresh = self.auth_manager.force_refresh(&auth.access);
758                    auth = match refresh.await {
759                        Ok(auth) => {
760                            if tx.is_closed() {
761                                return;
762                            }
763                            auth
764                        }
765                        Err(refresh_err) => {
766                            let _ = tx.send(Err(auth_refresh_error(refresh_err))).await;
767                            return;
768                        }
769                    };
770                    continue 'attempt;
771                }
772
773                if let Err(err) = &item
774                    && continuation_retry_available
775                    && is_continuation_retry_error(err)
776                    && !forwarded_any
777                {
778                    continuation_retry_available = false;
779                    super::continuation::clear_continuation(ctx.session_id.as_deref());
780                    if let Some(key) = pool_key.as_deref() {
781                        super::websocket::invalidate_codex_websocket_pool_key(key);
782                    }
783                    continuation = None;
784                    continue 'attempt;
785                }
786
787                forwarded_any = true;
788                if tx.send(item).await.is_err() {
789                    if let Some(key) = pool_key.as_deref() {
790                        super::websocket::invalidate_codex_websocket_pool_key(key);
791                    }
792                    return;
793                }
794            }
795        }
796    }
797
798    async fn attempt_post_http(
799        &self,
800        auth: &StoredAuth,
801        body_json: &str,
802        ctx: &RequestContext,
803        use_responses_lite: bool,
804    ) -> Result<CodexResponse, CodexError> {
805        let url = &self.base_url;
806        let headers = build_codex_headers(auth, ctx, use_responses_lite)?;
807
808        if let Some(traffic) = ctx.traffic.as_deref() {
809            write_codex_http_request_capture(traffic, url, &headers, body_json);
810        }
811
812        // Build headers
813        let mut req_builder = self.client.post(url);
814        for (key, value) in headers.iter() {
815            req_builder = req_builder.header(key.as_str(), value.as_bytes());
816        }
817
818        // Apply header timeout
819        let started_at = Instant::now();
820        let send_fut = req_builder.body(body_json.to_string()).send();
821        let header_timeout_dur = Duration::from_millis(self.header_timeout_ms);
822
823        let mut resp = tokio::time::timeout(header_timeout_dur, send_fut)
824            .await
825            .map_err(|_| CodexError {
826                status: 0,
827                message: format!(
828                    "Timed out waiting {}ms for Codex response headers",
829                    self.header_timeout_ms
830                ),
831                detail: None,
832                retry_after: None,
833                origin: CodexErrorOrigin::Http,
834            })?
835            .map_err(|e| {
836                if is_retryable_reqwest_error(&e) {
837                    CodexError {
838                        status: 0,
839                        message: format!("Transport error: {e}"),
840                        detail: None,
841                        retry_after: None,
842                        origin: CodexErrorOrigin::Http,
843                    }
844                } else {
845                    CodexError {
846                        status: 0,
847                        message: format!("Network error: {e}"),
848                        detail: None,
849                        retry_after: None,
850                        origin: CodexErrorOrigin::Http,
851                    }
852                }
853            })?;
854
855        let status = resp.status().as_u16();
856        let headers: Vec<(String, String)> = resp
857            .headers()
858            .iter()
859            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
860            .collect();
861
862        let mut body_bytes = Vec::new();
863        loop {
864            let chunk = tokio::time::timeout(
865                Duration::from_millis(self.body_idle_timeout_ms),
866                resp.chunk(),
867            )
868            .await
869            .map_err(|_| CodexError {
870                status: 0,
871                message: format!(
872                    "Timed out waiting {}ms for the next Codex response body chunk",
873                    self.body_idle_timeout_ms
874                ),
875                detail: Some("http_response_body".to_string()),
876                retry_after: None,
877                origin: CodexErrorOrigin::Http,
878            })?
879            .map_err(|e| CodexError {
880                status: 0,
881                message: format!("Transport error reading Codex response body: {e}"),
882                detail: Some("http_response_body".to_string()),
883                retry_after: None,
884                origin: CodexErrorOrigin::Http,
885            })?;
886
887            let Some(chunk) = chunk else {
888                break;
889            };
890            body_bytes.extend_from_slice(&chunk);
891        }
892
893        if let Some(traffic) = ctx.traffic.as_deref() {
894            write_upstream_response_capture(
895                traffic,
896                status,
897                started_at.elapsed(),
898                &headers,
899                &body_bytes,
900            );
901        }
902
903        Ok(CodexResponse {
904            body: body_bytes,
905            status,
906            headers,
907        })
908    }
909}
910
911fn write_codex_http_request_capture(
912    traffic: &TrafficCapture,
913    url: &str,
914    headers: &http::HeaderMap,
915    body_json: &str,
916) {
917    let body = serde_json::from_str(body_json).unwrap_or_else(|_| {
918        serde_json::json!({
919            "unparseable": true,
920            "bytes": body_json.len(),
921        })
922    });
923    traffic.write_json("020-upstream-request", &body);
924    traffic.write_json(
925        "021-upstream-request-metadata",
926        &serde_json::json!({
927            "provider": "codex",
928            "transport": "http",
929            "url": url,
930            "method": "POST",
931            "headers": headers_to_json(headers),
932            "size": summarize_json_request_size(&body, body_json),
933        }),
934    );
935}
936
937fn write_upstream_response_capture(
938    traffic: &TrafficCapture,
939    status: u16,
940    elapsed: Duration,
941    headers: &[(String, String)],
942    body: &[u8],
943) {
944    traffic.write_json(
945        "030-upstream-response-headers",
946        &serde_json::json!({
947            "status": status,
948            "elapsedMs": elapsed.as_millis(),
949            "headers": headers_to_json_from_pairs(headers),
950        }),
951    );
952    if status >= 400 {
953        traffic.write_text("031-upstream-error-body", &String::from_utf8_lossy(body));
954    } else {
955        traffic.write_bytes("032-upstream-response-body.sse", body);
956        write_codex_sse_event_capture(traffic, body);
957    }
958}
959
960fn write_codex_sse_event_capture(traffic: &TrafficCapture, body: &[u8]) {
961    for event in parse_sse_events(body) {
962        if event.data == "[DONE]" {
963            traffic.write_json_event(
964                "040-upstream-event",
965                &serde_json::json!({
966                    "event": event.event,
967                    "data": "[DONE]",
968                }),
969            );
970            continue;
971        }
972
973        match serde_json::from_str::<serde_json::Value>(&event.data) {
974            Ok(mut value) => {
975                if let Some(name) = event.event
976                    && let Some(obj) = value.as_object_mut()
977                {
978                    obj.entry("_sse_event").or_insert(serde_json::json!(name));
979                }
980                traffic.write_json_event("040-upstream-event", &value);
981            }
982            Err(_) => {
983                traffic.write_json_event(
984                    "040-upstream-event",
985                    &serde_json::json!({
986                        "event": event.event,
987                        "unparseable": true,
988                        "data": event.data,
989                    }),
990                );
991            }
992        }
993    }
994}
995
996fn headers_to_json(headers: &http::HeaderMap) -> serde_json::Value {
997    let mut out = serde_json::Map::new();
998    for (key, value) in headers.iter() {
999        out.insert(
1000            key.to_string(),
1001            serde_json::Value::String(value.to_str().unwrap_or("").to_string()),
1002        );
1003    }
1004    serde_json::Value::Object(out)
1005}
1006
1007fn headers_to_json_from_pairs(headers: &[(String, String)]) -> serde_json::Value {
1008    let mut out = serde_json::Map::new();
1009    for (key, value) in headers {
1010        out.insert(key.clone(), serde_json::Value::String(value.clone()));
1011    }
1012    serde_json::Value::Object(out)
1013}
1014
1015fn summarize_json_request_size(body: &serde_json::Value, body_json: &str) -> serde_json::Value {
1016    serde_json::json!({
1017        "bytes": body_json.len(),
1018        "inputCount": body
1019            .get("input")
1020            .and_then(|v| v.as_array())
1021            .map(|items| items.len()),
1022        "toolCount": body
1023            .get("tools")
1024            .and_then(|v| v.as_array())
1025            .map(|items| items.len()),
1026    })
1027}
1028
1029fn auth_refresh_error(err: anyhow::Error) -> CodexError {
1030    CodexError {
1031        status: 401,
1032        message: "Unauthorized".to_string(),
1033        detail: Some(err.to_string()),
1034        retry_after: None,
1035        origin: CodexErrorOrigin::Auth,
1036    }
1037}
1038
1039fn codex_status_error(
1040    response: CodexResponse,
1041    transport: crate::config::CodexTransport,
1042) -> CodexError {
1043    let retry_after = response
1044        .headers
1045        .iter()
1046        .find(|(key, _)| key.eq_ignore_ascii_case("retry-after"))
1047        .map(|(_, value)| value.clone());
1048    let message = codex_status_error_message(&response.body).unwrap_or_else(|| {
1049        format!(
1050            "Upstream Codex request failed with status {}",
1051            response.status
1052        )
1053    });
1054    CodexError {
1055        status: response.status,
1056        message: message.clone(),
1057        detail: Some(message),
1058        retry_after,
1059        origin: buffered_origin(transport),
1060    }
1061}
1062
1063fn codex_status_error_message(body: &[u8]) -> Option<String> {
1064    serde_json::from_slice::<serde_json::Value>(body)
1065        .ok()
1066        .and_then(|value| {
1067            value
1068                .pointer("/error/message")
1069                .or_else(|| value.get("message"))
1070                .or_else(|| value.get("detail"))
1071                .and_then(|value| value.as_str())
1072                .map(str::to_string)
1073        })
1074        .or_else(|| {
1075            parse_sse_events(body).into_iter().find_map(|event| {
1076                let payload = serde_json::from_str::<serde_json::Value>(&event.data).ok()?;
1077                super::events::classify_event_failure(&payload).map(|failure| failure.message)
1078            })
1079        })
1080}
1081
1082fn buffered_origin(transport: crate::config::CodexTransport) -> CodexErrorOrigin {
1083    match transport {
1084        crate::config::CodexTransport::Http => CodexErrorOrigin::BufferedHttp,
1085        crate::config::CodexTransport::WebSocket | crate::config::CodexTransport::Auto => {
1086            CodexErrorOrigin::BufferedWebSocket
1087        }
1088    }
1089}
1090
1091fn should_retry_codex_status(status: u16) -> bool {
1092    should_retry_status(status) || status == 529
1093}
1094
1095fn codex_error_origin_name(origin: CodexErrorOrigin) -> &'static str {
1096    match origin {
1097        CodexErrorOrigin::Http => "http",
1098        CodexErrorOrigin::WebSocket => "websocket",
1099        CodexErrorOrigin::WebSocketHandshake => "websocket_handshake",
1100        CodexErrorOrigin::Auth => "auth",
1101        CodexErrorOrigin::BufferedHttp => "buffered_http",
1102        CodexErrorOrigin::BufferedWebSocket => "buffered_websocket",
1103    }
1104}
1105
1106fn log_buffered_retry(
1107    ctx: &RequestContext,
1108    transport: crate::config::CodexTransport,
1109    failed_attempt: u32,
1110    delay_ms: u64,
1111    status: u16,
1112    origin: &str,
1113    reason: &str,
1114) {
1115    let mut fields = serde_json::Map::new();
1116    fields.insert("reqId".into(), serde_json::json!(ctx.req_id));
1117    fields.insert("transport".into(), serde_json::json!(transport.as_str()));
1118    fields.insert("failedAttempt".into(), serde_json::json!(failed_attempt));
1119    fields.insert("nextAttempt".into(), serde_json::json!(failed_attempt + 1));
1120    fields.insert(
1121        "maxAttempts".into(),
1122        serde_json::json!(MAX_BUFFERED_TRANSPORT_ATTEMPTS),
1123    );
1124    fields.insert("delayMs".into(), serde_json::json!(delay_ms));
1125    fields.insert("status".into(), serde_json::json!(status));
1126    fields.insert("origin".into(), serde_json::json!(origin));
1127    fields.insert("reason".into(), serde_json::json!(reason));
1128    create_logger("codex").warn("buffered_transport_retry", Some(fields));
1129}
1130
1131fn log_buffered_retry_exhausted(
1132    ctx: &RequestContext,
1133    transport: crate::config::CodexTransport,
1134    status: u16,
1135    origin: &str,
1136    reason: &str,
1137) {
1138    let mut fields = serde_json::Map::new();
1139    fields.insert("reqId".into(), serde_json::json!(ctx.req_id));
1140    fields.insert("transport".into(), serde_json::json!(transport.as_str()));
1141    fields.insert(
1142        "attempts".into(),
1143        serde_json::json!(MAX_BUFFERED_TRANSPORT_ATTEMPTS),
1144    );
1145    fields.insert("status".into(), serde_json::json!(status));
1146    fields.insert("origin".into(), serde_json::json!(origin));
1147    fields.insert("reason".into(), serde_json::json!(reason));
1148    create_logger("codex").warn("buffered_transport_retry_exhausted", Some(fields));
1149}
1150
1151fn is_retryable_transport_error(err: &CodexError) -> bool {
1152    if err.origin == CodexErrorOrigin::WebSocketHandshake {
1153        return err.status == 0 || should_retry_codex_status(err.status);
1154    }
1155    if err.detail.as_deref() == Some("websocket_pre_request") {
1156        return err.status == 0 || should_retry_codex_status(err.status);
1157    }
1158    if err.status != 0 {
1159        return false;
1160    }
1161
1162    let message = err.message.to_ascii_lowercase();
1163    message.contains("timed out waiting")
1164        || message.contains("transport error")
1165        || message.contains("connection reset")
1166        || message.contains("connection closed")
1167        || message.contains("timed out")
1168        || message.contains("econnreset")
1169        || message.contains("etimedout")
1170}
1171
1172fn is_retryable_reqwest_error(err: &reqwest::Error) -> bool {
1173    if err.is_timeout() || err.is_connect() {
1174        return true;
1175    }
1176    let msg = err.to_string().to_lowercase();
1177    msg.contains("connection reset")
1178        || msg.contains("connection closed")
1179        || msg.contains("econnreset")
1180        || msg.contains("etimedout")
1181        || msg.contains("epipe")
1182}
1183
1184fn should_refresh_after_unauthorized(
1185    result: &Result<CodexResponse, CodexError>,
1186    auth_refresh_attempted: bool,
1187    transport: crate::config::CodexTransport,
1188) -> bool {
1189    if auth_refresh_attempted {
1190        return false;
1191    }
1192    match result {
1193        Ok(response) => response.status == 401,
1194        Err(err) => {
1195            err.status == 401
1196                && (err.origin != CodexErrorOrigin::WebSocketHandshake
1197                    || transport == crate::config::CodexTransport::WebSocket)
1198        }
1199    }
1200}
1201
1202fn should_fallback_to_http(err: &CodexError) -> bool {
1203    err.origin == CodexErrorOrigin::WebSocketHandshake
1204}
1205
1206fn should_retry_without_continuation(
1207    err: &CodexError,
1208    continuation: Option<&super::continuation::ContinuationCandidate>,
1209) -> bool {
1210    if continuation
1211        .and_then(|c| c.previous_response_id.as_deref())
1212        .is_none()
1213    {
1214        return false;
1215    }
1216
1217    is_continuation_retry_error(err)
1218}
1219
1220fn is_continuation_retry_error(err: &CodexError) -> bool {
1221    matches!(
1222        err.detail.as_deref(),
1223        Some("previous_response_not_found")
1224            | Some(super::websocket::WEBSOCKET_RESPONSE_START_TIMEOUT_DETAIL)
1225            | Some(super::websocket::WEBSOCKET_MISSING_TERMINAL_DETAIL)
1226    )
1227}
1228
1229fn websocket_pool_key<'a>(
1230    ctx: &'a RequestContext,
1231    continuation: Option<&super::continuation::ContinuationCandidate>,
1232) -> Option<&'a str> {
1233    let session_id = ctx.session_id.as_deref()?;
1234    let continuation = continuation?;
1235    if continuation.disabled_reason.as_deref() == Some("disabled") {
1236        return None;
1237    }
1238    Some(session_id)
1239}
1240
1241fn should_reset_websocket_pool(
1242    continuation: Option<&super::continuation::ContinuationCandidate>,
1243) -> bool {
1244    let Some(reason) = continuation.and_then(|c| c.disabled_reason.as_deref()) else {
1245        return false;
1246    };
1247    !matches!(reason, "missing_state" | "disabled")
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::*;
1253    use tokio::io::{AsyncReadExt, AsyncWriteExt};
1254    use tokio::net::TcpListener;
1255
1256    fn http_test_auth() -> StoredAuth {
1257        StoredAuth {
1258            access: "test".into(),
1259            refresh: String::new(),
1260            account_id: Some("acct".into()),
1261            expires: u64::MAX,
1262        }
1263    }
1264
1265    fn http_test_context() -> RequestContext {
1266        RequestContext {
1267            req_id: "http-body-test".into(),
1268            session_id: None,
1269            session_seq: None,
1270            provider: "codex".into(),
1271            traffic: None,
1272            monitor: None,
1273            passthrough: None,
1274        }
1275    }
1276
1277    fn http_test_client(base_url: String, body_idle_timeout_ms: u64) -> CodexHttpClient {
1278        CodexHttpClient::new_for_test(
1279            reqwest::Client::new(),
1280            base_url,
1281            100,
1282            body_idle_timeout_ms,
1283            0,
1284        )
1285    }
1286
1287    fn buffered_test_request() -> ResponsesRequest {
1288        ResponsesRequest {
1289            model: "gpt-5.6-sol".into(),
1290            instructions: None,
1291            input: vec![],
1292            tools: None,
1293            tool_choice: None,
1294            store: false,
1295            stream: true,
1296            parallel_tool_calls: true,
1297            include: None,
1298            client_metadata: None,
1299            service_tier: None,
1300            prompt_cache_key: None,
1301            text: super::super::translate::request::ResponsesText {
1302                verbosity: None,
1303                format: None,
1304            },
1305            reasoning: None,
1306        }
1307    }
1308
1309    fn authenticated_http_test_client(base_url: String) -> CodexHttpClient {
1310        let client = http_test_client(base_url, 100);
1311        client.auth_manager().set_test_auth(http_test_auth());
1312        client
1313    }
1314
1315    #[tokio::test]
1316    async fn buffered_http_retries_retryable_status() {
1317        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1318        let addr = listener.local_addr().unwrap();
1319        let server = tokio::spawn(async move {
1320            for attempt in 0..2 {
1321                let (mut stream, _) = listener.accept().await.unwrap();
1322                let mut request = [0_u8; 16 * 1024];
1323                assert!(stream.read(&mut request).await.unwrap() > 0);
1324                let (status, body): (&str, &[u8]) = if attempt == 0 {
1325                    ("503 Service Unavailable", b"retry")
1326                } else {
1327                    ("200 OK", b"data: keep\n\n")
1328                };
1329                let response = format!(
1330                    "HTTP/1.1 {status}\r\ncontent-length: {}\r\nretry-after: 0\r\nconnection: close\r\n\r\n",
1331                    body.len()
1332                );
1333                stream.write_all(response.as_bytes()).await.unwrap();
1334                stream.write_all(body).await.unwrap();
1335            }
1336        });
1337
1338        let response = authenticated_http_test_client(format!("http://{addr}/responses"))
1339            .post_codex_with_transport(
1340                &buffered_test_request(),
1341                &http_test_context(),
1342                None,
1343                crate::config::CodexTransport::Http,
1344            )
1345            .await
1346            .unwrap();
1347        server.await.unwrap();
1348        assert_eq!(response.status, 200);
1349        assert_eq!(response.body, b"data: keep\n\n");
1350    }
1351
1352    #[tokio::test]
1353    async fn auto_falls_back_to_http_after_statusful_websocket_handshake_failure() {
1354        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1355        let addr = listener.local_addr().unwrap();
1356        let server = tokio::spawn(async move {
1357            let (mut websocket, _) = listener.accept().await.unwrap();
1358            let mut request = [0_u8; 16 * 1024];
1359            let read = websocket.read(&mut request).await.unwrap();
1360            assert!(read > 0);
1361            assert!(String::from_utf8_lossy(&request[..read]).contains("Upgrade: websocket"));
1362            websocket
1363                .write_all(
1364                    b"HTTP/1.1 401 Unauthorized\r\ncontent-length: 13\r\nconnection: close\r\n\r\npolicy denied",
1365                )
1366                .await
1367                .unwrap();
1368            drop(websocket);
1369
1370            let (mut http, _) = listener.accept().await.unwrap();
1371            let read = http.read(&mut request).await.unwrap();
1372            assert!(read > 0);
1373            assert!(String::from_utf8_lossy(&request[..read]).starts_with("POST "));
1374            let body = b"data: keep\n\n";
1375            let response = format!(
1376                "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
1377                body.len()
1378            );
1379            http.write_all(response.as_bytes()).await.unwrap();
1380            http.write_all(body).await.unwrap();
1381        });
1382
1383        let response = authenticated_http_test_client(format!("http://{addr}/responses"))
1384            .post_codex_with_transport(
1385                &buffered_test_request(),
1386                &http_test_context(),
1387                None,
1388                crate::config::CodexTransport::Auto,
1389            )
1390            .await
1391            .unwrap();
1392        server.await.unwrap();
1393
1394        assert_eq!(response.status, 200);
1395        assert_eq!(response.body, b"data: keep\n\n");
1396    }
1397
1398    #[tokio::test]
1399    async fn over_budget_retry_after_stops_without_replay() {
1400        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1401        let addr = listener.local_addr().unwrap();
1402        let server = tokio::spawn(async move {
1403            let (mut stream, _) = listener.accept().await.unwrap();
1404            let mut request = [0_u8; 16 * 1024];
1405            assert!(stream.read(&mut request).await.unwrap() > 0);
1406            stream
1407                .write_all(
1408                    b"HTTP/1.1 503 Service Unavailable\r\ncontent-length: 4\r\nretry-after: 120\r\nconnection: close\r\n\r\nstop",
1409                )
1410                .await
1411                .unwrap();
1412        });
1413
1414        let error = match authenticated_http_test_client(format!("http://{addr}/responses"))
1415            .post_codex_with_transport(
1416                &buffered_test_request(),
1417                &http_test_context(),
1418                None,
1419                crate::config::CodexTransport::Http,
1420            )
1421            .await
1422        {
1423            Ok(_) => panic!("over-budget Retry-After should propagate"),
1424            Err(error) => error,
1425        };
1426        server.await.unwrap();
1427        assert_eq!(error.status, 503);
1428        assert_eq!(error.retry_after.as_deref(), Some("120"));
1429    }
1430
1431    #[tokio::test]
1432    async fn buffered_http_rejects_non_retryable_error_status_before_sse_parsing() {
1433        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1434        let addr = listener.local_addr().unwrap();
1435        let server = tokio::spawn(async move {
1436            let (mut stream, _) = listener.accept().await.unwrap();
1437            let mut request = [0_u8; 16 * 1024];
1438            assert!(stream.read(&mut request).await.unwrap() > 0);
1439            let body = br#"{"error":{"message":"Model not found gpt-test"}}"#;
1440            let response = format!(
1441                "HTTP/1.1 404 Not Found\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
1442                body.len()
1443            );
1444            stream.write_all(response.as_bytes()).await.unwrap();
1445            stream.write_all(body).await.unwrap();
1446        });
1447
1448        let result = authenticated_http_test_client(format!("http://{addr}/responses"))
1449            .post_codex_with_transport(
1450                &buffered_test_request(),
1451                &http_test_context(),
1452                None,
1453                crate::config::CodexTransport::Http,
1454            )
1455            .await;
1456        server.await.unwrap();
1457        let error = match result {
1458            Ok(_) => panic!("non-success HTTP status must not reach the SSE reducer"),
1459            Err(error) => error,
1460        };
1461
1462        assert_eq!(error.status, 404);
1463        assert_eq!(error.detail.as_deref(), Some("Model not found gpt-test"));
1464        assert_eq!(error.origin, CodexErrorOrigin::BufferedHttp);
1465    }
1466
1467    #[test]
1468    fn status_error_preserves_buffered_websocket_event_message() {
1469        let error = codex_status_error(
1470            CodexResponse {
1471                body: b"data: {\"type\":\"error\",\"error\":{\"status\":400,\"message\":\"bad request\"}}\n\n"
1472                    .to_vec(),
1473                status: 400,
1474                headers: Vec::new(),
1475            },
1476            crate::config::CodexTransport::WebSocket,
1477        );
1478
1479        assert_eq!(error.status, 400);
1480        assert_eq!(error.detail.as_deref(), Some("bad request"));
1481        assert_eq!(error.origin, CodexErrorOrigin::BufferedWebSocket);
1482    }
1483
1484    #[tokio::test]
1485    async fn active_http_body_can_exceed_header_timeout() {
1486        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1487        let addr = listener.local_addr().unwrap();
1488        let server = tokio::spawn(async move {
1489            let (mut stream, _) = listener.accept().await.unwrap();
1490            let mut request = [0_u8; 4096];
1491            assert!(stream.read(&mut request).await.unwrap() > 0);
1492            stream
1493                .write_all(
1494                    b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\nconnection: close\r\n\r\n",
1495                )
1496                .await
1497                .unwrap();
1498            for chunk in [b"a".as_slice(), b"b", b"c"] {
1499                stream.write_all(b"1\r\n").await.unwrap();
1500                stream.write_all(chunk).await.unwrap();
1501                stream.write_all(b"\r\n").await.unwrap();
1502                tokio::time::sleep(Duration::from_millis(45)).await;
1503            }
1504            stream.write_all(b"0\r\n\r\n").await.unwrap();
1505        });
1506
1507        let response = http_test_client(format!("http://{addr}/responses"), 80)
1508            .attempt_post_http(&http_test_auth(), "{}", &http_test_context(), false)
1509            .await
1510            .expect("active body should not hit a whole-request timeout");
1511        server.await.unwrap();
1512
1513        assert_eq!(response.body, b"abc");
1514    }
1515
1516    #[tokio::test]
1517    async fn stalled_http_body_hits_idle_timeout() {
1518        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1519        let addr = listener.local_addr().unwrap();
1520        let server = tokio::spawn(async move {
1521            let (mut stream, _) = listener.accept().await.unwrap();
1522            let mut request = [0_u8; 4096];
1523            assert!(stream.read(&mut request).await.unwrap() > 0);
1524            stream
1525                .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\n")
1526                .await
1527                .unwrap();
1528            tokio::time::sleep(Duration::from_millis(100)).await;
1529        });
1530
1531        let result = http_test_client(format!("http://{addr}/responses"), 30)
1532            .attempt_post_http(&http_test_auth(), "{}", &http_test_context(), false)
1533            .await;
1534        server.await.unwrap();
1535        let error = result.err().expect("stalled body should time out");
1536
1537        assert!(error.message.contains("next Codex response body chunk"));
1538        assert_eq!(error.detail.as_deref(), Some("http_response_body"));
1539    }
1540
1541    #[tokio::test]
1542    async fn reset_http_body_returns_transport_error() {
1543        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1544        let addr = listener.local_addr().unwrap();
1545        let server = tokio::spawn(async move {
1546            let (mut stream, _) = listener.accept().await.unwrap();
1547            let mut request = [0_u8; 4096];
1548            assert!(stream.read(&mut request).await.unwrap() > 0);
1549            stream
1550                .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 10\r\n\r\npartial")
1551                .await
1552                .unwrap();
1553        });
1554
1555        let result = http_test_client(format!("http://{addr}/responses"), 100)
1556            .attempt_post_http(&http_test_auth(), "{}", &http_test_context(), false)
1557            .await;
1558        server.await.unwrap();
1559        let error = result.err().expect("truncated body should fail");
1560
1561        assert!(
1562            error
1563                .message
1564                .contains("Transport error reading Codex response body")
1565        );
1566        assert_eq!(error.detail.as_deref(), Some("http_response_body"));
1567    }
1568
1569    #[test]
1570    fn codex_error_display() {
1571        let err = CodexError {
1572            status: 429,
1573            message: "Rate limited".to_string(),
1574            detail: Some("body".to_string()),
1575            retry_after: Some("5".to_string()),
1576            origin: CodexErrorOrigin::Http,
1577        };
1578        let display = format!("{err}");
1579        assert!(display.contains("429"));
1580        assert!(display.contains("Rate limited"));
1581    }
1582
1583    #[test]
1584    fn websocket_pre_request_502_is_retryable() {
1585        let err = CodexError {
1586            status: 502,
1587            message: "WebSocket connect error".to_string(),
1588            detail: Some("websocket_pre_request".to_string()),
1589            retry_after: Some("3".to_string()),
1590            origin: CodexErrorOrigin::WebSocket,
1591        };
1592
1593        assert!(is_retryable_transport_error(&err));
1594    }
1595
1596    #[test]
1597    fn websocket_pre_request_statusless_error_is_retryable() {
1598        let err = CodexError {
1599            status: 0,
1600            message: "WebSocket connect timeout after 15000ms".to_string(),
1601            detail: Some("websocket_pre_request".to_string()),
1602            retry_after: None,
1603            origin: CodexErrorOrigin::WebSocket,
1604        };
1605
1606        assert!(is_retryable_transport_error(&err));
1607    }
1608
1609    #[test]
1610    fn websocket_pre_request_400_is_not_retryable() {
1611        let err = CodexError {
1612            status: 400,
1613            message: "WebSocket connect error".to_string(),
1614            detail: Some("websocket_pre_request".to_string()),
1615            retry_after: None,
1616            origin: CodexErrorOrigin::WebSocket,
1617        };
1618
1619        assert!(!is_retryable_transport_error(&err));
1620    }
1621
1622    #[test]
1623    fn statusless_transport_error_matching_is_case_insensitive() {
1624        let err = CodexError {
1625            status: 0,
1626            message: "WebSocket protocol error: Connection reset without closing handshake"
1627                .to_string(),
1628            detail: None,
1629            retry_after: None,
1630            origin: CodexErrorOrigin::WebSocket,
1631        };
1632
1633        assert!(is_retryable_transport_error(&err));
1634    }
1635
1636    #[test]
1637    fn codex_headers_include_session_and_beta() {
1638        let auth = StoredAuth {
1639            access: "tok".into(),
1640            refresh: String::new(),
1641            account_id: Some("acct".into()),
1642            expires: u64::MAX,
1643        };
1644        let ctx = RequestContext {
1645            req_id: "r".into(),
1646            session_id: Some("s".into()),
1647            session_seq: None,
1648            provider: "codex".into(),
1649            traffic: None,
1650            monitor: None,
1651            passthrough: None,
1652        };
1653        let headers = build_codex_headers(&auth, &ctx, false).unwrap();
1654        assert_eq!(
1655            headers.get("openai-beta").unwrap(),
1656            "responses=experimental"
1657        );
1658        assert_eq!(headers.get("session_id").unwrap(), "s");
1659    }
1660
1661    #[test]
1662    fn codex_headers_include_responses_lite_when_requested() {
1663        let auth = StoredAuth {
1664            access: "tok".into(),
1665            refresh: String::new(),
1666            account_id: None,
1667            expires: u64::MAX,
1668        };
1669        let ctx = RequestContext {
1670            req_id: "r".into(),
1671            session_id: None,
1672            session_seq: None,
1673            provider: "codex".into(),
1674            traffic: None,
1675            monitor: None,
1676            passthrough: None,
1677        };
1678        let headers = build_codex_headers(&auth, &ctx, true).unwrap();
1679        assert_eq!(
1680            headers
1681                .get("x-openai-internal-codex-responses-lite")
1682                .unwrap(),
1683            "true"
1684        );
1685        assert_eq!(headers.get("originator").unwrap(), "codex_cli_rs");
1686    }
1687
1688    #[test]
1689    fn codex_headers_omit_session_when_missing() {
1690        let auth = StoredAuth {
1691            access: "tok".into(),
1692            refresh: String::new(),
1693            account_id: None,
1694            expires: u64::MAX,
1695        };
1696        let ctx = RequestContext {
1697            req_id: "r".into(),
1698            session_id: None,
1699            session_seq: None,
1700            provider: "codex".into(),
1701            traffic: None,
1702            monitor: None,
1703            passthrough: None,
1704        };
1705        let headers = build_codex_headers(&auth, &ctx, false).unwrap();
1706        assert!(headers.get("session_id").is_none());
1707        assert!(headers.get("x-client-request-id").is_none());
1708    }
1709
1710    #[test]
1711    fn codex_headers_return_error_for_invalid_session_header() {
1712        let auth = StoredAuth {
1713            access: "tok".into(),
1714            refresh: String::new(),
1715            account_id: None,
1716            expires: u64::MAX,
1717        };
1718        let ctx = RequestContext {
1719            req_id: "r".into(),
1720            session_id: Some("bad\nsession".into()),
1721            session_seq: None,
1722            provider: "codex".into(),
1723            traffic: None,
1724            monitor: None,
1725            passthrough: None,
1726        };
1727        let err = build_codex_headers(&auth, &ctx, false).unwrap_err();
1728        assert_eq!(err.status, 500);
1729        assert!(err.message.contains("session_id"));
1730    }
1731
1732    #[test]
1733    fn build_websocket_request_removes_stream() {
1734        let input = vec![
1735            super::super::translate::request::ResponsesInputItem::Message {
1736                role: "user".to_string(),
1737                content: vec![
1738                    super::super::translate::request::ResponsesContentPart::InputText {
1739                        text: "hello".to_string(),
1740                    },
1741                ],
1742            },
1743        ];
1744        let req = ResponsesRequest {
1745            model: "gpt-5.5".to_string(),
1746            instructions: None,
1747            input,
1748            tools: None,
1749            tool_choice: None,
1750            store: false,
1751            stream: true,
1752            parallel_tool_calls: true,
1753            include: None,
1754            client_metadata: None,
1755            service_tier: None,
1756            prompt_cache_key: None,
1757            text: super::super::translate::request::ResponsesText {
1758                verbosity: Some("low".to_string()),
1759                format: None,
1760            },
1761            reasoning: None,
1762        };
1763        let payload = build_websocket_request(&req, None);
1764        assert_eq!(
1765            payload.get("type").and_then(|v| v.as_str()),
1766            Some("response.create")
1767        );
1768        assert!(payload.get("stream").is_none());
1769        assert!(payload.get("previous_response_id").is_none());
1770    }
1771
1772    #[test]
1773    fn websocket_pool_key_tracks_continuation_opt_in() {
1774        let ctx = RequestContext {
1775            req_id: "r".into(),
1776            session_id: Some("session".into()),
1777            session_seq: None,
1778            provider: "codex".into(),
1779            traffic: None,
1780            monitor: None,
1781            passthrough: None,
1782        };
1783        let disabled = super::super::continuation::ContinuationCandidate {
1784            previous_response_id: None,
1785            input_delta: None,
1786            input_delta_count: 1,
1787            disabled_reason: Some("disabled".into()),
1788        };
1789        let first_enabled = super::super::continuation::ContinuationCandidate {
1790            previous_response_id: None,
1791            input_delta: None,
1792            input_delta_count: 1,
1793            disabled_reason: Some("missing_state".into()),
1794        };
1795        let append = super::super::continuation::ContinuationCandidate {
1796            previous_response_id: Some("resp_1".into()),
1797            input_delta: None,
1798            input_delta_count: 1,
1799            disabled_reason: None,
1800        };
1801
1802        assert_eq!(websocket_pool_key(&ctx, Some(&disabled)), None);
1803        assert_eq!(
1804            websocket_pool_key(&ctx, Some(&first_enabled)),
1805            Some("session")
1806        );
1807        assert_eq!(websocket_pool_key(&ctx, Some(&append)), Some("session"));
1808    }
1809
1810    #[test]
1811    fn websocket_pool_reset_ignores_initial_and_disabled_states() {
1812        let missing_state = super::super::continuation::ContinuationCandidate {
1813            previous_response_id: None,
1814            input_delta: None,
1815            input_delta_count: 1,
1816            disabled_reason: Some("missing_state".into()),
1817        };
1818        let disabled = super::super::continuation::ContinuationCandidate {
1819            previous_response_id: None,
1820            input_delta: None,
1821            input_delta_count: 1,
1822            disabled_reason: Some("disabled".into()),
1823        };
1824        let prompt_changed = super::super::continuation::ContinuationCandidate {
1825            previous_response_id: None,
1826            input_delta: None,
1827            input_delta_count: 1,
1828            disabled_reason: Some("prompt_changed".into()),
1829        };
1830
1831        assert!(!should_reset_websocket_pool(Some(&missing_state)));
1832        assert!(!should_reset_websocket_pool(Some(&disabled)));
1833        assert!(should_reset_websocket_pool(Some(&prompt_changed)));
1834    }
1835
1836    #[test]
1837    fn build_codex_headers_error_on_empty_access() {
1838        let auth = StoredAuth {
1839            access: "".into(),
1840            refresh: String::new(),
1841            account_id: None,
1842            expires: u64::MAX,
1843        };
1844        let ctx = RequestContext {
1845            req_id: "r".into(),
1846            session_id: None,
1847            session_seq: None,
1848            provider: "codex".into(),
1849            traffic: None,
1850            monitor: None,
1851            passthrough: None,
1852        };
1853        let result = build_codex_headers(&auth, &ctx, false);
1854        assert!(
1855            result.is_ok(),
1856            "empty access should still produce valid Bearer header"
1857        );
1858    }
1859
1860    #[test]
1861    fn codex_header_timeout_error_display() {
1862        let err = CodexHeaderTimeoutError { timeout_ms: 60000 };
1863        let display = format!("{err}");
1864        assert!(display.contains("60000"));
1865    }
1866
1867    #[test]
1868    fn codex_transport_error_display() {
1869        let err = CodexTransportError {
1870            message: "connection reset".to_string(),
1871        };
1872        let display = format!("{err}");
1873        assert!(display.contains("connection reset"));
1874    }
1875
1876    #[test]
1877    fn unauthorized_retry_distinguishes_auto_and_strict_websocket_handshakes() {
1878        let http_unauthorized = Ok(CodexResponse {
1879            body: Vec::new(),
1880            status: 401,
1881            headers: Vec::new(),
1882        });
1883        let websocket_unauthorized = Err(CodexError {
1884            status: 401,
1885            message: "WebSocket connect error".to_string(),
1886            detail: None,
1887            retry_after: None,
1888            origin: CodexErrorOrigin::WebSocket,
1889        });
1890        let forbidden = Err(CodexError {
1891            status: 403,
1892            message: "Forbidden".to_string(),
1893            detail: None,
1894            retry_after: None,
1895            origin: CodexErrorOrigin::WebSocket,
1896        });
1897        let rejected_handshake = Err(CodexError {
1898            status: 401,
1899            message: "WebSocket connect error".to_string(),
1900            detail: Some("policy denied".to_string()),
1901            retry_after: None,
1902            origin: CodexErrorOrigin::WebSocketHandshake,
1903        });
1904        let rejected_handshake_err = match &rejected_handshake {
1905            Err(error) => error,
1906            Ok(_) => panic!("expected rejected handshake"),
1907        };
1908
1909        assert!(should_refresh_after_unauthorized(
1910            &http_unauthorized,
1911            false,
1912            crate::config::CodexTransport::Auto
1913        ));
1914        assert!(should_refresh_after_unauthorized(
1915            &websocket_unauthorized,
1916            false,
1917            crate::config::CodexTransport::Auto
1918        ));
1919        assert!(!should_refresh_after_unauthorized(
1920            &forbidden,
1921            false,
1922            crate::config::CodexTransport::Auto
1923        ));
1924        assert!(!should_refresh_after_unauthorized(
1925            &rejected_handshake,
1926            false,
1927            crate::config::CodexTransport::Auto
1928        ));
1929        assert!(should_refresh_after_unauthorized(
1930            &rejected_handshake,
1931            false,
1932            crate::config::CodexTransport::WebSocket
1933        ));
1934        assert!(!should_refresh_after_unauthorized(
1935            &http_unauthorized,
1936            true,
1937            crate::config::CodexTransport::Auto
1938        ));
1939        assert!(should_fallback_to_http(rejected_handshake_err));
1940    }
1941
1942    #[test]
1943    fn continuation_retry_requires_previous_response_id() {
1944        let append = super::super::continuation::ContinuationCandidate {
1945            previous_response_id: Some("resp_1".into()),
1946            input_delta: None,
1947            input_delta_count: 1,
1948            disabled_reason: None,
1949        };
1950        let initial = super::super::continuation::ContinuationCandidate {
1951            previous_response_id: None,
1952            input_delta: None,
1953            input_delta_count: 1,
1954            disabled_reason: Some("missing_state".into()),
1955        };
1956        let timeout = CodexError {
1957            status: 0,
1958            message: "WebSocket response start timeout after 60000ms".to_string(),
1959            detail: Some(
1960                super::super::websocket::WEBSOCKET_RESPONSE_START_TIMEOUT_DETAIL.to_string(),
1961            ),
1962            retry_after: None,
1963            origin: CodexErrorOrigin::WebSocket,
1964        };
1965        let missing = CodexError {
1966            status: 0,
1967            message: "Previous response not found".to_string(),
1968            detail: Some("previous_response_not_found".to_string()),
1969            retry_after: None,
1970            origin: CodexErrorOrigin::WebSocket,
1971        };
1972        let idle = CodexError {
1973            status: 0,
1974            message: "WebSocket idle timeout after 60000ms".to_string(),
1975            detail: None,
1976            retry_after: None,
1977            origin: CodexErrorOrigin::WebSocket,
1978        };
1979
1980        assert!(should_retry_without_continuation(&timeout, Some(&append)));
1981        assert!(should_retry_without_continuation(&missing, Some(&append)));
1982        assert!(!should_retry_without_continuation(&idle, Some(&append)));
1983        assert!(!should_retry_without_continuation(&timeout, Some(&initial)));
1984        assert!(!should_retry_without_continuation(&timeout, None));
1985    }
1986}