Skip to main content

agent_sdk_providers/impls/
openai_codex_responses.rs

1//! `OpenAI` Codex / `ChatGPT` subscription provider implementation.
2//!
3//! This mirrors pi's `openai-codex-responses` provider family and talks to the
4//! `ChatGPT` Codex backend using OAuth bearer tokens captured from the `ChatGPT`
5//! Plus/Pro login flow.
6
7use crate::attachments::validate_request_attachments;
8use crate::provider::LlmProvider;
9use crate::streaming::{SseLineBuffer, StreamBox, StreamDelta, StreamErrorKind};
10use agent_sdk_foundation::llm::{
11    ChatOutcome, ChatRequest, ChatResponse, Content, ContentBlock, Effort, ResponseFormat,
12    StopReason, ThinkingConfig, ThinkingMode, ToolChoice, Usage,
13};
14use anyhow::{Context, Result};
15use async_trait::async_trait;
16use base64::Engine;
17use futures::{SinkExt, StreamExt};
18use reqwest::StatusCode;
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use std::sync::Arc;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::time::{Duration, Instant};
24use tokio::net::TcpStream;
25use tokio::sync::Mutex;
26use tokio::time::timeout;
27use tokio_tungstenite::connect_async;
28use tokio_tungstenite::tungstenite::Message as WebSocketMessage;
29use tokio_tungstenite::tungstenite::client::IntoClientRequest;
30use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
31
32const DEFAULT_BASE_URL: &str = "https://chatgpt.com/backend-api";
33
34/// Connect timeout for the HTTP and WebSocket transports.
35const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
36/// Bound on a single WebSocket frame read / send. A peer that stops sending must
37/// not wedge the turn (and, before the lock was released per-turn, the whole
38/// session) indefinitely.
39const WEBSOCKET_IO_TIMEOUT: Duration = Duration::from_mins(2);
40/// Upper bound on the number of cached WebSocket sessions retained at once.
41const MAX_WEBSOCKET_SESSIONS: usize = 512;
42/// Environment variable that forces the Codex provider onto the HTTP transport,
43/// skipping the WebSocket path entirely. Set to a truthy value (`1`, `true`,
44/// `yes`, `on`) when the environment cannot complete the `wss` upgrade (a
45/// corporate proxy / firewall that black-holes websockets). Honored at provider
46/// construction.
47const OPENAI_CODEX_DISABLE_WEBSOCKETS_ENV: &str = "OPENAI_CODEX_DISABLE_WEBSOCKETS";
48
49/// Interpret a raw environment-variable value as a boolean. Absent or
50/// unrecognized values are treated as `false` so the default (WebSocket-first)
51/// behavior is preserved. Split out from the env read so the truthiness logic is
52/// unit-testable without mutating the process environment (`std::env::set_var`
53/// is `unsafe`, which `#![forbid(unsafe_code)]` rejects even in tests).
54fn parse_disable_websockets_value(value: Option<&str>) -> bool {
55    value.is_some_and(|value| {
56        matches!(
57            value.trim().to_ascii_lowercase().as_str(),
58            "1" | "true" | "yes" | "on"
59        )
60    })
61}
62
63/// Read [`OPENAI_CODEX_DISABLE_WEBSOCKETS_ENV`] as a boolean.
64fn websockets_disabled_from_env() -> bool {
65    parse_disable_websockets_value(
66        std::env::var(OPENAI_CODEX_DISABLE_WEBSOCKETS_ENV)
67            .ok()
68            .as_deref(),
69    )
70}
71
72/// Build an HTTP client with a connect/keepalive timeout, matching the sibling
73/// providers (`anthropic`, `vertex`). A bare `reqwest::Client::new()` has no
74/// connect timeout, so a black-holed connect would wedge `chat`/`chat_stream`.
75fn build_http_client() -> reqwest::Client {
76    reqwest::Client::builder()
77        .connect_timeout(CONNECT_TIMEOUT)
78        .tcp_keepalive(CONNECT_TIMEOUT)
79        .build()
80        .unwrap_or_default()
81}
82const OPENAI_CODEX_JWT_CLAIM_PATH: &str = "https://api.openai.com/auth";
83const OPENAI_CODEX_ORIGINATOR: &str = "codex_cli_rs";
84const OPENAI_CODEX_RESPONSES_BETA_HEADER: &str = "responses=experimental";
85const OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER: &str = "responses_websockets=2026-02-06";
86const OPENAI_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
87const OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE: &str =
88    "websocket_connection_limit_reached";
89
90// GPT-5.4 (frontier reasoning with 1.05M context)
91pub const MODEL_GPT54: &str = "gpt-5.4";
92
93// GPT-5.3-Codex (latest Codex model)
94pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
95
96// GPT-5.2-Codex (legacy Responses-first codex model)
97pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
98
99/// Reasoning effort level for the model.
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
101#[serde(rename_all = "lowercase")]
102pub enum ReasoningEffort {
103    Low,
104    #[default]
105    Medium,
106    High,
107    /// Extra-high reasoning for complex problems
108    #[serde(rename = "xhigh")]
109    XHigh,
110}
111
112/// `OpenAI` Codex / `ChatGPT` subscription provider.
113///
114/// This provider uses the `ChatGPT` Codex backend (`/backend-api/codex/responses`)
115/// and requires an OAuth access token obtained from the `ChatGPT` Plus/Pro login flow.
116#[derive(Clone)]
117pub struct OpenAICodexResponsesProvider {
118    client: reqwest::Client,
119    api_key: String,
120    model: String,
121    base_url: String,
122    thinking: Option<ThinkingConfig>,
123    account_id: Option<String>,
124    websocket_sessions: Arc<Mutex<HashMap<String, Arc<Mutex<WebsocketSessionState>>>>>,
125    /// Hard opt-out: when set, the WebSocket transport is never attempted and
126    /// every turn goes straight to HTTP. Sourced from
127    /// [`with_websockets_disabled`](Self::with_websockets_disabled) or the
128    /// [`OPENAI_CODEX_DISABLE_WEBSOCKETS_ENV`] environment variable.
129    websockets_disabled: bool,
130    /// Cross-session "websockets don't work in this environment" memory. The
131    /// per-session [`WebsocketSessionState::websocket_disabled`] flag only
132    /// protects the session that hit the failure, so a fresh session would
133    /// re-pay the full connect/warmup timeout penalty. A *transport*
134    /// (connectivity) failure latches this provider-level flag so every
135    /// subsequent session skips the WebSocket attempt and goes straight to
136    /// HTTP. The environment self-heals after the first failed session instead
137    /// of stalling on every one. Latches once per process and never resets — a
138    /// genuinely WS-hostile network does not recover mid-process, and a
139    /// once-per-process latch is the simplest correct behavior. Auth/request
140    /// failures (401/client errors) deliberately do *not* set this flag.
141    websockets_unhealthy: Arc<AtomicBool>,
142}
143
144type CodexWebSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
145
146#[derive(Default)]
147struct WebsocketSessionState {
148    connection: Option<CodexWebSocket>,
149    last_request: Option<ApiStreamingRequest>,
150    last_response_id: Option<String>,
151    last_response_items: Vec<ApiInputItem>,
152    turn_state: Option<String>,
153    prewarmed: bool,
154    websocket_disabled: bool,
155    /// Set while a turn is mid-flight on this session and cleared when it
156    /// completes cleanly. If a turn's stream is dropped (cancellation), this
157    /// stays set; the next turn detects the abandoned turn on lock acquisition,
158    /// discards the half-consumed connection + stale incremental baseline, and
159    /// reconnects so the cancelled turn can't poison it.
160    in_flight: bool,
161    /// Last time this session was touched, for LRU eviction of the bounded map.
162    last_used: Option<Instant>,
163}
164
165impl OpenAICodexResponsesProvider {
166    /// Create a new `OpenAI` Codex provider.
167    #[must_use]
168    pub fn new(api_key: String, model: String) -> Self {
169        Self {
170            client: build_http_client(),
171            api_key,
172            model,
173            base_url: DEFAULT_BASE_URL.to_owned(),
174            thinking: None,
175            account_id: None,
176            websocket_sessions: Arc::new(Mutex::new(HashMap::new())),
177            websockets_disabled: websockets_disabled_from_env(),
178            websockets_unhealthy: Arc::new(AtomicBool::new(false)),
179        }
180    }
181
182    /// Create a provider with a custom base URL.
183    #[must_use]
184    pub fn with_base_url(api_key: String, model: String, base_url: String) -> Self {
185        Self {
186            client: build_http_client(),
187            api_key,
188            model,
189            base_url,
190            thinking: None,
191            account_id: None,
192            websocket_sessions: Arc::new(Mutex::new(HashMap::new())),
193            websockets_disabled: websockets_disabled_from_env(),
194            websockets_unhealthy: Arc::new(AtomicBool::new(false)),
195        }
196    }
197
198    /// Create a provider using GPT-5.3-Codex (latest codex model).
199    #[must_use]
200    pub fn gpt53_codex(api_key: String) -> Self {
201        Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
202    }
203
204    /// Create a provider using the latest Codex model.
205    #[must_use]
206    pub fn codex(api_key: String) -> Self {
207        Self::gpt53_codex(api_key)
208    }
209
210    /// Create a provider using GPT-5.4 (frontier reasoning with 1.05M context).
211    #[must_use]
212    pub fn gpt54(api_key: String) -> Self {
213        Self::new(api_key, MODEL_GPT54.to_owned())
214    }
215
216    /// Set the provider-owned thinking configuration for this model.
217    #[must_use]
218    pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
219        self.thinking = Some(thinking);
220        self
221    }
222
223    /// Set a known `ChatGPT` account id, avoiding JWT decoding on each request.
224    #[must_use]
225    pub fn with_account_id(mut self, account_id: impl Into<String>) -> Self {
226        self.account_id = Some(account_id.into());
227        self
228    }
229
230    /// Set the reasoning effort level.
231    #[must_use]
232    pub fn with_reasoning_effort(self, effort: ReasoningEffort) -> Self {
233        self.with_thinking(ThinkingConfig::default().with_effort(map_reasoning_effort(effort)))
234    }
235
236    /// Force the HTTP transport, skipping the WebSocket path entirely.
237    ///
238    /// In a WebSocket-hostile environment (a corporate proxy / firewall that
239    /// black-holes the `wss` upgrade) the WebSocket-first transport stalls for
240    /// up to the connect + warmup timeout budget on every fresh session before
241    /// falling back to HTTP. An operator who knows their network cannot do
242    /// `wss` can set this to skip the penalty entirely. The
243    /// `OPENAI_CODEX_DISABLE_WEBSOCKETS` environment variable does the
244    /// same without a code change.
245    #[must_use]
246    pub const fn with_websockets_disabled(mut self, disabled: bool) -> Self {
247        self.websockets_disabled = disabled;
248        self
249    }
250
251    /// Whether the WebSocket transport should be skipped for this turn: either
252    /// it was hard-disabled (builder / env) or a prior session in this process
253    /// already proved the environment cannot complete the `wss` transport.
254    fn skip_websocket(&self) -> bool {
255        self.websockets_disabled || self.websockets_unhealthy.load(Ordering::Relaxed)
256    }
257
258    const fn max_output_tokens(request: &ChatRequest) -> Option<u32> {
259        if request.max_tokens_explicit {
260            Some(request.max_tokens)
261        } else {
262            None
263        }
264    }
265
266    fn build_headers(
267        &self,
268        streaming: bool,
269        session_id: Option<&str>,
270        turn_state: Option<&str>,
271    ) -> Result<reqwest::header::HeaderMap> {
272        self.build_headers_with_beta(
273            streaming,
274            session_id,
275            OPENAI_CODEX_RESPONSES_BETA_HEADER,
276            turn_state,
277        )
278    }
279
280    fn build_websocket_headers(
281        &self,
282        session_id: Option<&str>,
283        turn_state: Option<&str>,
284    ) -> Result<reqwest::header::HeaderMap> {
285        self.build_headers_with_beta(
286            false,
287            session_id,
288            OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER,
289            turn_state,
290        )
291    }
292
293    fn build_headers_with_beta(
294        &self,
295        streaming: bool,
296        session_id: Option<&str>,
297        beta_header: &'static str,
298        turn_state: Option<&str>,
299    ) -> Result<reqwest::header::HeaderMap> {
300        use reqwest::header::{
301            ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT,
302        };
303
304        let account_id = self
305            .account_id
306            .clone()
307            .map_or_else(|| extract_account_id(&self.api_key), Ok)
308            .context("failed to extract chatgpt account id from OpenAI Codex OAuth token")?;
309
310        let mut headers = HeaderMap::new();
311        headers.insert(
312            AUTHORIZATION,
313            HeaderValue::from_str(&format!("Bearer {}", self.api_key))?,
314        );
315        headers.insert("chatgpt-account-id", HeaderValue::from_str(&account_id)?);
316        headers.insert("OpenAI-Beta", HeaderValue::from_static(beta_header));
317        headers.insert(
318            "originator",
319            HeaderValue::from_static(OPENAI_CODEX_ORIGINATOR),
320        );
321        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
322        headers.insert(
323            USER_AGENT,
324            HeaderValue::from_str(&format!(
325                "{OPENAI_CODEX_ORIGINATOR}/{} ({} {})",
326                env!("CARGO_PKG_VERSION"),
327                std::env::consts::OS,
328                std::env::consts::ARCH,
329            ))?,
330        );
331        if streaming {
332            headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
333        }
334        if let Some(session_id) = session_id {
335            let session_id_header = HeaderValue::from_str(session_id)?;
336            headers.insert("session_id", session_id_header.clone());
337            headers.insert("x-client-request-id", session_id_header);
338        }
339        if let Some(turn_state) = turn_state {
340            headers.insert(
341                OPENAI_CODEX_TURN_STATE_HEADER,
342                HeaderValue::from_str(turn_state)?,
343            );
344        }
345
346        Ok(headers)
347    }
348
349    async fn websocket_session(&self, session_id: &str) -> Arc<Mutex<WebsocketSessionState>> {
350        let mut sessions = self.websocket_sessions.lock().await;
351        if !sessions.contains_key(session_id) && sessions.len() >= MAX_WEBSOCKET_SESSIONS {
352            evict_idle_sessions(&mut sessions);
353        }
354        sessions
355            .entry(session_id.to_string())
356            .or_insert_with(|| Arc::new(Mutex::new(WebsocketSessionState::default())))
357            .clone()
358    }
359
360    async fn connect_websocket(
361        &self,
362        session_id: Option<&str>,
363        turn_state: Option<&str>,
364    ) -> Result<(CodexWebSocket, Option<String>)> {
365        let headers = self.build_websocket_headers(session_id, turn_state)?;
366        let url = codex_websocket_url(&self.base_url)
367            .context("failed to build OpenAI Codex websocket URL")?;
368        let mut request = url
369            .as_str()
370            .into_client_request()
371            .context("failed to build OpenAI Codex websocket request")?;
372        request.headers_mut().extend(headers);
373
374        let (stream, response) = timeout(CONNECT_TIMEOUT, connect_async(request))
375            .await
376            .context("OpenAI Codex websocket connect timed out")?
377            .context("failed to connect OpenAI Codex websocket")?;
378        let turn_state = response
379            .headers()
380            .get(OPENAI_CODEX_TURN_STATE_HEADER)
381            .and_then(|value| value.to_str().ok())
382            .map(ToOwned::to_owned);
383        Ok((stream, turn_state))
384    }
385
386    fn map_response(api_response: ApiResponse) -> ChatResponse {
387        let content = build_content_blocks(&api_response.output);
388        let has_tool_calls = content
389            .iter()
390            .any(|block| matches!(block, ContentBlock::ToolUse { .. }));
391        let stop_reason = if has_tool_calls {
392            Some(StopReason::ToolUse)
393        } else {
394            api_response.status.map(|status| match status {
395                ApiStatus::Completed => StopReason::EndTurn,
396                ApiStatus::Incomplete => StopReason::MaxTokens,
397                ApiStatus::Failed => StopReason::StopSequence,
398            })
399        };
400
401        ChatResponse {
402            id: api_response.id,
403            content,
404            model: api_response.model,
405            stop_reason,
406            usage: api_response.usage.map_or(
407                Usage {
408                    input_tokens: 0,
409                    output_tokens: 0,
410                    cached_input_tokens: 0,
411                    cache_creation_input_tokens: 0,
412                },
413                |usage| Usage {
414                    input_tokens: usage.input_tokens,
415                    output_tokens: usage.output_tokens,
416                    cached_input_tokens: usage
417                        .input_tokens_details
418                        .as_ref()
419                        .map_or(0, |details| details.cached_tokens),
420                    cache_creation_input_tokens: 0,
421                },
422            ),
423        }
424    }
425}
426
427#[async_trait]
428impl LlmProvider for OpenAICodexResponsesProvider {
429    async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
430        let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
431            Ok(thinking) => thinking,
432            Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
433        };
434        if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
435            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
436        }
437        let reasoning = build_api_reasoning(thinking_config.as_ref());
438        let input = build_api_input(&request);
439        let max_output_tokens = Self::max_output_tokens(&request);
440        let prompt_cache_key = request.session_id.as_deref();
441        let tools: Option<Vec<ApiTool>> = request
442            .tools
443            .as_ref()
444            .map(|ts| ts.iter().cloned().map(convert_tool).collect());
445        let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
446        let text_format = request
447            .response_format
448            .as_ref()
449            .map(ApiResponseTextFormat::from);
450        let tool_choice = codex_tool_choice(request.tool_choice.as_ref());
451
452        let api_request = ApiResponsesRequest {
453            model: &self.model,
454            instructions: request.system.as_str(),
455            input: &input,
456            tools: tools.as_deref(),
457            max_output_tokens,
458            reasoning,
459            tool_choice: Some(tool_choice),
460            parallel_tool_calls: parallel_tool_calls.then_some(true),
461            store: false,
462            text: Some(ApiTextSettings {
463                verbosity: "medium",
464                format: text_format,
465            }),
466            include: Some(&["reasoning.encrypted_content"]),
467            prompt_cache_key,
468        };
469
470        log::debug!(
471            "OpenAI Codex request model={} max_tokens={}",
472            self.model,
473            request.max_tokens
474        );
475
476        let response = self
477            .client
478            .post(codex_url(&self.base_url))
479            .headers(self.build_headers(false, request.session_id.as_deref(), None)?)
480            .json(&api_request)
481            .send()
482            .await
483            .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
484
485        let status = response.status();
486        // Read `Retry-After` off the 429 response before the body is consumed.
487        let retry_after = if status == StatusCode::TOO_MANY_REQUESTS {
488            crate::http::retry_after_from_headers(response.headers())
489        } else {
490            None
491        };
492        let bytes = response
493            .bytes()
494            .await
495            .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
496
497        log::debug!(
498            "OpenAI Codex response status={} body_len={}",
499            status,
500            bytes.len()
501        );
502
503        if status == StatusCode::TOO_MANY_REQUESTS {
504            return Ok(ChatOutcome::RateLimited(retry_after));
505        }
506
507        if status.is_server_error() {
508            let body = String::from_utf8_lossy(&bytes);
509            log::error!("OpenAI Codex server error status={status} body={body}");
510            return Ok(ChatOutcome::ServerError(body.into_owned()));
511        }
512
513        if status.is_client_error() {
514            let body = String::from_utf8_lossy(&bytes);
515            log::warn!("OpenAI Codex client error status={status} body={body}");
516            return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
517        }
518
519        let api_response: ApiResponse = serde_json::from_slice(&bytes)
520            .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
521
522        // The Responses API reports generation failures as HTTP 200 with
523        // status=failed plus an error object. Surface that as a server error
524        // instead of a successful turn with empty content (mirrors the streaming
525        // `response.failed` handling).
526        if matches!(api_response.status, Some(ApiStatus::Failed)) {
527            let message = api_response
528                .error
529                .and_then(|error| error.message)
530                .unwrap_or_else(|| "OpenAI Codex reported status=failed".to_owned());
531            log::error!("OpenAI Codex generation failed: {message}");
532            return Ok(ChatOutcome::ServerError(message));
533        }
534
535        Ok(ChatOutcome::Success(Self::map_response(api_response)))
536    }
537
538    #[allow(clippy::too_many_lines)]
539    fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
540        Box::pin(async_stream::stream! {
541            let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
542                Ok(thinking) => thinking,
543                Err(error) => {
544                    yield Ok(StreamDelta::Error {
545                        message: error.to_string(),
546                        kind: StreamErrorKind::InvalidRequest,
547                    });
548                    return;
549                }
550            };
551            if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
552                yield Ok(StreamDelta::Error {
553                    message: error.to_string(),
554                    kind: StreamErrorKind::InvalidRequest,
555                });
556                return;
557            }
558
559            let reasoning = build_api_reasoning(thinking_config.as_ref());
560            let input = build_api_input(&request);
561            let max_output_tokens = Self::max_output_tokens(&request);
562            let tools: Option<Vec<ApiTool>> = request
563                .tools
564                .as_ref()
565                .map(|ts| ts.iter().cloned().map(convert_tool).collect());
566            let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
567            let text_format = request.response_format.as_ref().map(ApiResponseTextFormat::from);
568            let tool_choice = codex_tool_choice(request.tool_choice.as_ref());
569            let api_request = ApiStreamingRequest {
570                model: self.model.clone(),
571                instructions: request.system.clone(),
572                input,
573                tools,
574                max_output_tokens,
575                reasoning,
576                tool_choice: Some(tool_choice),
577                parallel_tool_calls: parallel_tool_calls.then_some(true),
578                store: false,
579                text: Some(ApiTextSettings { verbosity: "medium", format: text_format }),
580                include: Some(vec!["reasoning.encrypted_content".to_string()]),
581                prompt_cache_key: request.session_id.clone(),
582                stream: true,
583            };
584
585            log::debug!("OpenAI Codex streaming request model={} max_tokens={}", self.model, request.max_tokens);
586
587            let mut sse_turn_state: Option<String> = None;
588
589            // Skip the WebSocket transport entirely when it is hard-disabled
590            // (builder / env) or a prior session already proved this environment
591            // cannot complete the `wss` transport. The turn falls through to the
592            // HTTP request path below without paying any WebSocket connect /
593            // warmup timeout penalty.
594            if let Some(session_id) = request.session_id.as_deref().filter(|_| !self.skip_websocket()) {
595                let session = self.websocket_session(session_id).await;
596                let mut websocket_session = session.lock().await;
597
598                // Latched on a TRANSPORT/connectivity failure (connect timeout,
599                // upgrade failure, warmup connection-closed/timeout, mid-stream
600                // disconnect before output) so every later session in this
601                // process skips the WebSocket attempt. Auth/request failures
602                // (401 / client-error wrapped events) deliberately do NOT latch
603                // this — a transient auth blip must not force HTTP-only forever.
604                let mark_websocket_transport_unhealthy = || {
605                    self.websockets_unhealthy.store(true, Ordering::Relaxed);
606                };
607
608                // If the previous turn for this session was abandoned mid-flight
609                // (its stream was dropped — the SDK's cancellation mechanism), its
610                // half-consumed connection and incremental baseline are stale.
611                // Discard them so we reconnect fresh and never read the cancelled
612                // turn's trailing frames or pair a new request with its response id.
613                if websocket_session.in_flight {
614                    log::warn!(
615                        "OpenAI Codex session {session_id} had an abandoned in-flight turn; resetting websocket state"
616                    );
617                    reset_websocket_connection(&mut websocket_session);
618                    websocket_session.last_request = None;
619                    websocket_session.last_response_id = None;
620                    websocket_session.last_response_items.clear();
621                }
622                websocket_session.in_flight = true;
623                websocket_session.last_used = Some(Instant::now());
624
625                if !websocket_session.websocket_disabled {
626                    'websocket_attempts: for attempt in 0..2 {
627                        if websocket_session.connection.is_none() {
628                            match self
629                                .connect_websocket(
630                                    Some(session_id),
631                                    websocket_session.turn_state.as_deref(),
632                                )
633                                .await
634                            {
635                                Ok((connection, turn_state)) => {
636                                    websocket_session.connection = Some(connection);
637                                    if let Some(turn_state) = turn_state {
638                                        websocket_session.turn_state = Some(turn_state);
639                                    }
640                                    websocket_session.prewarmed = false;
641                                }
642                                Err(error) => {
643                                    log::warn!(
644                                        "OpenAI Codex websocket connect failed on attempt {}: {error:#}",
645                                        attempt + 1,
646                                    );
647                                    if attempt == 1 {
648                                        websocket_session.websocket_disabled = true;
649                                        mark_websocket_transport_unhealthy();
650                                    }
651                                    continue;
652                                }
653                            }
654                        }
655
656                        if websocket_session.connection.is_some()
657                            && websocket_session.last_request.is_none()
658                            && !websocket_session.prewarmed
659                        {
660                            let mut warmup_request = ApiWebsocketRequest::from(&api_request);
661                            warmup_request.generate = Some(false);
662                            let warmup_payload = match serde_json::to_string(&warmup_request) {
663                                Ok(payload) => payload,
664                                Err(error) => {
665                                    yield Ok(StreamDelta::Error {
666                                        message: format!(
667                                            "failed to encode websocket warmup request: {error}"
668                                        ),
669                                        kind: StreamErrorKind::InvalidRequest,
670                                    });
671                                    return;
672                                }
673                            };
674
675                            let warmup_send_result = if let Some(connection) =
676                                websocket_session.connection.as_mut()
677                            {
678                                timeout(
679                                    WEBSOCKET_IO_TIMEOUT,
680                                    connection.send(WebSocketMessage::Text(warmup_payload.into())),
681                                )
682                                .await
683                                .unwrap_or(Err(
684                                    tokio_tungstenite::tungstenite::Error::ConnectionClosed,
685                                ))
686                            } else {
687                                Err(tokio_tungstenite::tungstenite::Error::ConnectionClosed)
688                            };
689
690                            if let Err(error) = warmup_send_result {
691                                log::warn!(
692                                    "OpenAI Codex websocket warmup send failed on attempt {}: {error}",
693                                    attempt + 1,
694                                );
695                                reset_websocket_connection(&mut websocket_session);
696                                if attempt == 1 {
697                                    websocket_session.websocket_disabled = true;
698                                    mark_websocket_transport_unhealthy();
699                                }
700                                continue;
701                            }
702
703                            let mut warmup_response_id: Option<String> = None;
704                            let mut warmup_response_items = Vec::new();
705
706                            loop {
707                                let message_result = if let Some(connection) =
708                                    websocket_session.connection.as_mut()
709                                {
710                                    timeout(WEBSOCKET_IO_TIMEOUT, connection.next()).await.unwrap_or_else(|_| {
711                                        log::warn!("OpenAI Codex websocket warmup read timed out");
712                                        None
713                                    })
714                                } else {
715                                    None
716                                };
717                                let Some(message_result) = message_result else {
718                                    log::warn!(
719                                        "OpenAI Codex websocket warmup closed before completion on attempt {}",
720                                        attempt + 1,
721                                    );
722                                    reset_websocket_connection(&mut websocket_session);
723                                    if attempt == 1 {
724                                        websocket_session.websocket_disabled = true;
725                                        mark_websocket_transport_unhealthy();
726                                    }
727                                    continue 'websocket_attempts;
728                                };
729
730                                let message = match message_result {
731                                    Ok(message) => message,
732                                    Err(error) => {
733                                        log::warn!(
734                                            "OpenAI Codex websocket warmup failed on attempt {}: {error}",
735                                            attempt + 1,
736                                        );
737                                        reset_websocket_connection(&mut websocket_session);
738                                        if attempt == 1 {
739                                            websocket_session.websocket_disabled = true;
740                                            mark_websocket_transport_unhealthy();
741                                        }
742                                        continue 'websocket_attempts;
743                                    }
744                                };
745
746                                match message {
747                                    WebSocketMessage::Text(text) => {
748                                        if let Some((status, message)) =
749                                            parse_wrapped_websocket_error_event(&text)
750                                        {
751                                            log::warn!(
752                                                "OpenAI Codex websocket warmup wrapped error on attempt {} status={} message={message}",
753                                                attempt + 1,
754                                                status,
755                                            );
756                                            if status == StatusCode::UNAUTHORIZED
757                                                || status == StatusCode::UPGRADE_REQUIRED
758                                                || status.is_client_error()
759                                            {
760                                                websocket_session.websocket_disabled = true;
761                                            }
762                                            reset_websocket_connection(&mut websocket_session);
763                                            continue 'websocket_attempts;
764                                        }
765                                        if let Ok(event) =
766                                            serde_json::from_str::<ApiStreamEvent>(&text)
767                                        {
768                                            match event.r#type.as_str() {
769                                                "response.output_item.added" => {
770                                                    if let Some(item) = event.item
771                                                        && let Ok(item) =
772                                                            serde_json::from_value::<ApiOutputItem>(item)
773                                                        && let Some(item) =
774                                                            output_item_to_input_item(item)
775                                                    {
776                                                        warmup_response_items.push(item);
777                                                    }
778                                                }
779                                                "response.completed" | "response.done" => {
780                                                    if let Some(resp) = event.response
781                                                        && let Some(id) = resp.id
782                                                    {
783                                                        warmup_response_id = Some(id);
784                                                    }
785                                                    websocket_session.last_request =
786                                                        Some(api_request.clone());
787                                                    websocket_session.last_response_id =
788                                                        warmup_response_id;
789                                                    websocket_session.last_response_items =
790                                                        warmup_response_items;
791                                                    websocket_session.prewarmed = true;
792                                                    break;
793                                                }
794                                                "response.incomplete" | "response.failed" => {
795                                                    log::warn!(
796                                                        "OpenAI Codex websocket warmup returned {} on attempt {}",
797                                                        event.r#type,
798                                                        attempt + 1,
799                                                    );
800                                                    reset_websocket_connection(&mut websocket_session);
801                                                    if attempt == 1 {
802                                                        websocket_session.websocket_disabled = true;
803                                                        mark_websocket_transport_unhealthy();
804                                                    }
805                                                    continue 'websocket_attempts;
806                                                }
807                                                _ => {}
808                                            }
809                                        }
810                                    }
811                                    WebSocketMessage::Binary(bytes) => {
812                                        if let Ok(text) = String::from_utf8(bytes.to_vec()) {
813                                            if let Some((status, message)) =
814                                                parse_wrapped_websocket_error_event(&text)
815                                            {
816                                                log::warn!(
817                                                    "OpenAI Codex websocket warmup wrapped error on attempt {} status={} message={message}",
818                                                    attempt + 1,
819                                                    status,
820                                                );
821                                                if status == StatusCode::UNAUTHORIZED
822                                                    || status == StatusCode::UPGRADE_REQUIRED
823                                                    || status.is_client_error()
824                                                {
825                                                    websocket_session.websocket_disabled = true;
826                                                }
827                                                reset_websocket_connection(&mut websocket_session);
828                                                continue 'websocket_attempts;
829                                            }
830
831                                            if let Ok(event) =
832                                                serde_json::from_str::<ApiStreamEvent>(&text)
833                                            {
834                                                match event.r#type.as_str() {
835                                                    "response.output_item.added" => {
836                                                        if let Some(item) = event.item
837                                                            && let Ok(item) = serde_json::from_value::<
838                                                                ApiOutputItem,
839                                                            >(item)
840                                                            && let Some(item) =
841                                                                output_item_to_input_item(item)
842                                                        {
843                                                            warmup_response_items.push(item);
844                                                        }
845                                                    }
846                                                    "response.completed" | "response.done" => {
847                                                        if let Some(resp) = event.response
848                                                            && let Some(id) = resp.id
849                                                        {
850                                                            warmup_response_id = Some(id);
851                                                        }
852                                                        websocket_session.last_request =
853                                                            Some(api_request.clone());
854                                                        websocket_session.last_response_id =
855                                                            warmup_response_id;
856                                                        websocket_session.last_response_items =
857                                                            warmup_response_items;
858                                                        websocket_session.prewarmed = true;
859                                                        break;
860                                                    }
861                                                    "response.incomplete" | "response.failed" => {
862                                                        log::warn!(
863                                                            "OpenAI Codex websocket warmup returned {} on attempt {}",
864                                                            event.r#type,
865                                                            attempt + 1,
866                                                        );
867                                                        reset_websocket_connection(&mut websocket_session);
868                                                        if attempt == 1 {
869                                                            websocket_session.websocket_disabled = true;
870                                                            mark_websocket_transport_unhealthy();
871                                                        }
872                                                        continue 'websocket_attempts;
873                                                    }
874                                                    _ => {}
875                                                }
876                                            }
877                                        }
878                                    }
879                                    WebSocketMessage::Ping(payload) => {
880                                        if let Some(connection) =
881                                            websocket_session.connection.as_mut()
882                                            && let Err(error) = connection
883                                                .send(WebSocketMessage::Pong(payload))
884                                                .await
885                                        {
886                                            log::warn!(
887                                                "OpenAI Codex websocket warmup pong failed on attempt {}: {error}",
888                                                attempt + 1,
889                                            );
890                                            reset_websocket_connection(&mut websocket_session);
891                                            if attempt == 1 {
892                                                websocket_session.websocket_disabled = true;
893                                                mark_websocket_transport_unhealthy();
894                                            }
895                                            continue 'websocket_attempts;
896                                        }
897                                    }
898                                    WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {}
899                                    WebSocketMessage::Close(_) => {
900                                        log::warn!(
901                                            "OpenAI Codex websocket warmup closed on attempt {}",
902                                            attempt + 1,
903                                        );
904                                        reset_websocket_connection(&mut websocket_session);
905                                        if attempt == 1 {
906                                            websocket_session.websocket_disabled = true;
907                                            mark_websocket_transport_unhealthy();
908                                        }
909                                        continue 'websocket_attempts;
910                                    }
911                                }
912                            }
913                        }
914
915                        let websocket_request = prepare_websocket_request(
916                            &api_request,
917                            &websocket_session,
918                            websocket_session.prewarmed,
919                        );
920                        let request_payload = match serde_json::to_string(&websocket_request) {
921                            Ok(payload) => payload,
922                            Err(error) => {
923                                yield Ok(StreamDelta::Error {
924                                    message: format!(
925                                        "failed to encode websocket request: {error}"
926                                    ),
927                                    kind: StreamErrorKind::InvalidRequest,
928                                });
929                                return;
930                            }
931                        };
932
933                        let send_result = if let Some(connection) = websocket_session.connection.as_mut() {
934                            timeout(
935                                WEBSOCKET_IO_TIMEOUT,
936                                connection.send(WebSocketMessage::Text(request_payload.into())),
937                            )
938                            .await
939                            .unwrap_or(Err(
940                                tokio_tungstenite::tungstenite::Error::ConnectionClosed,
941                            ))
942                        } else {
943                            Err(tokio_tungstenite::tungstenite::Error::ConnectionClosed)
944                        };
945
946                        if let Err(error) = send_result {
947                            log::warn!(
948                                "OpenAI Codex websocket send failed on attempt {}: {error}",
949                                attempt + 1,
950                            );
951                            reset_websocket_connection(&mut websocket_session);
952                            if attempt == 1 {
953                                websocket_session.websocket_disabled = true;
954                                mark_websocket_transport_unhealthy();
955                            }
956                            continue;
957                        }
958
959                        let mut usage: Option<Usage> = None;
960                        let mut tool_calls: HashMap<String, ToolCallAccumulator> = HashMap::new();
961                        let mut response_id: Option<String> = None;
962                        let mut response_items = Vec::new();
963                        let mut emitted_output = false;
964
965                        loop {
966                            let message_result = if let Some(connection) =
967                                websocket_session.connection.as_mut()
968                            {
969                                timeout(WEBSOCKET_IO_TIMEOUT, connection.next()).await.unwrap_or_else(|_| {
970                                    log::warn!("OpenAI Codex websocket read timed out");
971                                    None
972                                })
973                            } else {
974                                None
975                            };
976                            let Some(message_result) = message_result else {
977                                if emitted_output {
978                                    reset_websocket_connection(&mut websocket_session);
979                                    yield Ok(StreamDelta::Error {
980                                        message: "websocket closed before response.completed"
981                                            .to_string(),
982                                        kind: StreamErrorKind::ServerError,
983                                    });
984                                    return;
985                                }
986                                reset_websocket_connection(&mut websocket_session);
987                                if attempt == 1 {
988                                    websocket_session.websocket_disabled = true;
989                                    mark_websocket_transport_unhealthy();
990                                }
991                                continue 'websocket_attempts;
992                            };
993
994                            let message = match message_result {
995                                Ok(message) => message,
996                                Err(error) => {
997                                    if emitted_output {
998                                        reset_websocket_connection(&mut websocket_session);
999                                        yield Ok(StreamDelta::Error {
1000                                            message: format!("websocket error: {error}"),
1001                                            kind: StreamErrorKind::ServerError,
1002                                        });
1003                                        return;
1004                                    }
1005                                    reset_websocket_connection(&mut websocket_session);
1006                                    if attempt == 1 {
1007                                        websocket_session.websocket_disabled = true;
1008                                        mark_websocket_transport_unhealthy();
1009                                    }
1010                                    continue 'websocket_attempts;
1011                                }
1012                            };
1013
1014                            match message {
1015                                WebSocketMessage::Text(text) => {
1016                                    if let Some((status, message)) =
1017                                        parse_wrapped_websocket_error_event(&text)
1018                                    {
1019                                        let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1020                                            StreamErrorKind::RateLimited
1021                                        } else if status.is_server_error() {
1022                                            StreamErrorKind::ServerError
1023                                        } else {
1024                                            StreamErrorKind::InvalidRequest
1025                                        };
1026                                        if emitted_output {
1027                                            reset_websocket_connection(&mut websocket_session);
1028                                            yield Ok(StreamDelta::Error {
1029                                                message,
1030                                                kind,
1031                                            });
1032                                            return;
1033                                        }
1034                                        if status == StatusCode::UNAUTHORIZED
1035                                            || status == StatusCode::UPGRADE_REQUIRED
1036                                            || status.is_client_error()
1037                                        {
1038                                            websocket_session.websocket_disabled = true;
1039                                        }
1040                                        reset_websocket_connection(&mut websocket_session);
1041                                        continue 'websocket_attempts;
1042                                    }
1043                                    if let Ok(event) = serde_json::from_str::<ApiStreamEvent>(&text) {
1044                                        match event.r#type.as_str() {
1045                                            "response.output_text.delta" => {
1046                                                if let Some(delta) = event.delta {
1047                                                    emitted_output = true;
1048                                                    yield Ok(StreamDelta::TextDelta {
1049                                                        delta,
1050                                                        block_index: 0,
1051                                                    });
1052                                                }
1053                                            }
1054                                            "response.function_call_arguments.delta" => {
1055                                                if let (Some(call_id), Some(delta)) =
1056                                                    (event.call_id, event.delta)
1057                                                {
1058                                                    let order = tool_calls.len();
1059                                                    let acc = tool_calls
1060                                                        .entry(call_id.clone())
1061                                                        .or_insert_with(|| ToolCallAccumulator {
1062                                                            id: call_id,
1063                                                            name: event.name.unwrap_or_default(),
1064                                                            arguments: String::new(),
1065                                                            order,
1066                                                        });
1067                                                    acc.arguments.push_str(&delta);
1068                                                }
1069                                            }
1070                                            "response.output_item.added" => {
1071                                                if let Some(item) = event.item
1072                                                    && let Ok(item) =
1073                                                        serde_json::from_value::<ApiOutputItem>(item)
1074                                                    && let Some(item) = output_item_to_input_item(item)
1075                                                {
1076                                                    response_items.push(item);
1077                                                }
1078                                            }
1079                                            "response.completed"
1080                                            | "response.incomplete"
1081                                            | "response.done" => {
1082                                                if let Some(resp) = event.response {
1083                                                    if let Some(u) = resp.usage {
1084                                                        usage = Some(usage_from_api_usage(&u));
1085                                                    }
1086                                                    if let Some(id) = resp.id {
1087                                                        response_id = Some(id);
1088                                                    }
1089                                                }
1090                                                let final_status = Some(match event.r#type.as_str() {
1091                                                    "response.incomplete" => ApiStatus::Incomplete,
1092                                                    _ => ApiStatus::Completed,
1093                                                });
1094                                                for delta in emit_accumulated_tool_calls(&tool_calls) {
1095                                                    yield Ok(delta);
1096                                                }
1097                                                if let Some(u) = usage.take() {
1098                                                    yield Ok(StreamDelta::Usage(u));
1099                                                }
1100                                                websocket_session.last_request = Some(api_request.clone());
1101                                                websocket_session.last_response_id = response_id;
1102                                                websocket_session.last_response_items = response_items;
1103                                                websocket_session.prewarmed = false;
1104                                                // Clean completion: the turn is no
1105                                                // longer in flight, so the next turn
1106                                                // may reuse this connection/baseline.
1107                                                websocket_session.in_flight = false;
1108                                                yield Ok(StreamDelta::Done {
1109                                                    stop_reason: Some(stop_reason_from_stream_state(
1110                                                        &tool_calls,
1111                                                        final_status,
1112                                                    )),
1113                                                });
1114                                                return;
1115                                            }
1116                                            "response.failed" => {
1117                                                websocket_session.last_request = None;
1118                                                websocket_session.last_response_id = None;
1119                                                websocket_session.last_response_items.clear();
1120                                                websocket_session.prewarmed = false;
1121                                                let message = event
1122                                                    .response
1123                                                    .and_then(|resp| resp.error)
1124                                                    .and_then(|error| error.message)
1125                                                    .unwrap_or_else(|| {
1126                                                        "Codex response failed".to_string()
1127                                                    });
1128                                                yield Ok(StreamDelta::Error {
1129                                                    message,
1130                                                    kind: StreamErrorKind::ServerError,
1131                                                });
1132                                                return;
1133                                            }
1134                                            _ => {}
1135                                        }
1136                                    }
1137                                }
1138                                WebSocketMessage::Binary(bytes) => {
1139                                    if let Ok(text) = String::from_utf8(bytes.to_vec()) {
1140                                        if let Some((status, message)) =
1141                                            parse_wrapped_websocket_error_event(&text)
1142                                        {
1143                                            let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1144                                                StreamErrorKind::RateLimited
1145                                            } else if status.is_server_error() {
1146                                                StreamErrorKind::ServerError
1147                                            } else {
1148                                                StreamErrorKind::InvalidRequest
1149                                            };
1150                                            if emitted_output {
1151                                                reset_websocket_connection(&mut websocket_session);
1152                                                yield Ok(StreamDelta::Error {
1153                                                    message,
1154                                                    kind,
1155                                                });
1156                                                return;
1157                                            }
1158                                            if status == StatusCode::UNAUTHORIZED
1159                                                || status == StatusCode::UPGRADE_REQUIRED
1160                                                || status.is_client_error()
1161                                            {
1162                                                websocket_session.websocket_disabled = true;
1163                                            }
1164                                            reset_websocket_connection(&mut websocket_session);
1165                                            continue 'websocket_attempts;
1166                                        }
1167
1168                                        if let Ok(event) =
1169                                            serde_json::from_str::<ApiStreamEvent>(&text)
1170                                        {
1171                                            match event.r#type.as_str() {
1172                                                "response.output_text.delta" => {
1173                                                    if let Some(delta) = event.delta {
1174                                                        emitted_output = true;
1175                                                        yield Ok(StreamDelta::TextDelta {
1176                                                            delta,
1177                                                            block_index: 0,
1178                                                        });
1179                                                    }
1180                                                }
1181                                                "response.function_call_arguments.delta" => {
1182                                                    if let (Some(call_id), Some(delta)) =
1183                                                        (event.call_id, event.delta)
1184                                                    {
1185                                                        let order = tool_calls.len();
1186                                                        let acc = tool_calls
1187                                                            .entry(call_id.clone())
1188                                                            .or_insert_with(|| ToolCallAccumulator {
1189                                                                id: call_id,
1190                                                                name: event.name.unwrap_or_default(),
1191                                                                arguments: String::new(),
1192                                                                order,
1193                                                            });
1194                                                        acc.arguments.push_str(&delta);
1195                                                    }
1196                                                }
1197                                                "response.output_item.added" => {
1198                                                    if let Some(item) = event.item
1199                                                        && let Ok(item) = serde_json::from_value::<
1200                                                            ApiOutputItem,
1201                                                        >(item)
1202                                                        && let Some(item) =
1203                                                            output_item_to_input_item(item)
1204                                                    {
1205                                                        response_items.push(item);
1206                                                    }
1207                                                }
1208                                                "response.completed"
1209                                                | "response.incomplete"
1210                                                | "response.done" => {
1211                                                    if let Some(resp) = event.response {
1212                                                        if let Some(u) = resp.usage {
1213                                                            usage = Some(usage_from_api_usage(&u));
1214                                                        }
1215                                                        if let Some(id) = resp.id {
1216                                                            response_id = Some(id);
1217                                                        }
1218                                                    }
1219                                                    let final_status = Some(
1220                                                        match event.r#type.as_str() {
1221                                                            "response.incomplete" => ApiStatus::Incomplete,
1222                                                            _ => ApiStatus::Completed,
1223                                                        },
1224                                                    );
1225                                                    for delta in
1226                                                        emit_accumulated_tool_calls(&tool_calls)
1227                                                    {
1228                                                        yield Ok(delta);
1229                                                    }
1230                                                    if let Some(u) = usage.take() {
1231                                                        yield Ok(StreamDelta::Usage(u));
1232                                                    }
1233                                                    websocket_session.last_request =
1234                                                        Some(api_request.clone());
1235                                                    websocket_session.last_response_id = response_id;
1236                                                    websocket_session.last_response_items =
1237                                                        response_items;
1238                                                    websocket_session.prewarmed = false;
1239                                                    // Clean completion: the turn is no
1240                                                    // longer in flight, so the next
1241                                                    // turn may reuse the connection.
1242                                                    websocket_session.in_flight = false;
1243                                                    yield Ok(StreamDelta::Done {
1244                                                        stop_reason: Some(
1245                                                            stop_reason_from_stream_state(
1246                                                                &tool_calls,
1247                                                                final_status,
1248                                                            ),
1249                                                        ),
1250                                                    });
1251                                                    return;
1252                                                }
1253                                                "response.failed" => {
1254                                                    websocket_session.last_request = None;
1255                                                    websocket_session.last_response_id = None;
1256                                                    websocket_session.last_response_items.clear();
1257                                                    websocket_session.prewarmed = false;
1258                                                    let message = event
1259                                                        .response
1260                                                        .and_then(|resp| resp.error)
1261                                                        .and_then(|error| error.message)
1262                                                        .unwrap_or_else(|| {
1263                                                            "Codex response failed".to_string()
1264                                                        });
1265                                                    yield Ok(StreamDelta::Error {
1266                                                        message,
1267                                                        kind: StreamErrorKind::ServerError,
1268                                                    });
1269                                                    return;
1270                                                }
1271                                                _ => {}
1272                                            }
1273                                        }
1274                                    }
1275                                }
1276                                WebSocketMessage::Ping(payload) => {
1277                                    if let Some(connection) = websocket_session.connection.as_mut()
1278                                        && let Err(error) = connection
1279                                            .send(WebSocketMessage::Pong(payload))
1280                                            .await
1281                                    {
1282                                        if emitted_output {
1283                                            reset_websocket_connection(&mut websocket_session);
1284                                            yield Ok(StreamDelta::Error {
1285                                                message: format!("websocket pong failed: {error}"),
1286                                                kind: StreamErrorKind::ServerError,
1287                                            });
1288                                            return;
1289                                        }
1290                                        reset_websocket_connection(&mut websocket_session);
1291                                        if attempt == 1 {
1292                                            websocket_session.websocket_disabled = true;
1293                                            mark_websocket_transport_unhealthy();
1294                                        }
1295                                        continue 'websocket_attempts;
1296                                    }
1297                                }
1298                                WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {}
1299                                WebSocketMessage::Close(_) => {
1300                                    if emitted_output {
1301                                        reset_websocket_connection(&mut websocket_session);
1302                                        yield Ok(StreamDelta::Error {
1303                                            message: "websocket closed before response.completed"
1304                                                .to_string(),
1305                                            kind: StreamErrorKind::ServerError,
1306                                        });
1307                                        return;
1308                                    }
1309                                    reset_websocket_connection(&mut websocket_session);
1310                                    if attempt == 1 {
1311                                        websocket_session.websocket_disabled = true;
1312                                        mark_websocket_transport_unhealthy();
1313                                    }
1314                                    continue 'websocket_attempts;
1315                                }
1316                            }
1317                        }
1318                    }
1319                }
1320                // The websocket turn did not complete (disabled, or attempts
1321                // exhausted); the turn now falls through to the HTTP SSE path, so
1322                // no websocket turn is in flight.
1323                websocket_session.in_flight = false;
1324                sse_turn_state = websocket_session.turn_state.clone();
1325                drop(websocket_session);
1326            }
1327
1328            let headers = match self.build_headers(
1329                true,
1330                request.session_id.as_deref(),
1331                sse_turn_state.as_deref(),
1332            ) {
1333                Ok(headers) => headers,
1334                Err(error) => {
1335                    yield Ok(StreamDelta::Error {
1336                        message: error.to_string(),
1337                        kind: StreamErrorKind::InvalidRequest,
1338                    });
1339                    return;
1340                }
1341            };
1342
1343            let Ok(response) = self.client
1344                .post(codex_url(&self.base_url))
1345                .headers(headers)
1346                .json(&api_request)
1347                .send()
1348                .await
1349            else {
1350                yield Err(anyhow::anyhow!("request failed"));
1351                return;
1352            };
1353
1354            let status = response.status();
1355            if !status.is_success() {
1356                let body = response.text().await.unwrap_or_default();
1357                let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1358                    StreamErrorKind::RateLimited
1359                } else if status.is_server_error() {
1360                    StreamErrorKind::ServerError
1361                } else {
1362                    StreamErrorKind::InvalidRequest
1363                };
1364                log::warn!("OpenAI Codex error status={status} body={body}");
1365                yield Ok(StreamDelta::Error { message: body, kind });
1366                return;
1367            }
1368
1369            if let Some(session_id) = request.session_id.as_deref() {
1370                let turn_state = response
1371                    .headers()
1372                    .get(OPENAI_CODEX_TURN_STATE_HEADER)
1373                    .and_then(|value| value.to_str().ok())
1374                    .map(ToOwned::to_owned);
1375                if let Some(turn_state) = turn_state {
1376                    let session = self.websocket_session(session_id).await;
1377                    let mut websocket_session = session.lock().await;
1378                    websocket_session.turn_state = Some(turn_state);
1379                }
1380            }
1381
1382            let mut sse = SseLineBuffer::new();
1383            let mut stream = response.bytes_stream();
1384            let mut usage: Option<Usage> = None;
1385            let mut tool_calls: HashMap<String, ToolCallAccumulator> = HashMap::new();
1386            let mut final_status: Option<ApiStatus> = None;
1387
1388            while let Some(chunk_result) = stream.next().await {
1389                let Ok(chunk) = chunk_result else {
1390                    yield Err(anyhow::anyhow!("stream error"));
1391                    return;
1392                };
1393                sse.extend(&chunk);
1394
1395                while let Some(line) = sse.next_line() {
1396                    let line = line.trim();
1397                    if line.is_empty() {
1398                        continue;
1399                    }
1400
1401                    let Some(data) = line.strip_prefix("data: ") else {
1402                        continue;
1403                    };
1404
1405                    if data == "[DONE]" {
1406                        for delta in emit_accumulated_tool_calls(&tool_calls) {
1407                            yield Ok(delta);
1408                        }
1409                        if let Some(u) = usage.take() {
1410                            yield Ok(StreamDelta::Usage(u));
1411                        }
1412                        yield Ok(StreamDelta::Done {
1413                            stop_reason: Some(stop_reason_from_stream_state(&tool_calls, final_status)),
1414                        });
1415                        return;
1416                    }
1417
1418                    if let Ok(event) = serde_json::from_str::<ApiStreamEvent>(data) {
1419                        match event.r#type.as_str() {
1420                            "response.output_text.delta" => {
1421                                if let Some(delta) = event.delta {
1422                                    yield Ok(StreamDelta::TextDelta { delta, block_index: 0 });
1423                                }
1424                            }
1425                            "response.function_call_arguments.delta" => {
1426                                if let (Some(call_id), Some(delta)) = (event.call_id, event.delta) {
1427                                    let order = tool_calls.len();
1428                                    let acc = tool_calls.entry(call_id.clone()).or_insert_with(|| {
1429                                        ToolCallAccumulator {
1430                                            id: call_id,
1431                                            name: event.name.unwrap_or_default(),
1432                                            arguments: String::new(),
1433                                            order,
1434                                        }
1435                                    });
1436                                    acc.arguments.push_str(&delta);
1437                                }
1438                            }
1439                            "response.completed" | "response.incomplete" | "response.done" => {
1440                                if let Some(resp) = event.response
1441                                    && let Some(u) = resp.usage
1442                                {
1443                                    usage = Some(usage_from_api_usage(&u));
1444                                }
1445                                final_status = Some(match event.r#type.as_str() {
1446                                    "response.incomplete" => ApiStatus::Incomplete,
1447                                    _ => ApiStatus::Completed,
1448                                });
1449                            }
1450                            "response.failed" => {
1451                                let message = event
1452                                    .response
1453                                    .and_then(|resp| resp.error)
1454                                    .and_then(|error| error.message)
1455                                    .unwrap_or_else(|| "Codex response failed".to_string());
1456                                yield Ok(StreamDelta::Error {
1457                                    message,
1458                                    kind: StreamErrorKind::ServerError,
1459                                });
1460                                return;
1461                            }
1462                            _ => {}
1463                        }
1464                    }
1465                }
1466            }
1467
1468            if let Some(u) = usage {
1469                yield Ok(StreamDelta::Usage(u));
1470            }
1471            yield Ok(StreamDelta::Done {
1472                stop_reason: Some(stop_reason_from_stream_state(&tool_calls, final_status)),
1473            });
1474        })
1475    }
1476
1477    fn model(&self) -> &str {
1478        &self.model
1479    }
1480
1481    fn provider(&self) -> &'static str {
1482        "openai-codex"
1483    }
1484
1485    fn configured_thinking(&self) -> Option<&ThinkingConfig> {
1486        self.thinking.as_ref()
1487    }
1488}
1489
1490// ============================================================================
1491// Input building
1492// ============================================================================
1493
1494fn build_api_input(request: &ChatRequest) -> Vec<ApiInputItem> {
1495    let mut items = Vec::new();
1496
1497    // Convert user/assistant messages. The system prompt is sent separately as
1498    // `instructions`, matching pi's Codex transport.
1499    for msg in &request.messages {
1500        match &msg.content {
1501            Content::Text(text) => {
1502                items.push(ApiInputItem::Message(ApiMessage {
1503                    role: match msg.role {
1504                        agent_sdk_foundation::llm::Role::User => ApiRole::User,
1505                        agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
1506                    },
1507                    content: ApiMessageContent::Text(text.clone()),
1508                }));
1509            }
1510            Content::Blocks(blocks) => {
1511                let mut content_parts = Vec::new();
1512
1513                for block in blocks {
1514                    match block {
1515                        ContentBlock::Text { text } => {
1516                            let part = match msg.role {
1517                                agent_sdk_foundation::llm::Role::Assistant => {
1518                                    ApiInputContent::OutputText { text: text.clone() }
1519                                }
1520                                agent_sdk_foundation::llm::Role::User => {
1521                                    ApiInputContent::InputText { text: text.clone() }
1522                                }
1523                            };
1524                            content_parts.push(part);
1525                        }
1526                        ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {}
1527                        ContentBlock::Image { source } => {
1528                            content_parts.push(ApiInputContent::Image {
1529                                image_url: format!(
1530                                    "data:{};base64,{}",
1531                                    source.media_type, source.data
1532                                ),
1533                            });
1534                        }
1535                        ContentBlock::Document { source } => {
1536                            content_parts.push(ApiInputContent::File {
1537                                filename: suggested_filename(&source.media_type),
1538                                file_data: format!(
1539                                    "data:{};base64,{}",
1540                                    source.media_type, source.data
1541                                ),
1542                            });
1543                        }
1544                        ContentBlock::ToolUse {
1545                            id, name, input, ..
1546                        } => {
1547                            items.push(ApiInputItem::FunctionCall(ApiFunctionCall::new(
1548                                id.clone(),
1549                                name.clone(),
1550                                serde_json::to_string(input).unwrap_or_default(),
1551                            )));
1552                        }
1553                        ContentBlock::ToolResult {
1554                            tool_use_id,
1555                            content,
1556                            ..
1557                        } => {
1558                            items.push(ApiInputItem::FunctionCallOutput(
1559                                ApiFunctionCallOutput::new(tool_use_id.clone(), content.clone()),
1560                            ));
1561                        }
1562                        // `ContentBlock` is `#[non_exhaustive]`; a block kind this
1563                        // SDK version cannot represent on the wire is skipped.
1564                        _ => {
1565                            log::warn!("Skipping unrecognized OpenAI Responses content block");
1566                        }
1567                    }
1568                }
1569
1570                if !content_parts.is_empty() {
1571                    items.push(ApiInputItem::Message(ApiMessage {
1572                        role: match msg.role {
1573                            agent_sdk_foundation::llm::Role::User => ApiRole::User,
1574                            agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
1575                        },
1576                        content: ApiMessageContent::Parts(content_parts),
1577                    }));
1578                }
1579            }
1580        }
1581    }
1582
1583    items
1584}
1585
1586/// Recursively fix a JSON schema for `OpenAI` strict mode.
1587///
1588/// Adds `additionalProperties: false`, marks every property required, and — to
1589/// keep previously-optional properties from being forced to fabricated values —
1590/// wraps optional properties in `anyOf: [..., {"type": "null"}]`. This mirrors
1591/// the sibling `openai_responses` provider so a tool schema behaves identically
1592/// across both providers.
1593fn fix_schema_for_strict_mode(schema: &mut serde_json::Value) {
1594    let Some(obj) = schema.as_object_mut() else {
1595        return;
1596    };
1597
1598    // Check if this is an object type schema
1599    let is_object_type = obj
1600        .get("type")
1601        .is_some_and(|t| t.as_str() == Some("object"));
1602
1603    if is_object_type {
1604        // Add additionalProperties: false
1605        obj.insert(
1606            "additionalProperties".to_owned(),
1607            serde_json::Value::Bool(false),
1608        );
1609
1610        // Ensure properties and required exist (strict mode needs them even if empty)
1611        obj.entry("properties".to_owned())
1612            .or_insert_with(|| serde_json::json!({}));
1613        obj.entry("required".to_owned())
1614            .or_insert_with(|| serde_json::json!([]));
1615
1616        // Collect the set of originally required keys
1617        let originally_required: std::collections::HashSet<String> = obj
1618            .get("required")
1619            .and_then(|v| v.as_array())
1620            .map(|arr| {
1621                arr.iter()
1622                    .filter_map(|v| v.as_str().map(String::from))
1623                    .collect()
1624            })
1625            .unwrap_or_default();
1626
1627        // Wrap previously-optional properties in anyOf with null
1628        if let Some(serde_json::Value::Object(props)) = obj.get_mut("properties") {
1629            for (key, prop_schema) in props.iter_mut() {
1630                if !originally_required.contains(key) {
1631                    make_nullable(prop_schema);
1632                }
1633            }
1634        }
1635
1636        // Ensure all properties are marked as required
1637        if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
1638            let all_keys: Vec<serde_json::Value> = props
1639                .keys()
1640                .map(|k| serde_json::Value::String(k.clone()))
1641                .collect();
1642            obj.insert("required".to_owned(), serde_json::Value::Array(all_keys));
1643        }
1644    }
1645
1646    // Recursively process nested schemas
1647    if let Some(props) = obj.get_mut("properties")
1648        && let Some(props_obj) = props.as_object_mut()
1649    {
1650        for prop_schema in props_obj.values_mut() {
1651            fix_schema_for_strict_mode(prop_schema);
1652        }
1653    }
1654
1655    // Process array items
1656    if let Some(items) = obj.get_mut("items") {
1657        fix_schema_for_strict_mode(items);
1658    }
1659
1660    // Process anyOf/oneOf/allOf
1661    for key in ["anyOf", "oneOf", "allOf"] {
1662        if let Some(arr) = obj.get_mut(key)
1663            && let Some(arr_items) = arr.as_array_mut()
1664        {
1665            for item in arr_items {
1666                fix_schema_for_strict_mode(item);
1667            }
1668        }
1669    }
1670}
1671
1672/// Wrap a schema in `anyOf: [{original}, {"type": "null"}]` so the property
1673/// accepts its original type OR null. Appends the null variant when an `anyOf`
1674/// already exists.
1675fn make_nullable(schema: &mut serde_json::Value) {
1676    if let Some(any_of) = schema
1677        .as_object_mut()
1678        .and_then(|o| o.get_mut("anyOf"))
1679        .and_then(|v| v.as_array_mut())
1680    {
1681        let has_null = any_of
1682            .iter()
1683            .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
1684        if !has_null {
1685            any_of.push(serde_json::json!({"type": "null"}));
1686        }
1687        return;
1688    }
1689
1690    let original = schema.clone();
1691    *schema = serde_json::json!({
1692        "anyOf": [original, {"type": "null"}]
1693    });
1694}
1695
1696/// Check whether a JSON schema contains any object-typed schema without a
1697/// `properties` map (a free-form object). These are incompatible with
1698/// `OpenAI` strict mode and must disable it.
1699fn has_freeform_object(schema: &serde_json::Value) -> bool {
1700    let Some(obj) = schema.as_object() else {
1701        return false;
1702    };
1703
1704    let is_object = obj
1705        .get("type")
1706        .is_some_and(|t| t.as_str() == Some("object"));
1707
1708    if is_object && !obj.contains_key("properties") {
1709        return true;
1710    }
1711
1712    if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
1713        for prop in props.values() {
1714            if has_freeform_object(prop) {
1715                return true;
1716            }
1717        }
1718    }
1719
1720    if let Some(items) = obj.get("items")
1721        && has_freeform_object(items)
1722    {
1723        return true;
1724    }
1725
1726    for key in ["anyOf", "oneOf", "allOf"] {
1727        if let Some(arr) = obj.get(key).and_then(|v| v.as_array()) {
1728            for item in arr {
1729                if has_freeform_object(item) {
1730                    return true;
1731                }
1732            }
1733        }
1734    }
1735
1736    false
1737}
1738
1739fn convert_tool(tool: agent_sdk_foundation::llm::Tool) -> ApiTool {
1740    // Strict mode requires additionalProperties: false on all objects and every
1741    // property in required, which is incompatible with free-form object schemas
1742    // (objects with no defined properties). Detect and skip strict for those —
1743    // matching the sibling openai_responses provider.
1744    let mut schema = tool.input_schema;
1745    let use_strict = if has_freeform_object(&schema) {
1746        log::debug!(
1747            "Tool '{}' has free-form object schema — disabling strict mode",
1748            tool.name
1749        );
1750        None
1751    } else {
1752        fix_schema_for_strict_mode(&mut schema);
1753        Some(true)
1754    };
1755
1756    ApiTool {
1757        r#type: "function".to_owned(),
1758        name: tool.name,
1759        description: Some(tool.description),
1760        parameters: Some(schema),
1761        strict: use_strict,
1762    }
1763}
1764
1765fn suggested_filename(media_type: &str) -> String {
1766    match media_type {
1767        "application/pdf" => "attachment.pdf".to_string(),
1768        "image/png" => "image.png".to_string(),
1769        "image/jpeg" => "image.jpg".to_string(),
1770        "image/gif" => "image.gif".to_string(),
1771        "image/webp" => "image.webp".to_string(),
1772        _ => "attachment.bin".to_string(),
1773    }
1774}
1775
1776fn build_content_blocks(output: &[ApiOutputItem]) -> Vec<ContentBlock> {
1777    let mut blocks = Vec::new();
1778
1779    for item in output {
1780        match item {
1781            ApiOutputItem::Message { content, .. } => {
1782                for c in content {
1783                    if let ApiOutputContent::Text { text } = c
1784                        && !text.is_empty()
1785                    {
1786                        blocks.push(ContentBlock::Text { text: text.clone() });
1787                    }
1788                }
1789            }
1790            ApiOutputItem::FunctionCall {
1791                call_id,
1792                name,
1793                arguments,
1794                ..
1795            } => {
1796                let input =
1797                    serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
1798                blocks.push(ContentBlock::ToolUse {
1799                    id: call_id.clone(),
1800                    name: name.clone(),
1801                    input,
1802                    thought_signature: None,
1803                });
1804            }
1805            ApiOutputItem::Unknown => {
1806                // Skip unknown output types
1807            }
1808        }
1809    }
1810
1811    blocks
1812}
1813
1814fn build_api_reasoning(thinking: Option<&ThinkingConfig>) -> Option<ApiReasoning> {
1815    thinking
1816        .and_then(resolve_reasoning_effort)
1817        .map(|effort| ApiReasoning { effort })
1818}
1819
1820const fn resolve_reasoning_effort(config: &ThinkingConfig) -> Option<ReasoningEffort> {
1821    if let Some(effort) = config.effort {
1822        return Some(map_effort(effort));
1823    }
1824
1825    match &config.mode {
1826        ThinkingMode::Adaptive => None,
1827        ThinkingMode::Enabled { budget_tokens } => Some(map_budget_to_reasoning(*budget_tokens)),
1828    }
1829}
1830
1831const fn map_effort(effort: Effort) -> ReasoningEffort {
1832    match effort {
1833        Effort::Low => ReasoningEffort::Low,
1834        Effort::Medium => ReasoningEffort::Medium,
1835        Effort::High => ReasoningEffort::High,
1836        Effort::Max => ReasoningEffort::XHigh,
1837    }
1838}
1839
1840const fn map_reasoning_effort(effort: ReasoningEffort) -> Effort {
1841    match effort {
1842        ReasoningEffort::Low => Effort::Low,
1843        ReasoningEffort::Medium => Effort::Medium,
1844        ReasoningEffort::High => Effort::High,
1845        ReasoningEffort::XHigh => Effort::Max,
1846    }
1847}
1848
1849const fn map_budget_to_reasoning(budget_tokens: u32) -> ReasoningEffort {
1850    if budget_tokens <= 4_096 {
1851        ReasoningEffort::Low
1852    } else if budget_tokens <= 16_384 {
1853        ReasoningEffort::Medium
1854    } else if budget_tokens <= 32_768 {
1855        ReasoningEffort::High
1856    } else {
1857        ReasoningEffort::XHigh
1858    }
1859}
1860
1861fn codex_url(base_url: &str) -> String {
1862    let normalized = base_url.trim_end_matches('/');
1863    if normalized.ends_with("/codex/responses") {
1864        normalized.to_string()
1865    } else if normalized.ends_with("/codex") {
1866        format!("{normalized}/responses")
1867    } else {
1868        format!("{normalized}/codex/responses")
1869    }
1870}
1871
1872fn codex_websocket_url(base_url: &str) -> Result<url::Url> {
1873    let mut url = url::Url::parse(&codex_url(base_url))
1874        .context("failed to parse OpenAI Codex websocket URL")?;
1875
1876    let scheme = match url.scheme() {
1877        "http" => Some("ws"),
1878        "https" => Some("wss"),
1879        _ => None,
1880    };
1881
1882    if let Some(scheme) = scheme {
1883        let _ = url.set_scheme(scheme);
1884    }
1885
1886    Ok(url)
1887}
1888
1889fn extract_account_id(token: &str) -> Result<String> {
1890    let payload = token
1891        .split('.')
1892        .nth(1)
1893        .ok_or_else(|| anyhow::anyhow!("invalid OpenAI Codex OAuth token"))?;
1894    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
1895        .decode(payload)
1896        .context("failed to decode OpenAI Codex token payload")?;
1897    let payload: serde_json::Value =
1898        serde_json::from_slice(&decoded).context("failed to parse OpenAI Codex token payload")?;
1899    payload
1900        .get(OPENAI_CODEX_JWT_CLAIM_PATH)
1901        .and_then(|value| value.get("chatgpt_account_id"))
1902        .and_then(serde_json::Value::as_str)
1903        .map(ToOwned::to_owned)
1904        .ok_or_else(|| anyhow::anyhow!("chatgpt_account_id missing from OpenAI Codex token"))
1905}
1906
1907fn is_empty(value: &str) -> bool {
1908    value.trim().is_empty()
1909}
1910
1911// ============================================================================
1912// Streaming helpers
1913// ============================================================================
1914
1915struct ToolCallAccumulator {
1916    id: String,
1917    name: String,
1918    arguments: String,
1919    /// Registration order, used to assign deterministic, distinct block indices
1920    /// when emitting (`HashMap` iteration order is otherwise nondeterministic).
1921    order: usize,
1922}
1923
1924fn usage_from_api_usage(usage: &ApiUsage) -> Usage {
1925    Usage {
1926        input_tokens: usage.input_tokens,
1927        output_tokens: usage.output_tokens,
1928        cached_input_tokens: usage
1929            .input_tokens_details
1930            .as_ref()
1931            .map_or(0, |details| details.cached_tokens),
1932        cache_creation_input_tokens: 0,
1933    }
1934}
1935
1936fn emit_accumulated_tool_calls(
1937    tool_calls: &HashMap<String, ToolCallAccumulator>,
1938) -> Vec<StreamDelta> {
1939    // Assign distinct, monotonically increasing block indices in registration
1940    // order. The previous code gave every call the same index (1) and iterated
1941    // HashMap::values(), so StreamAccumulator's stable sort preserved
1942    // nondeterministic insertion order for multi-tool turns.
1943    let mut accs: Vec<&ToolCallAccumulator> = tool_calls.values().collect();
1944    accs.sort_by_key(|acc| acc.order);
1945
1946    let mut deltas = Vec::with_capacity(accs.len() * 2);
1947    for (idx, acc) in accs.iter().enumerate() {
1948        let block_index = idx + 1;
1949        deltas.push(StreamDelta::ToolUseStart {
1950            id: acc.id.clone(),
1951            name: acc.name.clone(),
1952            block_index,
1953            thought_signature: None,
1954        });
1955        deltas.push(StreamDelta::ToolInputDelta {
1956            id: acc.id.clone(),
1957            delta: acc.arguments.clone(),
1958            block_index,
1959        });
1960    }
1961    deltas
1962}
1963
1964fn stop_reason_from_stream_state(
1965    tool_calls: &HashMap<String, ToolCallAccumulator>,
1966    status: Option<ApiStatus>,
1967) -> StopReason {
1968    if tool_calls.is_empty() {
1969        match status.unwrap_or(ApiStatus::Completed) {
1970            ApiStatus::Completed => StopReason::EndTurn,
1971            ApiStatus::Incomplete => StopReason::MaxTokens,
1972            ApiStatus::Failed => StopReason::StopSequence,
1973        }
1974    } else {
1975        StopReason::ToolUse
1976    }
1977}
1978
1979fn reset_websocket_connection(session: &mut WebsocketSessionState) {
1980    session.connection = None;
1981    if session.prewarmed {
1982        session.last_request = None;
1983        session.last_response_id = None;
1984        session.last_response_items.clear();
1985    }
1986    session.prewarmed = false;
1987}
1988
1989/// Bound the websocket-session map by evicting idle (not in-flight) sessions,
1990/// oldest-first by last use. The map exists for cross-turn reuse, so completed
1991/// sessions retain a cached baseline indefinitely; without eviction a host
1992/// serving many distinct sessions would leak memory and open sockets without
1993/// bound. Sessions whose lock is currently held (in use) or that are mid-turn
1994/// are retained.
1995fn evict_idle_sessions(sessions: &mut HashMap<String, Arc<Mutex<WebsocketSessionState>>>) {
1996    let mut candidates: Vec<(String, Option<Instant>)> = Vec::new();
1997    for (key, state) in sessions.iter() {
1998        if let Ok(guard) = state.try_lock()
1999            && !guard.in_flight
2000        {
2001            candidates.push((key.clone(), guard.last_used));
2002        }
2003    }
2004    // Oldest first; `None` (never used) sorts before `Some`.
2005    candidates.sort_by_key(|a| a.1);
2006    let evict_count = candidates.len().min(sessions.len() / 2 + 1);
2007    for (key, _) in candidates.into_iter().take(evict_count) {
2008        sessions.remove(&key);
2009    }
2010}
2011
2012fn parse_wrapped_websocket_error_event(payload: &str) -> Option<(StatusCode, String)> {
2013    let event: ApiWrappedWebsocketErrorEvent = serde_json::from_str(payload).ok()?;
2014    if event.kind != "error" {
2015        return None;
2016    }
2017
2018    if event.error.as_ref().and_then(|error| error.code.as_deref())
2019        == Some(OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE)
2020    {
2021        let message = event
2022            .error
2023            .and_then(|error| error.message)
2024            .unwrap_or_else(|| "Responses websocket connection limit reached".to_string());
2025        return Some((StatusCode::TOO_MANY_REQUESTS, message));
2026    }
2027
2028    let status = StatusCode::from_u16(event.status?).ok()?;
2029    let message = event
2030        .error
2031        .and_then(|error| error.message)
2032        .unwrap_or_else(|| payload.to_string());
2033    if status.is_success() {
2034        None
2035    } else {
2036        Some((status, message))
2037    }
2038}
2039
2040fn output_item_to_input_item(item: ApiOutputItem) -> Option<ApiInputItem> {
2041    match item {
2042        ApiOutputItem::Message { content, .. } => {
2043            let parts: Vec<ApiInputContent> = content
2044                .into_iter()
2045                .filter_map(|content| match content {
2046                    ApiOutputContent::Text { text } if !text.is_empty() => {
2047                        Some(ApiInputContent::OutputText { text })
2048                    }
2049                    ApiOutputContent::Unknown | ApiOutputContent::Text { .. } => None,
2050                })
2051                .collect();
2052            if parts.is_empty() {
2053                None
2054            } else {
2055                Some(ApiInputItem::Message(ApiMessage {
2056                    role: ApiRole::Assistant,
2057                    content: ApiMessageContent::Parts(parts),
2058                }))
2059            }
2060        }
2061        ApiOutputItem::FunctionCall {
2062            call_id,
2063            name,
2064            arguments,
2065        } => Some(ApiInputItem::FunctionCall(ApiFunctionCall::new(
2066            call_id, name, arguments,
2067        ))),
2068        ApiOutputItem::Unknown => None,
2069    }
2070}
2071
2072fn prepare_websocket_request(
2073    request: &ApiStreamingRequest,
2074    session: &WebsocketSessionState,
2075    allow_empty_delta: bool,
2076) -> ApiWebsocketRequest {
2077    let mut websocket_request = ApiWebsocketRequest::from(request);
2078
2079    let Some(last_request) = session.last_request.as_ref() else {
2080        return websocket_request;
2081    };
2082    let Some(last_response_id) = session.last_response_id.as_ref() else {
2083        return websocket_request;
2084    };
2085
2086    let mut previous_without_input = last_request.clone();
2087    previous_without_input.input.clear();
2088    let mut current_without_input = request.clone();
2089    current_without_input.input.clear();
2090    if previous_without_input != current_without_input {
2091        return websocket_request;
2092    }
2093
2094    let mut baseline = last_request.input.clone();
2095    baseline.extend(session.last_response_items.clone());
2096    if request.input.starts_with(&baseline)
2097        && (allow_empty_delta || baseline.len() < request.input.len())
2098    {
2099        websocket_request.previous_response_id = Some(last_response_id.clone());
2100        websocket_request.input = request.input[baseline.len()..].to_vec();
2101    }
2102
2103    websocket_request
2104}
2105
2106// ============================================================================
2107// API Request Types
2108// ============================================================================
2109
2110#[derive(Serialize)]
2111struct ApiResponsesRequest<'a> {
2112    model: &'a str,
2113    #[serde(skip_serializing_if = "is_empty")]
2114    instructions: &'a str,
2115    input: &'a [ApiInputItem],
2116    #[serde(skip_serializing_if = "Option::is_none")]
2117    tools: Option<&'a [ApiTool]>,
2118    #[serde(skip_serializing_if = "Option::is_none")]
2119    max_output_tokens: Option<u32>,
2120    #[serde(skip_serializing_if = "Option::is_none")]
2121    reasoning: Option<ApiReasoning>,
2122    #[serde(skip_serializing_if = "Option::is_none")]
2123    tool_choice: Option<ApiToolChoice>,
2124    #[serde(skip_serializing_if = "Option::is_none")]
2125    parallel_tool_calls: Option<bool>,
2126    store: bool,
2127    #[serde(skip_serializing_if = "Option::is_none")]
2128    text: Option<ApiTextSettings>,
2129    #[serde(skip_serializing_if = "Option::is_none")]
2130    include: Option<&'a [&'static str]>,
2131    #[serde(skip_serializing_if = "Option::is_none")]
2132    prompt_cache_key: Option<&'a str>,
2133}
2134
2135#[derive(Clone, PartialEq, Serialize)]
2136struct ApiStreamingRequest {
2137    model: String,
2138    #[serde(skip_serializing_if = "String::is_empty")]
2139    instructions: String,
2140    input: Vec<ApiInputItem>,
2141    #[serde(skip_serializing_if = "Option::is_none")]
2142    tools: Option<Vec<ApiTool>>,
2143    #[serde(skip_serializing_if = "Option::is_none")]
2144    max_output_tokens: Option<u32>,
2145    #[serde(skip_serializing_if = "Option::is_none")]
2146    reasoning: Option<ApiReasoning>,
2147    #[serde(skip_serializing_if = "Option::is_none")]
2148    tool_choice: Option<ApiToolChoice>,
2149    #[serde(skip_serializing_if = "Option::is_none")]
2150    parallel_tool_calls: Option<bool>,
2151    store: bool,
2152    #[serde(skip_serializing_if = "Option::is_none")]
2153    text: Option<ApiTextSettings>,
2154    #[serde(skip_serializing_if = "Option::is_none")]
2155    include: Option<Vec<String>>,
2156    #[serde(skip_serializing_if = "Option::is_none")]
2157    prompt_cache_key: Option<String>,
2158    stream: bool,
2159}
2160
2161#[derive(Clone, Serialize)]
2162struct ApiWebsocketRequest {
2163    #[serde(rename = "type")]
2164    kind: &'static str,
2165    model: String,
2166    #[serde(skip_serializing_if = "String::is_empty")]
2167    instructions: String,
2168    #[serde(skip_serializing_if = "Option::is_none")]
2169    previous_response_id: Option<String>,
2170    input: Vec<ApiInputItem>,
2171    #[serde(skip_serializing_if = "Option::is_none")]
2172    tools: Option<Vec<ApiTool>>,
2173    #[serde(skip_serializing_if = "Option::is_none")]
2174    max_output_tokens: Option<u32>,
2175    #[serde(skip_serializing_if = "Option::is_none")]
2176    reasoning: Option<ApiReasoning>,
2177    #[serde(skip_serializing_if = "Option::is_none")]
2178    tool_choice: Option<ApiToolChoice>,
2179    #[serde(skip_serializing_if = "Option::is_none")]
2180    parallel_tool_calls: Option<bool>,
2181    store: bool,
2182    #[serde(skip_serializing_if = "Option::is_none")]
2183    text: Option<ApiTextSettings>,
2184    #[serde(skip_serializing_if = "Option::is_none")]
2185    include: Option<Vec<String>>,
2186    #[serde(skip_serializing_if = "Option::is_none")]
2187    prompt_cache_key: Option<String>,
2188    stream: bool,
2189    #[serde(skip_serializing_if = "Option::is_none")]
2190    generate: Option<bool>,
2191}
2192
2193impl From<&ApiStreamingRequest> for ApiWebsocketRequest {
2194    fn from(request: &ApiStreamingRequest) -> Self {
2195        Self {
2196            kind: "response.create",
2197            model: request.model.clone(),
2198            instructions: request.instructions.clone(),
2199            previous_response_id: None,
2200            input: request.input.clone(),
2201            tools: request.tools.clone(),
2202            max_output_tokens: request.max_output_tokens,
2203            reasoning: request.reasoning.clone(),
2204            tool_choice: request.tool_choice.clone(),
2205            parallel_tool_calls: request.parallel_tool_calls,
2206            store: request.store,
2207            text: request.text.clone(),
2208            include: request.include.clone(),
2209            prompt_cache_key: request.prompt_cache_key.clone(),
2210            stream: request.stream,
2211            generate: None,
2212        }
2213    }
2214}
2215
2216#[derive(Clone, PartialEq, Serialize)]
2217struct ApiTextSettings {
2218    verbosity: &'static str,
2219    /// Structured-output schema (`text.format`), set when the request carries a
2220    /// `response_format`.
2221    #[serde(skip_serializing_if = "Option::is_none")]
2222    format: Option<ApiResponseTextFormat>,
2223}
2224
2225#[derive(Clone, PartialEq, Serialize)]
2226struct ApiResponseTextFormat {
2227    #[serde(rename = "type")]
2228    format_type: &'static str,
2229    name: String,
2230    schema: serde_json::Value,
2231    strict: bool,
2232}
2233
2234impl From<&ResponseFormat> for ApiResponseTextFormat {
2235    fn from(rf: &ResponseFormat) -> Self {
2236        Self {
2237            format_type: "json_schema",
2238            name: rf.name.clone(),
2239            schema: rf.schema.clone(),
2240            strict: rf.strict,
2241        }
2242    }
2243}
2244
2245/// Responses API `tool_choice` wire format.
2246///
2247/// - `"auto"` — model decides (the Codex default).
2248/// - `{"type": "function", "name": "<name>"}` — force a specific function.
2249#[derive(Clone, PartialEq, Serialize)]
2250#[serde(untagged)]
2251enum ApiToolChoice {
2252    Mode(&'static str),
2253    Function {
2254        #[serde(rename = "type")]
2255        choice_type: &'static str,
2256        name: String,
2257    },
2258}
2259
2260/// Map an optional [`ToolChoice`] onto the Codex wire `tool_choice`, defaulting
2261/// to `"auto"` (the historical Codex behavior) when unset.
2262fn codex_tool_choice(tool_choice: Option<&ToolChoice>) -> ApiToolChoice {
2263    match tool_choice {
2264        Some(ToolChoice::Tool(name)) => ApiToolChoice::Function {
2265            choice_type: "function",
2266            name: name.clone(),
2267        },
2268        _ => ApiToolChoice::Mode("auto"),
2269    }
2270}
2271
2272#[derive(Clone, PartialEq, Serialize)]
2273struct ApiReasoning {
2274    effort: ReasoningEffort,
2275}
2276
2277#[derive(Clone, PartialEq, Serialize)]
2278#[serde(untagged)]
2279enum ApiInputItem {
2280    Message(ApiMessage),
2281    FunctionCall(ApiFunctionCall),
2282    FunctionCallOutput(ApiFunctionCallOutput),
2283}
2284
2285#[derive(Clone, PartialEq, Serialize)]
2286struct ApiMessage {
2287    role: ApiRole,
2288    content: ApiMessageContent,
2289}
2290
2291#[derive(Clone, Copy, PartialEq, Serialize)]
2292#[serde(rename_all = "lowercase")]
2293enum ApiRole {
2294    User,
2295    Assistant,
2296}
2297
2298#[derive(Clone, PartialEq, Serialize)]
2299#[serde(untagged)]
2300enum ApiMessageContent {
2301    Text(String),
2302    Parts(Vec<ApiInputContent>),
2303}
2304
2305#[derive(Clone, PartialEq, Serialize)]
2306#[serde(tag = "type")]
2307enum ApiInputContent {
2308    #[serde(rename = "input_text")]
2309    InputText { text: String },
2310    #[serde(rename = "output_text")]
2311    OutputText { text: String },
2312    #[serde(rename = "input_image")]
2313    Image { image_url: String },
2314    #[serde(rename = "input_file")]
2315    File { filename: String, file_data: String },
2316}
2317
2318#[derive(Clone, PartialEq, Serialize)]
2319struct ApiFunctionCall {
2320    r#type: &'static str,
2321    call_id: String,
2322    name: String,
2323    arguments: String,
2324}
2325
2326impl ApiFunctionCall {
2327    const fn new(call_id: String, name: String, arguments: String) -> Self {
2328        Self {
2329            r#type: "function_call",
2330            call_id,
2331            name,
2332            arguments,
2333        }
2334    }
2335}
2336
2337#[derive(Clone, PartialEq, Serialize)]
2338struct ApiFunctionCallOutput {
2339    r#type: &'static str,
2340    call_id: String,
2341    output: String,
2342}
2343
2344impl ApiFunctionCallOutput {
2345    const fn new(call_id: String, output: String) -> Self {
2346        Self {
2347            r#type: "function_call_output",
2348            call_id,
2349            output,
2350        }
2351    }
2352}
2353
2354#[derive(Clone, PartialEq, Serialize)]
2355struct ApiTool {
2356    r#type: String,
2357    name: String,
2358    #[serde(skip_serializing_if = "Option::is_none")]
2359    description: Option<String>,
2360    #[serde(skip_serializing_if = "Option::is_none")]
2361    parameters: Option<serde_json::Value>,
2362    #[serde(skip_serializing_if = "Option::is_none")]
2363    strict: Option<bool>,
2364}
2365
2366// ============================================================================
2367// API Response Types
2368// ============================================================================
2369
2370#[derive(Deserialize)]
2371struct ApiResponse {
2372    id: String,
2373    model: String,
2374    output: Vec<ApiOutputItem>,
2375    #[serde(default)]
2376    status: Option<ApiStatus>,
2377    #[serde(default)]
2378    usage: Option<ApiUsage>,
2379    #[serde(default)]
2380    error: Option<ApiErrorBody>,
2381}
2382
2383#[derive(Clone, Copy, Deserialize)]
2384#[serde(rename_all = "snake_case")]
2385enum ApiStatus {
2386    Completed,
2387    Incomplete,
2388    Failed,
2389}
2390
2391#[derive(Deserialize)]
2392struct ApiUsage {
2393    input_tokens: u32,
2394    output_tokens: u32,
2395    #[serde(default)]
2396    input_tokens_details: Option<ApiInputTokensDetails>,
2397}
2398
2399#[derive(Deserialize)]
2400struct ApiInputTokensDetails {
2401    #[serde(default)]
2402    cached_tokens: u32,
2403}
2404
2405#[derive(Deserialize)]
2406#[serde(tag = "type")]
2407enum ApiOutputItem {
2408    #[serde(rename = "message")]
2409    Message {
2410        #[serde(rename = "role")]
2411        _role: String,
2412        content: Vec<ApiOutputContent>,
2413    },
2414    #[serde(rename = "function_call")]
2415    FunctionCall {
2416        call_id: String,
2417        name: String,
2418        arguments: String,
2419    },
2420    #[serde(other)]
2421    Unknown,
2422}
2423
2424#[derive(Deserialize)]
2425#[serde(tag = "type")]
2426enum ApiOutputContent {
2427    #[serde(rename = "output_text")]
2428    Text { text: String },
2429    #[serde(other)]
2430    Unknown,
2431}
2432
2433// ============================================================================
2434// Streaming Types
2435// ============================================================================
2436
2437#[derive(Deserialize)]
2438struct ApiStreamEvent {
2439    r#type: String,
2440    #[serde(default)]
2441    delta: Option<String>,
2442    #[serde(default)]
2443    call_id: Option<String>,
2444    #[serde(default)]
2445    name: Option<String>,
2446    #[serde(default)]
2447    item: Option<serde_json::Value>,
2448    #[serde(default)]
2449    response: Option<ApiStreamResponse>,
2450}
2451
2452#[derive(Deserialize)]
2453struct ApiStreamResponse {
2454    #[serde(default)]
2455    id: Option<String>,
2456    #[serde(default)]
2457    usage: Option<ApiUsage>,
2458    #[serde(default)]
2459    error: Option<ApiErrorBody>,
2460}
2461
2462#[derive(Deserialize)]
2463struct ApiErrorBody {
2464    #[serde(default)]
2465    message: Option<String>,
2466}
2467
2468#[derive(Deserialize)]
2469struct ApiWrappedWebsocketErrorBody {
2470    #[serde(default)]
2471    code: Option<String>,
2472    #[serde(default)]
2473    message: Option<String>,
2474}
2475
2476#[derive(Deserialize)]
2477struct ApiWrappedWebsocketErrorEvent {
2478    #[serde(rename = "type")]
2479    kind: String,
2480    #[serde(alias = "status_code")]
2481    status: Option<u16>,
2482    #[serde(default)]
2483    error: Option<ApiWrappedWebsocketErrorBody>,
2484}
2485
2486// ============================================================================
2487// Tests
2488// ============================================================================
2489
2490#[cfg(test)]
2491mod tests {
2492    use super::*;
2493
2494    #[test]
2495    fn test_model_constant() {
2496        assert_eq!(MODEL_GPT54, "gpt-5.4");
2497        assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
2498        assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
2499    }
2500
2501    #[test]
2502    fn test_codex_factory() {
2503        let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
2504        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2505        assert_eq!(provider.provider(), "openai-codex");
2506    }
2507
2508    #[test]
2509    fn test_gpt54_factory() {
2510        let provider = OpenAICodexResponsesProvider::gpt54("test-key".to_string());
2511        assert_eq!(provider.model(), MODEL_GPT54);
2512        assert_eq!(provider.provider(), "openai-codex");
2513    }
2514
2515    #[test]
2516    fn test_gpt53_codex_factory() {
2517        let provider = OpenAICodexResponsesProvider::gpt53_codex("test-key".to_string());
2518        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2519        assert_eq!(provider.provider(), "openai-codex");
2520    }
2521
2522    #[test]
2523    fn test_reasoning_effort_serialization() {
2524        let low = serde_json::to_string(&ReasoningEffort::Low).unwrap();
2525        assert_eq!(low, "\"low\"");
2526
2527        let xhigh = serde_json::to_string(&ReasoningEffort::XHigh).unwrap();
2528        assert_eq!(xhigh, "\"xhigh\"");
2529    }
2530
2531    #[test]
2532    fn test_with_reasoning_effort() {
2533        let provider = OpenAICodexResponsesProvider::codex("test-key".to_string())
2534            .with_reasoning_effort(ReasoningEffort::High);
2535        let thinking = provider.thinking.as_ref().unwrap();
2536        assert!(matches!(thinking.effort, Some(Effort::High)));
2537    }
2538
2539    #[test]
2540    fn test_build_api_reasoning_uses_explicit_effort() {
2541        let reasoning =
2542            build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::Low))).unwrap();
2543        assert!(matches!(reasoning.effort, ReasoningEffort::Low));
2544    }
2545
2546    #[test]
2547    fn test_build_api_reasoning_omits_adaptive_without_effort() {
2548        assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
2549    }
2550
2551    #[test]
2552    fn test_openai_responses_rejects_adaptive_thinking() {
2553        let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
2554        let error = provider
2555            .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
2556            .unwrap_err();
2557        assert!(
2558            error
2559                .to_string()
2560                .contains("adaptive thinking is not supported")
2561        );
2562    }
2563
2564    #[test]
2565    fn test_api_tool_serialization() {
2566        let tool = ApiTool {
2567            r#type: "function".to_owned(),
2568            name: "get_weather".to_owned(),
2569            description: Some("Get weather".to_owned()),
2570            parameters: Some(serde_json::json!({"type": "object"})),
2571            strict: Some(true),
2572        };
2573
2574        let json = serde_json::to_string(&tool).unwrap();
2575        assert!(json.contains("\"type\":\"function\""));
2576        assert!(json.contains("\"name\":\"get_weather\""));
2577        assert!(json.contains("\"strict\":true"));
2578    }
2579
2580    fn test_token() -> String {
2581        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
2582        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(
2583            r#"{{"{OPENAI_CODEX_JWT_CLAIM_PATH}":{{"chatgpt_account_id":"acct_123"}}}}"#
2584        ));
2585        format!("{header}.{payload}.sig")
2586    }
2587
2588    #[test]
2589    fn test_build_headers_match_codex_style_defaults() -> anyhow::Result<()> {
2590        let provider = OpenAICodexResponsesProvider::codex(test_token());
2591
2592        let headers = provider.build_headers(true, Some("session-123"), None)?;
2593        assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
2594        assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
2595        assert_eq!(headers.get("session_id").unwrap(), "session-123");
2596        assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
2597        assert_eq!(
2598            headers.get("OpenAI-Beta").unwrap(),
2599            OPENAI_CODEX_RESPONSES_BETA_HEADER
2600        );
2601
2602        Ok(())
2603    }
2604
2605    #[test]
2606    fn test_build_websocket_headers_match_codex_style_defaults() -> anyhow::Result<()> {
2607        let provider = OpenAICodexResponsesProvider::codex(test_token());
2608
2609        let headers = provider.build_websocket_headers(Some("session-123"), Some("turn-1"))?;
2610        assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
2611        assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
2612        assert_eq!(headers.get("session_id").unwrap(), "session-123");
2613        assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
2614        assert_eq!(
2615            headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
2616            "turn-1"
2617        );
2618        assert_eq!(
2619            headers.get("OpenAI-Beta").unwrap(),
2620            OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER,
2621        );
2622
2623        Ok(())
2624    }
2625
2626    #[test]
2627    fn test_build_headers_uses_configured_account_id_without_jwt_decode() -> anyhow::Result<()> {
2628        let provider = OpenAICodexResponsesProvider::codex("not-a-jwt".to_string())
2629            .with_account_id("acct_stored");
2630
2631        let headers = provider.build_headers(true, Some("session-123"), Some("turn-1"))?;
2632        assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_stored");
2633        assert_eq!(
2634            headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
2635            "turn-1"
2636        );
2637
2638        Ok(())
2639    }
2640
2641    #[test]
2642    fn test_request_serialization_includes_store_false() {
2643        let request = ApiStreamingRequest {
2644            model: MODEL_GPT53_CODEX.to_string(),
2645            instructions: "system".to_string(),
2646            input: Vec::new(),
2647            tools: None,
2648            max_output_tokens: None,
2649            reasoning: None,
2650            tool_choice: Some(ApiToolChoice::Mode("auto")),
2651            parallel_tool_calls: Some(true),
2652            store: false,
2653            text: Some(ApiTextSettings {
2654                verbosity: "medium",
2655                format: None,
2656            }),
2657            include: Some(vec!["reasoning.encrypted_content".to_string()]),
2658            prompt_cache_key: Some("session-123".to_string()),
2659            stream: true,
2660        };
2661
2662        let json = serde_json::to_string(&request).unwrap();
2663        assert!(json.contains("\"store\":false"));
2664        assert!(json.contains("\"stream\":true"));
2665    }
2666
2667    #[test]
2668    fn test_prepare_websocket_request_uses_previous_response_id_for_incremental_input() {
2669        let request = ApiStreamingRequest {
2670            model: MODEL_GPT53_CODEX.to_string(),
2671            instructions: "system".to_string(),
2672            input: vec![
2673                ApiInputItem::Message(ApiMessage {
2674                    role: ApiRole::User,
2675                    content: ApiMessageContent::Text("first".to_string()),
2676                }),
2677                ApiInputItem::Message(ApiMessage {
2678                    role: ApiRole::Assistant,
2679                    content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
2680                        text: "answer".to_string(),
2681                    }]),
2682                }),
2683                ApiInputItem::Message(ApiMessage {
2684                    role: ApiRole::User,
2685                    content: ApiMessageContent::Text("follow up".to_string()),
2686                }),
2687            ],
2688            tools: None,
2689            max_output_tokens: None,
2690            reasoning: None,
2691            tool_choice: Some(ApiToolChoice::Mode("auto")),
2692            parallel_tool_calls: None,
2693            store: false,
2694            text: Some(ApiTextSettings {
2695                verbosity: "medium",
2696                format: None,
2697            }),
2698            include: Some(vec!["reasoning.encrypted_content".to_string()]),
2699            prompt_cache_key: Some("thread-1".to_string()),
2700            stream: true,
2701        };
2702        let previous_request = ApiStreamingRequest {
2703            input: vec![ApiInputItem::Message(ApiMessage {
2704                role: ApiRole::User,
2705                content: ApiMessageContent::Text("first".to_string()),
2706            })],
2707            ..request.clone()
2708        };
2709        let session = WebsocketSessionState {
2710            connection: None,
2711            last_request: Some(previous_request),
2712            last_response_id: Some("resp_prev".to_string()),
2713            last_response_items: vec![ApiInputItem::Message(ApiMessage {
2714                role: ApiRole::Assistant,
2715                content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
2716                    text: "answer".to_string(),
2717                }]),
2718            })],
2719            turn_state: None,
2720            prewarmed: false,
2721            websocket_disabled: false,
2722            in_flight: false,
2723            last_used: None,
2724        };
2725
2726        let websocket_request = prepare_websocket_request(&request, &session, false);
2727        assert_eq!(
2728            websocket_request.previous_response_id.as_deref(),
2729            Some("resp_prev")
2730        );
2731        assert_eq!(websocket_request.input.len(), 1);
2732        match &websocket_request.input[0] {
2733            ApiInputItem::Message(ApiMessage {
2734                role: ApiRole::User,
2735                content: ApiMessageContent::Text(text),
2736            }) => assert_eq!(text, "follow up"),
2737            _ => panic!("expected incremental follow-up user message"),
2738        }
2739    }
2740
2741    #[test]
2742    fn test_parse_wrapped_websocket_error_event_maps_http_status() {
2743        let payload = r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
2744        let parsed = parse_wrapped_websocket_error_event(payload);
2745        assert_eq!(
2746            parsed,
2747            Some((StatusCode::UNAUTHORIZED, "unauthorized".to_string())),
2748        );
2749    }
2750
2751    #[test]
2752    fn test_parse_wrapped_websocket_error_event_maps_connection_limit() {
2753        let payload = format!(
2754            r#"{{"type":"error","status":429,"error":{{"code":"{OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE}","message":"limit"}}}}"#,
2755        );
2756        let parsed = parse_wrapped_websocket_error_event(&payload);
2757        assert_eq!(
2758            parsed,
2759            Some((StatusCode::TOO_MANY_REQUESTS, "limit".to_string())),
2760        );
2761    }
2762
2763    #[test]
2764    fn test_prepare_websocket_request_allows_empty_delta_after_prewarm() {
2765        let request = ApiStreamingRequest {
2766            model: MODEL_GPT53_CODEX.to_string(),
2767            instructions: "system".to_string(),
2768            input: vec![ApiInputItem::Message(ApiMessage {
2769                role: ApiRole::User,
2770                content: ApiMessageContent::Text("first".to_string()),
2771            })],
2772            tools: None,
2773            max_output_tokens: None,
2774            reasoning: None,
2775            tool_choice: Some(ApiToolChoice::Mode("auto")),
2776            parallel_tool_calls: None,
2777            store: false,
2778            text: Some(ApiTextSettings {
2779                verbosity: "medium",
2780                format: None,
2781            }),
2782            include: Some(vec!["reasoning.encrypted_content".to_string()]),
2783            prompt_cache_key: Some("thread-1".to_string()),
2784            stream: true,
2785        };
2786        let session = WebsocketSessionState {
2787            connection: None,
2788            last_request: Some(request.clone()),
2789            last_response_id: Some("resp_prewarm".to_string()),
2790            last_response_items: Vec::new(),
2791            turn_state: None,
2792            prewarmed: true,
2793            websocket_disabled: false,
2794            in_flight: false,
2795            last_used: None,
2796        };
2797
2798        let websocket_request = prepare_websocket_request(&request, &session, true);
2799        assert_eq!(
2800            websocket_request.previous_response_id.as_deref(),
2801            Some("resp_prewarm")
2802        );
2803        assert!(websocket_request.input.is_empty());
2804    }
2805
2806    #[test]
2807    fn test_api_response_deserialization() {
2808        let json = r#"{
2809            "id": "resp_123",
2810            "model": "gpt-5.2-codex",
2811            "output": [
2812                {
2813                    "type": "message",
2814                    "role": "assistant",
2815                    "content": [
2816                        {"type": "output_text", "text": "Hello!"}
2817                    ]
2818                }
2819            ],
2820            "status": "completed",
2821            "usage": {
2822                "input_tokens": 100,
2823                "output_tokens": 50
2824            }
2825        }"#;
2826
2827        let response: ApiResponse = serde_json::from_str(json).unwrap();
2828        assert_eq!(response.id, "resp_123");
2829        assert_eq!(response.model, "gpt-5.2-codex");
2830        assert_eq!(response.output.len(), 1);
2831    }
2832
2833    #[test]
2834    fn test_api_response_with_function_call() {
2835        let json = r#"{
2836            "id": "resp_456",
2837            "model": "gpt-5.2-codex",
2838            "output": [
2839                {
2840                    "type": "function_call",
2841                    "call_id": "call_abc",
2842                    "name": "read_file",
2843                    "arguments": "{\"path\": \"test.txt\"}"
2844                }
2845            ],
2846            "status": "completed"
2847        }"#;
2848
2849        let response: ApiResponse = serde_json::from_str(json).unwrap();
2850        assert_eq!(response.output.len(), 1);
2851
2852        match &response.output[0] {
2853            ApiOutputItem::FunctionCall {
2854                call_id,
2855                name,
2856                arguments,
2857            } => {
2858                assert_eq!(call_id, "call_abc");
2859                assert_eq!(name, "read_file");
2860                assert!(arguments.contains("test.txt"));
2861            }
2862            _ => panic!("Expected FunctionCall"),
2863        }
2864    }
2865
2866    #[test]
2867    fn test_build_api_input_uses_responses_text_types_by_role() {
2868        let request = ChatRequest {
2869            system: "system".to_string(),
2870            messages: vec![
2871                agent_sdk_foundation::llm::Message::user_with_content(vec![ContentBlock::Text {
2872                    text: "question".to_string(),
2873                }]),
2874                agent_sdk_foundation::llm::Message {
2875                    role: agent_sdk_foundation::llm::Role::Assistant,
2876                    content: Content::Blocks(vec![ContentBlock::Text {
2877                        text: "answer".to_string(),
2878                    }]),
2879                },
2880            ],
2881            tools: None,
2882            max_tokens: 512,
2883            max_tokens_explicit: false,
2884            session_id: None,
2885            cached_content: None,
2886            thinking: None,
2887            tool_choice: None,
2888            response_format: None,
2889            cache: None,
2890        };
2891
2892        let input = build_api_input(&request);
2893        assert_eq!(input.len(), 2);
2894
2895        match &input[0] {
2896            ApiInputItem::Message(ApiMessage {
2897                role: ApiRole::User,
2898                content: ApiMessageContent::Parts(parts),
2899            }) => assert!(matches!(
2900                parts.as_slice(),
2901                [ApiInputContent::InputText { text }] if text == "question"
2902            )),
2903            _ => panic!("expected user message with input_text content"),
2904        }
2905
2906        match &input[1] {
2907            ApiInputItem::Message(ApiMessage {
2908                role: ApiRole::Assistant,
2909                content: ApiMessageContent::Parts(parts),
2910            }) => assert!(matches!(
2911                parts.as_slice(),
2912                [ApiInputContent::OutputText { text }] if text == "answer"
2913            )),
2914            _ => panic!("expected assistant message with output_text content"),
2915        }
2916    }
2917
2918    #[test]
2919    fn test_api_input_content_serialization_uses_current_responses_tags() {
2920        let json = serde_json::to_string(&ApiMessageContent::Parts(vec![
2921            ApiInputContent::InputText {
2922                text: "prompt".to_string(),
2923            },
2924            ApiInputContent::OutputText {
2925                text: "reply".to_string(),
2926            },
2927            ApiInputContent::Image {
2928                image_url: "data:image/png;base64,abc".to_string(),
2929            },
2930            ApiInputContent::File {
2931                filename: "notes.txt".to_string(),
2932                file_data: "data:text/plain;base64,abc".to_string(),
2933            },
2934        ]))
2935        .unwrap();
2936
2937        assert!(json.contains("\"type\":\"input_text\""));
2938        assert!(json.contains("\"type\":\"output_text\""));
2939        assert!(json.contains("\"type\":\"input_image\""));
2940        assert!(json.contains("\"type\":\"input_file\""));
2941    }
2942
2943    #[test]
2944    fn test_build_content_blocks_text() {
2945        let output = vec![ApiOutputItem::Message {
2946            _role: "assistant".to_owned(),
2947            content: vec![ApiOutputContent::Text {
2948                text: "Hello!".to_owned(),
2949            }],
2950        }];
2951
2952        let blocks = build_content_blocks(&output);
2953        assert_eq!(blocks.len(), 1);
2954        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello!"));
2955    }
2956
2957    #[test]
2958    fn test_build_content_blocks_function_call() {
2959        let output = vec![ApiOutputItem::FunctionCall {
2960            call_id: "call_123".to_owned(),
2961            name: "test_tool".to_owned(),
2962            arguments: r#"{"key": "value"}"#.to_owned(),
2963        }];
2964
2965        let blocks = build_content_blocks(&output);
2966        assert_eq!(blocks.len(), 1);
2967        assert!(
2968            matches!(&blocks[0], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "test_tool")
2969        );
2970    }
2971
2972    #[test]
2973    fn test_request_serializes_response_format_text_and_forced_tool_choice() {
2974        let request = ApiStreamingRequest {
2975            model: MODEL_GPT53_CODEX.to_string(),
2976            instructions: String::new(),
2977            input: Vec::new(),
2978            tools: None,
2979            max_output_tokens: None,
2980            reasoning: None,
2981            tool_choice: Some(codex_tool_choice(Some(&ToolChoice::Tool(
2982                "respond".to_owned(),
2983            )))),
2984            parallel_tool_calls: None,
2985            store: false,
2986            text: Some(ApiTextSettings {
2987                verbosity: "medium",
2988                format: Some(ApiResponseTextFormat::from(&ResponseFormat::new(
2989                    "person",
2990                    serde_json::json!({"type": "object"}),
2991                ))),
2992            }),
2993            include: None,
2994            prompt_cache_key: None,
2995            stream: true,
2996        };
2997
2998        let json = serde_json::to_value(&request).unwrap();
2999        assert_eq!(json["text"]["format"]["type"], "json_schema");
3000        assert_eq!(json["text"]["format"]["name"], "person");
3001        assert_eq!(json["text"]["format"]["strict"], true);
3002        assert_eq!(json["tool_choice"]["type"], "function");
3003        assert_eq!(json["tool_choice"]["name"], "respond");
3004    }
3005
3006    #[test]
3007    fn test_codex_tool_choice_defaults_to_auto() {
3008        assert_eq!(
3009            serde_json::to_value(codex_tool_choice(None)).unwrap(),
3010            serde_json::json!("auto")
3011        );
3012        assert_eq!(
3013            serde_json::to_value(codex_tool_choice(Some(&ToolChoice::Auto))).unwrap(),
3014            serde_json::json!("auto")
3015        );
3016    }
3017
3018    #[test]
3019    fn test_convert_tool_makes_optional_params_nullable() {
3020        let tool = agent_sdk_foundation::llm::Tool {
3021            name: "t".to_string(),
3022            description: "d".to_string(),
3023            input_schema: serde_json::json!({
3024                "type": "object",
3025                "properties": {
3026                    "req": {"type": "string"},
3027                    "opt": {"type": "string"}
3028                },
3029                "required": ["req"]
3030            }),
3031            display_name: "T".to_string(),
3032            tier: agent_sdk_foundation::ToolTier::Observe,
3033        };
3034
3035        let api_tool = convert_tool(tool);
3036        assert_eq!(api_tool.strict, Some(true));
3037        let schema = api_tool.parameters.unwrap();
3038
3039        let required: Vec<&str> = schema["required"]
3040            .as_array()
3041            .unwrap()
3042            .iter()
3043            .filter_map(|v| v.as_str())
3044            .collect();
3045        assert!(required.contains(&"req"));
3046        assert!(required.contains(&"opt"));
3047
3048        // The previously-optional `opt` must be wrapped in anyOf with a null
3049        // variant so the model is not forced to fabricate a value for it.
3050        let any_of = schema["properties"]["opt"]["anyOf"].as_array().unwrap();
3051        assert!(
3052            any_of
3053                .iter()
3054                .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"))
3055        );
3056    }
3057
3058    #[test]
3059    fn test_convert_tool_disables_strict_for_freeform_object() {
3060        let tool = agent_sdk_foundation::llm::Tool {
3061            name: "t".to_string(),
3062            description: "d".to_string(),
3063            input_schema: serde_json::json!({"type": "object"}),
3064            display_name: "T".to_string(),
3065            tier: agent_sdk_foundation::ToolTier::Observe,
3066        };
3067
3068        let api_tool = convert_tool(tool);
3069        assert_eq!(api_tool.strict, None);
3070    }
3071
3072    #[test]
3073    fn test_emit_accumulated_tool_calls_assigns_distinct_ordered_indices() {
3074        let mut tool_calls = HashMap::new();
3075        tool_calls.insert(
3076            "b".to_string(),
3077            ToolCallAccumulator {
3078                id: "b".to_string(),
3079                name: "second".to_string(),
3080                arguments: "{}".to_string(),
3081                order: 1,
3082            },
3083        );
3084        tool_calls.insert(
3085            "a".to_string(),
3086            ToolCallAccumulator {
3087                id: "a".to_string(),
3088                name: "first".to_string(),
3089                arguments: "{}".to_string(),
3090                order: 0,
3091            },
3092        );
3093
3094        let deltas = emit_accumulated_tool_calls(&tool_calls);
3095        let starts: Vec<(String, usize)> = deltas
3096            .iter()
3097            .filter_map(|d| match d {
3098                StreamDelta::ToolUseStart {
3099                    name, block_index, ..
3100                } => Some((name.clone(), *block_index)),
3101                _ => None,
3102            })
3103            .collect();
3104        assert_eq!(
3105            starts,
3106            vec![("first".to_string(), 1), ("second".to_string(), 2)]
3107        );
3108    }
3109
3110    // ────────────────────────────────────────────────────────────────────
3111    // Transport selection: force-HTTP knob + cross-session WS-unhealthy memory
3112    // ────────────────────────────────────────────────────────────────────
3113
3114    use crate::provider::LlmProvider;
3115    use agent_sdk_foundation::llm::{ChatRequest, Message};
3116    use std::sync::atomic::AtomicUsize;
3117    use tokio::io::{AsyncReadExt, AsyncWriteExt};
3118    use tokio::net::TcpListener;
3119
3120    /// A truthy OAuth-shaped token so `build_headers` can extract an account id
3121    /// without a real network call.
3122    fn oauth_token() -> String {
3123        test_token()
3124    }
3125
3126    fn streaming_request(session_id: &str) -> ChatRequest {
3127        ChatRequest::new("You are helpful.", vec![Message::user("hello")])
3128            .with_max_tokens(1024)
3129            .with_session_id(session_id)
3130    }
3131
3132    /// Read a single HTTP/1.1 request head (up to the blank line) from a stream.
3133    async fn read_http_head(stream: &mut tokio::net::TcpStream) -> String {
3134        let mut buf = Vec::new();
3135        let mut byte = [0u8; 1];
3136        while stream.read_exact(&mut byte).await.is_ok() {
3137            buf.push(byte[0]);
3138            if buf.ends_with(b"\r\n\r\n") {
3139                break;
3140            }
3141            if buf.len() > 16 * 1024 {
3142                break;
3143            }
3144        }
3145        String::from_utf8_lossy(&buf).into_owned()
3146    }
3147
3148    /// Minimal SSE body that drives the HTTP fallback to a clean `Done`.
3149    const HTTP_SSE_BODY: &str = concat!(
3150        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n",
3151        "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\"}}\n\n",
3152        "data: [DONE]\n\n",
3153    );
3154
3155    /// Spawn an HTTP-only server that records whether any WebSocket upgrade was
3156    /// attempted and serves a fixed SSE body to plain POSTs. Returns the base
3157    /// URL plus the (`ws_attempts`, `http_requests`) counters.
3158    async fn spawn_http_only_server() -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
3159        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3160        let addr = listener.local_addr().unwrap();
3161        let ws_attempts = Arc::new(AtomicUsize::new(0));
3162        let http_requests = Arc::new(AtomicUsize::new(0));
3163        let ws_attempts_task = ws_attempts.clone();
3164        let http_requests_task = http_requests.clone();
3165
3166        tokio::spawn(async move {
3167            loop {
3168                let Ok((mut stream, _)) = listener.accept().await else {
3169                    break;
3170                };
3171                let ws_attempts = ws_attempts_task.clone();
3172                let http_requests = http_requests_task.clone();
3173                tokio::spawn(async move {
3174                    let head = read_http_head(&mut stream).await;
3175                    if head.to_ascii_lowercase().contains("upgrade: websocket") {
3176                        ws_attempts.fetch_add(1, Ordering::Relaxed);
3177                        // Refuse the upgrade; a black-holed proxy would simply
3178                        // never complete it, but refusing fast keeps the test
3179                        // deterministic.
3180                        let _ = stream
3181                            .write_all(
3182                                b"HTTP/1.1 426 Upgrade Required\r\ncontent-length: 0\r\n\r\n",
3183                            )
3184                            .await;
3185                        return;
3186                    }
3187                    http_requests.fetch_add(1, Ordering::Relaxed);
3188                    let response = format!(
3189                        "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
3190                        HTTP_SSE_BODY.len(),
3191                        HTTP_SSE_BODY,
3192                    );
3193                    let _ = stream.write_all(response.as_bytes()).await;
3194                });
3195            }
3196        });
3197
3198        (
3199            format!("http://{addr}/backend-api"),
3200            ws_attempts,
3201            http_requests,
3202        )
3203    }
3204
3205    /// Drain a stream, returning whether it completed without a transport error.
3206    async fn drain_ok(provider: &OpenAICodexResponsesProvider, request: ChatRequest) -> bool {
3207        let mut stream = std::pin::pin!(provider.chat_stream(request));
3208        let mut saw_error = false;
3209        while let Some(item) = stream.next().await {
3210            match item {
3211                Ok(StreamDelta::Error { .. }) | Err(_) => saw_error = true,
3212                Ok(_) => {}
3213            }
3214        }
3215        !saw_error
3216    }
3217
3218    #[tokio::test]
3219    async fn websockets_disabled_builder_goes_straight_to_http() {
3220        let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
3221        let provider = OpenAICodexResponsesProvider::with_base_url(
3222            oauth_token(),
3223            MODEL_GPT53_CODEX.to_string(),
3224            base_url,
3225        )
3226        .with_websockets_disabled(true);
3227
3228        assert!(drain_ok(&provider, streaming_request("session-a")).await);
3229
3230        assert_eq!(
3231            ws_attempts.load(Ordering::Relaxed),
3232            0,
3233            "no websocket upgrade may be attempted when websockets are disabled",
3234        );
3235        assert_eq!(http_requests.load(Ordering::Relaxed), 1);
3236    }
3237
3238    #[test]
3239    fn parse_disable_websockets_value_recognizes_truthy_values() {
3240        for value in ["1", "true", "TRUE", " yes ", "on"] {
3241            assert!(
3242                parse_disable_websockets_value(Some(value)),
3243                "value={value:?} should disable websockets",
3244            );
3245        }
3246        for value in ["0", "false", "no", "off", "", "maybe"] {
3247            assert!(
3248                !parse_disable_websockets_value(Some(value)),
3249                "value={value:?} should NOT disable websockets",
3250            );
3251        }
3252        assert!(!parse_disable_websockets_value(None));
3253    }
3254
3255    #[tokio::test]
3256    async fn websockets_disabled_via_env_value_goes_straight_to_http() {
3257        // The env var feeds `with_websockets_disabled` through
3258        // `parse_disable_websockets_value` at construction. `std::env::set_var`
3259        // is `unsafe` and rejected by `#![forbid(unsafe_code)]`, so we drive the
3260        // exact value the env reader would produce for `"1"` end-to-end here.
3261        let disabled = parse_disable_websockets_value(Some("1"));
3262        assert!(disabled);
3263
3264        let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
3265        let provider = OpenAICodexResponsesProvider::with_base_url(
3266            oauth_token(),
3267            MODEL_GPT53_CODEX.to_string(),
3268            base_url,
3269        )
3270        .with_websockets_disabled(disabled);
3271
3272        assert!(drain_ok(&provider, streaming_request("session-env")).await);
3273
3274        assert_eq!(
3275            ws_attempts.load(Ordering::Relaxed),
3276            0,
3277            "no websocket upgrade may be attempted when the env var forces HTTP-only",
3278        );
3279        assert_eq!(http_requests.load(Ordering::Relaxed), 1);
3280    }
3281
3282    #[tokio::test]
3283    async fn provider_marked_ws_unhealthy_skips_websocket_on_new_session() {
3284        let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
3285        let provider = OpenAICodexResponsesProvider::with_base_url(
3286            oauth_token(),
3287            MODEL_GPT53_CODEX.to_string(),
3288            base_url,
3289        );
3290
3291        // Simulate a prior session having latched the provider-level transport
3292        // signal after a connectivity failure.
3293        provider.websockets_unhealthy.store(true, Ordering::Relaxed);
3294
3295        // A brand-new session must skip the websocket attempt entirely.
3296        assert!(drain_ok(&provider, streaming_request("fresh-session")).await);
3297
3298        assert_eq!(
3299            ws_attempts.load(Ordering::Relaxed),
3300            0,
3301            "a websocket-unhealthy provider must not attempt a new upgrade",
3302        );
3303        assert_eq!(http_requests.load(Ordering::Relaxed), 1);
3304    }
3305
3306    /// Spawn a server that completes the WebSocket handshake then emits a
3307    /// wrapped 401 error event. Plain POSTs get the HTTP SSE body. Returns the
3308    /// base URL plus the http-request counter (for the fallback assertion).
3309    async fn spawn_ws_unauthorized_server() -> (String, Arc<AtomicUsize>) {
3310        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3311        let addr = listener.local_addr().unwrap();
3312        let http_requests = Arc::new(AtomicUsize::new(0));
3313        let http_requests_task = http_requests.clone();
3314
3315        tokio::spawn(async move {
3316            loop {
3317                let Ok((stream, _)) = listener.accept().await else {
3318                    break;
3319                };
3320                let http_requests = http_requests_task.clone();
3321                tokio::spawn(async move {
3322                    let mut stream = stream;
3323                    // Non-destructively peek the head to route WS vs HTTP, then
3324                    // hand the still-unread stream to the right handler.
3325                    let mut peek = [0u8; 1024];
3326                    let Ok(n) = stream.peek(&mut peek).await else {
3327                        return;
3328                    };
3329                    let head = String::from_utf8_lossy(&peek[..n]).to_ascii_lowercase();
3330
3331                    if head.contains("upgrade: websocket") {
3332                        // `accept_async` reads and completes the handshake from
3333                        // the untouched stream, so no manual SHA-1 is needed.
3334                        let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
3335                            return;
3336                        };
3337                        let payload =
3338                            r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
3339                        let _ = ws
3340                            .send(WebSocketMessage::Text(payload.to_string().into()))
3341                            .await;
3342                        let _ = ws.send(WebSocketMessage::Close(None)).await;
3343                        return;
3344                    }
3345
3346                    // Plain HTTP POST: drain the request head, then reply.
3347                    let _ = read_http_head(&mut stream).await;
3348                    http_requests.fetch_add(1, Ordering::Relaxed);
3349                    let response = format!(
3350                        "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
3351                        HTTP_SSE_BODY.len(),
3352                        HTTP_SSE_BODY,
3353                    );
3354                    let _ = stream.write_all(response.as_bytes()).await;
3355                });
3356            }
3357        });
3358
3359        (format!("http://{addr}/backend-api"), http_requests)
3360    }
3361
3362    #[tokio::test]
3363    async fn ws_unauthorized_disables_session_but_not_provider() {
3364        let (base_url, http_requests) = spawn_ws_unauthorized_server().await;
3365        let provider = OpenAICodexResponsesProvider::with_base_url(
3366            oauth_token(),
3367            MODEL_GPT53_CODEX.to_string(),
3368            base_url,
3369        );
3370
3371        // The websocket warmup hits a wrapped 401, disables the session's
3372        // websocket, and falls back to HTTP — which completes cleanly.
3373        assert!(drain_ok(&provider, streaming_request("auth-session")).await);
3374
3375        // The auth failure is a request problem, NOT a transport problem: the
3376        // provider-level flag must stay clear so a transient blip does not
3377        // force HTTP-only forever.
3378        assert!(
3379            !provider.websockets_unhealthy.load(Ordering::Relaxed),
3380            "a 401 must not mark the provider websocket-transport-unhealthy",
3381        );
3382        // The per-session flag was set, so this same session now goes straight
3383        // to HTTP without another websocket attempt.
3384        let session = provider.websocket_session("auth-session").await;
3385        assert!(session.lock().await.websocket_disabled);
3386
3387        assert!(http_requests.load(Ordering::Relaxed) >= 1);
3388    }
3389}