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::{
10    SseLineBuffer, StreamBox, StreamDelta, StreamErrorKind, reqwest_body_error_delta,
11    reqwest_error_delta,
12};
13#[cfg(test)]
14use agent_sdk_foundation::llm::ChatResponse;
15use agent_sdk_foundation::llm::{
16    ChatOutcome, ChatRequest, Content, ContentBlock, Effort, ResponseFormat, StopReason,
17    ThinkingConfig, ThinkingMode, ToolChoice, Usage,
18};
19use anyhow::{Context, Result};
20use async_trait::async_trait;
21use base64::Engine;
22use futures::{SinkExt, StreamExt};
23use reqwest::StatusCode;
24use serde::{Deserialize, Serialize};
25use std::collections::{HashMap, HashSet};
26use std::sync::Arc;
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::time::{Duration, Instant};
29use tokio::net::TcpStream;
30use tokio::sync::Mutex;
31use tokio::time::timeout;
32use tokio_tungstenite::connect_async;
33use tokio_tungstenite::tungstenite::Message as WebSocketMessage;
34use tokio_tungstenite::tungstenite::client::IntoClientRequest;
35use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
36
37const DEFAULT_BASE_URL: &str = "https://chatgpt.com/backend-api";
38
39/// Connect timeout for the HTTP and WebSocket transports.
40const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
41/// Bound on a single WebSocket frame read / send. A peer that stops sending must
42/// not wedge the turn (and, before the lock was released per-turn, the whole
43/// session) indefinitely.
44const WEBSOCKET_IO_TIMEOUT: Duration = Duration::from_mins(2);
45/// Upper bound on the number of cached WebSocket sessions retained at once.
46const MAX_WEBSOCKET_SESSIONS: usize = 512;
47/// Environment variable that forces the Codex provider onto the HTTP transport,
48/// skipping the WebSocket path entirely. Set to a truthy value (`1`, `true`,
49/// `yes`, `on`) when the environment cannot complete the `wss` upgrade (a
50/// corporate proxy / firewall that black-holes websockets). Honored at provider
51/// construction.
52const OPENAI_CODEX_DISABLE_WEBSOCKETS_ENV: &str = "OPENAI_CODEX_DISABLE_WEBSOCKETS";
53
54/// Interpret a raw environment-variable value as a boolean. Absent or
55/// unrecognized values are treated as `false` so the default (WebSocket-first)
56/// behavior is preserved. Split out from the env read so the truthiness logic is
57/// unit-testable without mutating the process environment (`std::env::set_var`
58/// is `unsafe`, which `#![forbid(unsafe_code)]` rejects even in tests).
59fn parse_disable_websockets_value(value: Option<&str>) -> bool {
60    value.is_some_and(|value| {
61        matches!(
62            value.trim().to_ascii_lowercase().as_str(),
63            "1" | "true" | "yes" | "on"
64        )
65    })
66}
67
68/// Read [`OPENAI_CODEX_DISABLE_WEBSOCKETS_ENV`] as a boolean.
69fn websockets_disabled_from_env() -> bool {
70    parse_disable_websockets_value(
71        std::env::var(OPENAI_CODEX_DISABLE_WEBSOCKETS_ENV)
72            .ok()
73            .as_deref(),
74    )
75}
76
77/// Build an HTTP client with a connect/keepalive timeout, matching the sibling
78/// providers (`anthropic`, `vertex`). A bare `reqwest::Client::new()` has no
79/// connect timeout, so a black-holed connect would wedge `chat`/`chat_stream`.
80fn build_http_client() -> reqwest::Client {
81    reqwest::Client::builder()
82        .connect_timeout(CONNECT_TIMEOUT)
83        .tcp_keepalive(CONNECT_TIMEOUT)
84        .build()
85        .unwrap_or_default()
86}
87const OPENAI_CODEX_JWT_CLAIM_PATH: &str = "https://api.openai.com/auth";
88const OPENAI_CODEX_ORIGINATOR: &str = "codex_cli_rs";
89const OPENAI_CODEX_RESPONSES_BETA_HEADER: &str = "responses=experimental";
90const OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER: &str = "responses_websockets=2026-02-06";
91const OPENAI_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
92const OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE: &str =
93    "websocket_connection_limit_reached";
94const OPENAI_RESPONSES_REASONING_PROVIDER: &str = "openai-responses";
95const OPENAI_MESSAGE_ITEM_TYPE: &str = "message";
96
97// GPT-5.4 (frontier reasoning with 1.05M context)
98pub const MODEL_GPT54: &str = "gpt-5.4";
99
100// GPT-5.3-Codex (latest Codex model)
101pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
102
103// GPT-5.2-Codex (legacy Responses-first codex model)
104pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
105
106/// Reasoning effort level for the model.
107#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
108#[serde(rename_all = "lowercase")]
109pub enum ReasoningEffort {
110    Low,
111    #[default]
112    Medium,
113    High,
114    /// Extra-high reasoning for complex problems
115    #[serde(rename = "xhigh")]
116    XHigh,
117    /// GPT-5.6 maximum reasoning effort
118    Max,
119}
120
121/// `OpenAI` Codex / `ChatGPT` subscription provider.
122///
123/// This provider uses the `ChatGPT` Codex backend (`/backend-api/codex/responses`)
124/// and requires an OAuth access token obtained from the `ChatGPT` Plus/Pro login flow.
125#[derive(Clone)]
126pub struct OpenAICodexResponsesProvider {
127    client: reqwest::Client,
128    api_key: String,
129    model: String,
130    base_url: String,
131    thinking: Option<ThinkingConfig>,
132    account_id: Option<String>,
133    websocket_sessions: Arc<Mutex<HashMap<String, Arc<Mutex<WebsocketSessionState>>>>>,
134    /// Hard opt-out: when set, the WebSocket transport is never attempted and
135    /// every turn goes straight to HTTP. Sourced from
136    /// [`with_websockets_disabled`](Self::with_websockets_disabled) or the
137    /// [`OPENAI_CODEX_DISABLE_WEBSOCKETS_ENV`] environment variable.
138    websockets_disabled: bool,
139    /// Cross-session "websockets don't work in this environment" memory. The
140    /// per-session [`WebsocketSessionState::websocket_disabled`] flag only
141    /// protects the session that hit the failure, so a fresh session would
142    /// re-pay the full connect/warmup timeout penalty. A *transport*
143    /// (connectivity) failure latches this provider-level flag so every
144    /// subsequent session skips the WebSocket attempt and goes straight to
145    /// HTTP. The environment self-heals after the first failed session instead
146    /// of stalling on every one. Latches once per process and never resets — a
147    /// genuinely WS-hostile network does not recover mid-process, and a
148    /// once-per-process latch is the simplest correct behavior. Auth/request
149    /// failures (401/client errors) deliberately do *not* set this flag.
150    websockets_unhealthy: Arc<AtomicBool>,
151}
152
153type CodexWebSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
154
155#[derive(Default)]
156struct WebsocketSessionState {
157    connection: Option<CodexWebSocket>,
158    last_request: Option<ApiStreamingRequest>,
159    last_response_id: Option<String>,
160    last_response_items: Vec<ApiInputItem>,
161    turn_state: Option<String>,
162    prewarmed: bool,
163    websocket_disabled: bool,
164    /// Set while a turn is mid-flight on this session and cleared when it
165    /// completes cleanly. If a turn's stream is dropped (cancellation), this
166    /// stays set; the next turn detects the abandoned turn on lock acquisition,
167    /// discards the half-consumed connection + stale incremental baseline, and
168    /// reconnects so the cancelled turn can't poison it.
169    in_flight: bool,
170    /// Last time this session was touched, for LRU eviction of the bounded map.
171    last_used: Option<Instant>,
172}
173
174impl OpenAICodexResponsesProvider {
175    /// Create a new `OpenAI` Codex provider.
176    #[must_use]
177    pub fn new(api_key: String, model: String) -> Self {
178        Self {
179            client: build_http_client(),
180            api_key,
181            model,
182            base_url: DEFAULT_BASE_URL.to_owned(),
183            thinking: None,
184            account_id: None,
185            websocket_sessions: Arc::new(Mutex::new(HashMap::new())),
186            websockets_disabled: websockets_disabled_from_env(),
187            websockets_unhealthy: Arc::new(AtomicBool::new(false)),
188        }
189    }
190
191    /// Create a provider with a custom base URL.
192    #[must_use]
193    pub fn with_base_url(api_key: String, model: String, base_url: String) -> Self {
194        Self {
195            client: build_http_client(),
196            api_key,
197            model,
198            base_url,
199            thinking: None,
200            account_id: None,
201            websocket_sessions: Arc::new(Mutex::new(HashMap::new())),
202            websockets_disabled: websockets_disabled_from_env(),
203            websockets_unhealthy: Arc::new(AtomicBool::new(false)),
204        }
205    }
206
207    /// Create a provider using GPT-5.3-Codex (latest codex model).
208    #[must_use]
209    pub fn gpt53_codex(api_key: String) -> Self {
210        Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
211    }
212
213    /// Create a provider using the latest Codex model.
214    #[must_use]
215    pub fn codex(api_key: String) -> Self {
216        Self::gpt53_codex(api_key)
217    }
218
219    /// Create a provider using GPT-5.4 (frontier reasoning with 1.05M context).
220    #[must_use]
221    pub fn gpt54(api_key: String) -> Self {
222        Self::new(api_key, MODEL_GPT54.to_owned())
223    }
224
225    /// Set the provider-owned thinking configuration for this model.
226    #[must_use]
227    pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
228        self.thinking = Some(thinking);
229        self
230    }
231
232    /// Set a known `ChatGPT` account id, avoiding JWT decoding on each request.
233    #[must_use]
234    pub fn with_account_id(mut self, account_id: impl Into<String>) -> Self {
235        self.account_id = Some(account_id.into());
236        self
237    }
238
239    /// Set the reasoning effort level.
240    #[must_use]
241    pub fn with_reasoning_effort(self, effort: ReasoningEffort) -> Self {
242        self.with_thinking(ThinkingConfig::default().with_effort(map_reasoning_effort(effort)))
243    }
244
245    /// Force the HTTP transport, skipping the WebSocket path entirely.
246    ///
247    /// In a WebSocket-hostile environment (a corporate proxy / firewall that
248    /// black-holes the `wss` upgrade) the WebSocket-first transport stalls for
249    /// up to the connect + warmup timeout budget on every fresh session before
250    /// falling back to HTTP. An operator who knows their network cannot do
251    /// `wss` can set this to skip the penalty entirely. The
252    /// `OPENAI_CODEX_DISABLE_WEBSOCKETS` environment variable does the
253    /// same without a code change.
254    #[must_use]
255    pub const fn with_websockets_disabled(mut self, disabled: bool) -> Self {
256        self.websockets_disabled = disabled;
257        self
258    }
259
260    /// Whether the WebSocket transport should be skipped for this turn: either
261    /// it was hard-disabled (builder / env) or a prior session in this process
262    /// already proved the environment cannot complete the `wss` transport.
263    fn skip_websocket(&self) -> bool {
264        self.websockets_disabled || self.websockets_unhealthy.load(Ordering::Relaxed)
265    }
266
267    /// The `ChatGPT`-backend Codex Responses contract does not accept
268    /// `max_output_tokens` — the backend manages the output budget and
269    /// rejects the parameter with `400 InvalidRequest` ("Unsupported
270    /// parameter: `max_output_tokens`", verified live 2026-07-10 against
271    /// `gpt-5.4` on a `ChatGPT` account). The official Codex CLI never
272    /// sends it, and the reverse-engineered request contract (bip's
273    /// `docs/codex-oauth-study-2026-06-13.md`) carries no such field. The
274    /// caller's `max_tokens` is therefore intentionally dropped on this
275    /// transport regardless of `max_tokens_explicit` — hosts like
276    /// `agent-server` always mark a resolved default as explicit, which
277    /// would otherwise fail every daemon-path Codex turn.
278    const fn max_output_tokens(_request: &ChatRequest) -> Option<u32> {
279        None
280    }
281
282    fn build_headers(
283        &self,
284        streaming: bool,
285        session_id: Option<&str>,
286        turn_state: Option<&str>,
287    ) -> Result<reqwest::header::HeaderMap> {
288        self.build_headers_with_beta(
289            streaming,
290            session_id,
291            OPENAI_CODEX_RESPONSES_BETA_HEADER,
292            turn_state,
293        )
294    }
295
296    fn build_websocket_headers(
297        &self,
298        session_id: Option<&str>,
299        turn_state: Option<&str>,
300    ) -> Result<reqwest::header::HeaderMap> {
301        self.build_headers_with_beta(
302            false,
303            session_id,
304            OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER,
305            turn_state,
306        )
307    }
308
309    fn build_headers_with_beta(
310        &self,
311        streaming: bool,
312        session_id: Option<&str>,
313        beta_header: &'static str,
314        turn_state: Option<&str>,
315    ) -> Result<reqwest::header::HeaderMap> {
316        use reqwest::header::{
317            ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT,
318        };
319
320        let account_id = self
321            .account_id
322            .clone()
323            .map_or_else(|| extract_account_id(&self.api_key), Ok)
324            .context("failed to extract chatgpt account id from OpenAI Codex OAuth token")?;
325
326        let mut headers = HeaderMap::new();
327        headers.insert(
328            AUTHORIZATION,
329            HeaderValue::from_str(&format!("Bearer {}", self.api_key))?,
330        );
331        headers.insert("chatgpt-account-id", HeaderValue::from_str(&account_id)?);
332        headers.insert("OpenAI-Beta", HeaderValue::from_static(beta_header));
333        headers.insert(
334            "originator",
335            HeaderValue::from_static(OPENAI_CODEX_ORIGINATOR),
336        );
337        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
338        headers.insert(
339            USER_AGENT,
340            HeaderValue::from_str(&format!(
341                "{OPENAI_CODEX_ORIGINATOR}/{} ({} {})",
342                env!("CARGO_PKG_VERSION"),
343                std::env::consts::OS,
344                std::env::consts::ARCH,
345            ))?,
346        );
347        if streaming {
348            headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
349        }
350        if let Some(session_id) = session_id {
351            let session_id_header = HeaderValue::from_str(session_id)?;
352            headers.insert("session_id", session_id_header.clone());
353            headers.insert("x-client-request-id", session_id_header);
354        }
355        if let Some(turn_state) = turn_state {
356            headers.insert(
357                OPENAI_CODEX_TURN_STATE_HEADER,
358                HeaderValue::from_str(turn_state)?,
359            );
360        }
361
362        Ok(headers)
363    }
364
365    async fn websocket_session(&self, session_id: &str) -> Arc<Mutex<WebsocketSessionState>> {
366        let mut sessions = self.websocket_sessions.lock().await;
367        if !sessions.contains_key(session_id) && sessions.len() >= MAX_WEBSOCKET_SESSIONS {
368            evict_idle_sessions(&mut sessions);
369        }
370        sessions
371            .entry(session_id.to_string())
372            .or_insert_with(|| Arc::new(Mutex::new(WebsocketSessionState::default())))
373            .clone()
374    }
375
376    async fn connect_websocket(
377        &self,
378        session_id: Option<&str>,
379        turn_state: Option<&str>,
380    ) -> Result<(CodexWebSocket, Option<String>)> {
381        let headers = self.build_websocket_headers(session_id, turn_state)?;
382        let url = codex_websocket_url(&self.base_url)
383            .context("failed to build OpenAI Codex websocket URL")?;
384        let mut request = url
385            .as_str()
386            .into_client_request()
387            .context("failed to build OpenAI Codex websocket request")?;
388        request.headers_mut().extend(headers);
389
390        let (stream, response) = timeout(CONNECT_TIMEOUT, connect_async(request))
391            .await
392            .context("OpenAI Codex websocket connect timed out")?
393            .context("failed to connect OpenAI Codex websocket")?;
394        let turn_state = response
395            .headers()
396            .get(OPENAI_CODEX_TURN_STATE_HEADER)
397            .and_then(|value| value.to_str().ok())
398            .map(ToOwned::to_owned);
399        Ok((stream, turn_state))
400    }
401
402    #[cfg(test)]
403    fn map_response(api_response: ApiResponse) -> ChatResponse {
404        let refused = output_contains_refusal(&api_response.output);
405        let mut content = build_content_blocks(&api_response.output);
406        let has_tool_calls = content
407            .iter()
408            .any(|block| matches!(block, ContentBlock::ToolUse { .. }));
409        let stop_reason = if matches!(api_response.status, Some(ApiStatus::Incomplete)) {
410            Some(
411                api_response
412                    .incomplete_details
413                    .as_ref()
414                    .and_then(|details| details.reason.as_deref())
415                    .map_or(StopReason::Unknown, incomplete_stop_reason),
416            )
417        } else if refused {
418            Some(StopReason::Refusal)
419        } else if has_tool_calls {
420            Some(StopReason::ToolUse)
421        } else {
422            api_response.status.map(|status| match status {
423                ApiStatus::Completed => StopReason::EndTurn,
424                // Terminal failures and any non-terminal/unknown status
425                // that leaked into a final response map to Unknown, exactly
426                // as the old Incomplete|Failed arm did. Spelled out per
427                // variant so adding a status forces a decision here.
428                ApiStatus::Incomplete
429                | ApiStatus::Failed
430                | ApiStatus::InProgress
431                | ApiStatus::Queued
432                | ApiStatus::Cancelled
433                | ApiStatus::Other => StopReason::Unknown,
434            })
435        };
436
437        if stop_reason != Some(StopReason::ToolUse) {
438            content.retain(|block| !matches!(block, ContentBlock::ToolUse { .. }));
439        }
440
441        ChatResponse {
442            id: api_response.id,
443            content,
444            model: api_response.model,
445            stop_reason,
446            usage: api_response.usage.map_or(
447                Usage {
448                    input_tokens: 0,
449                    output_tokens: 0,
450                    cached_input_tokens: 0,
451                    cache_creation_input_tokens: 0,
452                },
453                |usage| usage_from_api_usage(&usage),
454            ),
455        }
456    }
457}
458
459#[async_trait]
460impl LlmProvider for OpenAICodexResponsesProvider {
461    async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
462        // The ChatGPT Codex backend requires every Responses request to stream,
463        // including callers such as context compaction that need one collected
464        // response. Reuse the SSE implementation so the wire request carries
465        // `stream: true`, then collect its deltas back into `ChatOutcome`.
466        crate::provider::collect_stream(self.chat_stream(request), self.model.clone()).await
467    }
468
469    #[allow(clippy::too_many_lines)]
470    fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
471        Box::pin(async_stream::stream! {
472            let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
473                Ok(thinking) => thinking,
474                Err(error) => {
475                    yield Ok(StreamDelta::Error {
476                        message: error.to_string(),
477                        kind: StreamErrorKind::InvalidRequest,
478                    });
479                    return;
480                }
481            };
482            if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
483                yield Ok(StreamDelta::Error {
484                    message: error.to_string(),
485                    kind: StreamErrorKind::InvalidRequest,
486                });
487                return;
488            }
489
490            let reasoning = build_api_reasoning(thinking_config.as_ref());
491            let input = build_api_input(&request);
492            let max_output_tokens = Self::max_output_tokens(&request);
493            let tools: Option<Vec<ApiTool>> = request
494                .tools
495                .as_ref()
496                .map(|ts| ts.iter().cloned().map(convert_tool).collect());
497            let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
498            let text_format = request.response_format.as_ref().map(ApiResponseTextFormat::from);
499            let tool_choice = codex_tool_choice(request.tool_choice.as_ref());
500            let api_request = ApiStreamingRequest {
501                model: self.model.clone(),
502                instructions: request.system.clone(),
503                input,
504                tools,
505                max_output_tokens,
506                reasoning,
507                tool_choice: Some(tool_choice),
508                parallel_tool_calls: parallel_tool_calls.then_some(true),
509                store: false,
510                text: Some(ApiTextSettings { verbosity: "medium", format: text_format }),
511                include: Some(vec!["reasoning.encrypted_content".to_string()]),
512                prompt_cache_key: request.session_id.clone(),
513                stream: true,
514            };
515
516            log::debug!("OpenAI Codex streaming request model={} max_tokens={}", self.model, request.max_tokens);
517
518            let mut sse_turn_state: Option<String> = None;
519
520            // Skip the WebSocket transport entirely when it is hard-disabled
521            // (builder / env) or a prior session already proved this environment
522            // cannot complete the `wss` transport. The turn falls through to the
523            // HTTP request path below without paying any WebSocket connect /
524            // warmup timeout penalty.
525            if let Some(session_id) = request.session_id.as_deref().filter(|_| !self.skip_websocket()) {
526                let session = self.websocket_session(session_id).await;
527                let mut websocket_session = session.lock().await;
528
529                // Latched on a TRANSPORT/connectivity failure (connect timeout,
530                // upgrade failure, warmup connection-closed/timeout, mid-stream
531                // disconnect before output) so every later session in this
532                // process skips the WebSocket attempt. Auth/request failures
533                // (401 / client-error wrapped events) deliberately do NOT latch
534                // this — a transient auth blip must not force HTTP-only forever.
535                let mark_websocket_transport_unhealthy = || {
536                    self.websockets_unhealthy.store(true, Ordering::Relaxed);
537                };
538
539                // If the previous turn for this session was abandoned mid-flight
540                // (its stream was dropped — the SDK's cancellation mechanism), its
541                // half-consumed connection and incremental baseline are stale.
542                // Discard them so we reconnect fresh and never read the cancelled
543                // turn's trailing frames or pair a new request with its response id.
544                if websocket_session.in_flight {
545                    log::warn!(
546                        "OpenAI Codex session {session_id} had an abandoned in-flight turn; resetting websocket state"
547                    );
548                    reset_websocket_connection(&mut websocket_session);
549                    websocket_session.last_request = None;
550                    websocket_session.last_response_id = None;
551                    websocket_session.last_response_items.clear();
552                }
553                websocket_session.in_flight = true;
554                websocket_session.last_used = Some(Instant::now());
555
556                if !websocket_session.websocket_disabled {
557                    'websocket_attempts: for attempt in 0..2 {
558                        if websocket_session.connection.is_none() {
559                            match self
560                                .connect_websocket(
561                                    Some(session_id),
562                                    websocket_session.turn_state.as_deref(),
563                                )
564                                .await
565                            {
566                                Ok((connection, turn_state)) => {
567                                    websocket_session.connection = Some(connection);
568                                    if let Some(turn_state) = turn_state {
569                                        websocket_session.turn_state = Some(turn_state);
570                                    }
571                                    websocket_session.prewarmed = false;
572                                }
573                                Err(error) => {
574                                    log::warn!(
575                                        "OpenAI Codex websocket connect failed on attempt {}: {error:#}",
576                                        attempt + 1,
577                                    );
578                                    if attempt == 1 {
579                                        websocket_session.websocket_disabled = true;
580                                        mark_websocket_transport_unhealthy();
581                                    }
582                                    continue;
583                                }
584                            }
585                        }
586
587                        if websocket_session.connection.is_some()
588                            && websocket_session.last_request.is_none()
589                            && !websocket_session.prewarmed
590                        {
591                            let mut warmup_request = ApiWebsocketRequest::from(&api_request);
592                            warmup_request.generate = Some(false);
593                            let warmup_payload = match serde_json::to_string(&warmup_request) {
594                                Ok(payload) => payload,
595                                Err(error) => {
596                                    yield Ok(StreamDelta::Error {
597                                        message: format!(
598                                            "failed to encode websocket warmup request: {error}"
599                                        ),
600                                        kind: StreamErrorKind::InvalidRequest,
601                                    });
602                                    return;
603                                }
604                            };
605
606                            let warmup_send_result = if let Some(connection) =
607                                websocket_session.connection.as_mut()
608                            {
609                                timeout(
610                                    WEBSOCKET_IO_TIMEOUT,
611                                    connection.send(WebSocketMessage::Text(warmup_payload.into())),
612                                )
613                                .await
614                                .unwrap_or(Err(
615                                    tokio_tungstenite::tungstenite::Error::ConnectionClosed,
616                                ))
617                            } else {
618                                Err(tokio_tungstenite::tungstenite::Error::ConnectionClosed)
619                            };
620
621                            if let Err(error) = warmup_send_result {
622                                log::warn!(
623                                    "OpenAI Codex websocket warmup send failed on attempt {}: {error}",
624                                    attempt + 1,
625                                );
626                                reset_websocket_connection(&mut websocket_session);
627                                if attempt == 1 {
628                                    websocket_session.websocket_disabled = true;
629                                    mark_websocket_transport_unhealthy();
630                                }
631                                continue;
632                            }
633
634                            let mut warmup_response_id: Option<String> = None;
635                            let mut warmup_response_items = Vec::new();
636
637                            loop {
638                                let message_result = if let Some(connection) =
639                                    websocket_session.connection.as_mut()
640                                {
641                                    timeout(WEBSOCKET_IO_TIMEOUT, connection.next()).await.unwrap_or_else(|_| {
642                                        log::warn!("OpenAI Codex websocket warmup read timed out");
643                                        None
644                                    })
645                                } else {
646                                    None
647                                };
648                                let Some(message_result) = message_result else {
649                                    log::warn!(
650                                        "OpenAI Codex websocket warmup closed before completion on attempt {}",
651                                        attempt + 1,
652                                    );
653                                    reset_websocket_connection(&mut websocket_session);
654                                    if attempt == 1 {
655                                        websocket_session.websocket_disabled = true;
656                                        mark_websocket_transport_unhealthy();
657                                    }
658                                    continue 'websocket_attempts;
659                                };
660
661                                let message = match message_result {
662                                    Ok(message) => message,
663                                    Err(error) => {
664                                        log::warn!(
665                                            "OpenAI Codex websocket warmup failed on attempt {}: {error}",
666                                            attempt + 1,
667                                        );
668                                        reset_websocket_connection(&mut websocket_session);
669                                        if attempt == 1 {
670                                            websocket_session.websocket_disabled = true;
671                                            mark_websocket_transport_unhealthy();
672                                        }
673                                        continue 'websocket_attempts;
674                                    }
675                                };
676
677                                match message {
678                                    WebSocketMessage::Text(text) => {
679                                        if let Some(error) =
680                                            parse_wrapped_websocket_error_event(&text)
681                                        {
682                                            log::warn!(
683                                                "OpenAI Codex websocket warmup wrapped error on attempt {} status={} message={}",
684                                                attempt + 1,
685                                                error.status,
686                                                error.message,
687                                            );
688                                            // A quota rejection applies to the request, not to
689                                            // this socket: reconnecting or falling back to HTTP
690                                            // would spend another request inside the window the
691                                            // service asked us to sit out. Surface it with its
692                                            // delay instead. The connection-limit sentinel shares
693                                            // the status but is a transport condition, so it keeps
694                                            // its immediate fallback.
695                                            if is_websocket_quota_rejection(&error) {
696                                                let kind = websocket_error_kind(&error);
697                                                end_websocket_turn(&mut websocket_session);
698                                                yield Ok(StreamDelta::Error {
699                                                    message: error.message,
700                                                    kind,
701                                                });
702                                                return;
703                                            }
704                                            if error.status == StatusCode::UNAUTHORIZED
705                                                || error.status == StatusCode::UPGRADE_REQUIRED
706                                                || error.status.is_client_error()
707                                            {
708                                                websocket_session.websocket_disabled = true;
709                                            }
710                                            reset_websocket_connection(&mut websocket_session);
711                                            continue 'websocket_attempts;
712                                        }
713                                        let event = match decode_stream_event(&text) {
714                                            Ok(event) => event,
715                                            Err(error) => {
716                                                end_websocket_turn(
717                                                    &mut websocket_session,
718                                                );
719                                                yield Ok(StreamDelta::Error {
720                                                    message: error.to_string(),
721                                                    kind: StreamErrorKind::ServerError,
722                                                });
723                                                return;
724                                            }
725                                        };
726                                            match event.r#type.as_str() {
727                                                "response.output_item.done" => {
728                                                    let item = match decode_output_item(event.item) {
729                                                        Ok(item) => item,
730                                                        Err(error) => {
731                                                            end_websocket_turn(
732                                                                &mut websocket_session,
733                                                            );
734                                                            yield Ok(StreamDelta::Error {
735                                                                message: error.to_string(),
736                                                                kind: StreamErrorKind::ServerError,
737                                                            });
738                                                            return;
739                                                        }
740                                                    };
741                                                    if let Some(item) = output_item_to_input_item(item) {
742                                                        warmup_response_items.push(item);
743                                                    }
744                                                }
745                                                "response.completed" | "response.done" => {
746                                                    if let Some(resp) = event.response
747                                                        && let Some(id) = resp.id
748                                                    {
749                                                        warmup_response_id = Some(id);
750                                                    }
751                                                    websocket_session.last_request =
752                                                        Some(api_request.clone());
753                                                    websocket_session.last_response_id =
754                                                        warmup_response_id;
755                                                    websocket_session.last_response_items =
756                                                        warmup_response_items;
757                                                    websocket_session.prewarmed = true;
758                                                    break;
759                                                }
760                                                "response.incomplete" | "response.failed" => {
761                                                    log::warn!(
762                                                        "OpenAI Codex websocket warmup returned {} on attempt {}",
763                                                        event.r#type,
764                                                        attempt + 1,
765                                                    );
766                                                    reset_websocket_connection(&mut websocket_session);
767                                                    if attempt == 1 {
768                                                        websocket_session.websocket_disabled = true;
769                                                        mark_websocket_transport_unhealthy();
770                                                    }
771                                                    continue 'websocket_attempts;
772                                                }
773                                                _ => {}
774                                            }
775                                    }
776                                    WebSocketMessage::Binary(bytes) => {
777                                        let text = match String::from_utf8(bytes.to_vec()) {
778                                            Ok(text) => text,
779                                            Err(error) => {
780                                                end_websocket_turn(
781                                                    &mut websocket_session,
782                                                );
783                                                yield Ok(StreamDelta::Error {
784                                                    message: format!(
785                                                        "invalid OpenAI Codex websocket UTF-8: {error}"
786                                                    ),
787                                                    kind: StreamErrorKind::ServerError,
788                                                });
789                                                return;
790                                            }
791                                        };
792                                            if let Some(error) =
793                                                parse_wrapped_websocket_error_event(&text)
794                                            {
795                                                log::warn!(
796                                                    "OpenAI Codex websocket warmup wrapped error on attempt {} status={} message={}",
797                                                    attempt + 1,
798                                                    error.status,
799                                                    error.message,
800                                                );
801                                                // Same split as the text warmup frame: a quota
802                                                // rejection is surfaced with its delay; the
803                                                // connection-limit sentinel falls back at once.
804                                                if is_websocket_quota_rejection(&error) {
805                                                    let kind = websocket_error_kind(&error);
806                                                    end_websocket_turn(&mut websocket_session);
807                                                    yield Ok(StreamDelta::Error {
808                                                        message: error.message,
809                                                        kind,
810                                                    });
811                                                    return;
812                                                }
813                                                if error.status == StatusCode::UNAUTHORIZED
814                                                    || error.status == StatusCode::UPGRADE_REQUIRED
815                                                    || error.status.is_client_error()
816                                                {
817                                                    websocket_session.websocket_disabled = true;
818                                                }
819                                                reset_websocket_connection(&mut websocket_session);
820                                                continue 'websocket_attempts;
821                                            }
822
823                                            let event = match decode_stream_event(&text) {
824                                                Ok(event) => event,
825                                                Err(error) => {
826                                                    end_websocket_turn(
827                                                        &mut websocket_session,
828                                                    );
829                                                    yield Ok(StreamDelta::Error {
830                                                        message: error.to_string(),
831                                                        kind: StreamErrorKind::ServerError,
832                                                    });
833                                                    return;
834                                                }
835                                            };
836                                                match event.r#type.as_str() {
837                                                    "response.output_item.done" => {
838                                                        let item = match decode_output_item(event.item) {
839                                                            Ok(item) => item,
840                                                            Err(error) => {
841                                                                end_websocket_turn(
842                                                                    &mut websocket_session,
843                                                                );
844                                                                yield Ok(StreamDelta::Error {
845                                                                    message: error.to_string(),
846                                                                    kind: StreamErrorKind::ServerError,
847                                                                });
848                                                                return;
849                                                            }
850                                                        };
851                                                        if let Some(item) = output_item_to_input_item(item) {
852                                                            warmup_response_items.push(item);
853                                                        }
854                                                    }
855                                                    "response.completed" | "response.done" => {
856                                                        if let Some(resp) = event.response
857                                                            && let Some(id) = resp.id
858                                                        {
859                                                            warmup_response_id = Some(id);
860                                                        }
861                                                        websocket_session.last_request =
862                                                            Some(api_request.clone());
863                                                        websocket_session.last_response_id =
864                                                            warmup_response_id;
865                                                        websocket_session.last_response_items =
866                                                            warmup_response_items;
867                                                        websocket_session.prewarmed = true;
868                                                        break;
869                                                    }
870                                                    "response.incomplete" | "response.failed" => {
871                                                        log::warn!(
872                                                            "OpenAI Codex websocket warmup returned {} on attempt {}",
873                                                            event.r#type,
874                                                            attempt + 1,
875                                                        );
876                                                        reset_websocket_connection(&mut websocket_session);
877                                                        if attempt == 1 {
878                                                            websocket_session.websocket_disabled = true;
879                                                            mark_websocket_transport_unhealthy();
880                                                        }
881                                                        continue 'websocket_attempts;
882                                                    }
883                                                    _ => {}
884                                                }
885                                    }
886                                    WebSocketMessage::Ping(payload) => {
887                                        if let Some(connection) =
888                                            websocket_session.connection.as_mut()
889                                            && let Err(error) = connection
890                                                .send(WebSocketMessage::Pong(payload))
891                                                .await
892                                        {
893                                            log::warn!(
894                                                "OpenAI Codex websocket warmup pong failed on attempt {}: {error}",
895                                                attempt + 1,
896                                            );
897                                            reset_websocket_connection(&mut websocket_session);
898                                            if attempt == 1 {
899                                                websocket_session.websocket_disabled = true;
900                                                mark_websocket_transport_unhealthy();
901                                            }
902                                            continue 'websocket_attempts;
903                                        }
904                                    }
905                                    WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {}
906                                    WebSocketMessage::Close(_) => {
907                                        log::warn!(
908                                            "OpenAI Codex websocket warmup closed on attempt {}",
909                                            attempt + 1,
910                                        );
911                                        reset_websocket_connection(&mut websocket_session);
912                                        if attempt == 1 {
913                                            websocket_session.websocket_disabled = true;
914                                            mark_websocket_transport_unhealthy();
915                                        }
916                                        continue 'websocket_attempts;
917                                    }
918                                }
919                            }
920                        }
921
922                        let websocket_request = prepare_websocket_request(
923                            &api_request,
924                            &websocket_session,
925                            websocket_session.prewarmed,
926                        );
927                        let request_payload = match serde_json::to_string(&websocket_request) {
928                            Ok(payload) => payload,
929                            Err(error) => {
930                                yield Ok(StreamDelta::Error {
931                                    message: format!(
932                                        "failed to encode websocket request: {error}"
933                                    ),
934                                    kind: StreamErrorKind::InvalidRequest,
935                                });
936                                return;
937                            }
938                        };
939
940                        let send_result = if let Some(connection) = websocket_session.connection.as_mut() {
941                            timeout(
942                                WEBSOCKET_IO_TIMEOUT,
943                                connection.send(WebSocketMessage::Text(request_payload.into())),
944                            )
945                            .await
946                            .unwrap_or(Err(
947                                tokio_tungstenite::tungstenite::Error::ConnectionClosed,
948                            ))
949                        } else {
950                            Err(tokio_tungstenite::tungstenite::Error::ConnectionClosed)
951                        };
952
953                        if let Err(error) = send_result {
954                            log::warn!(
955                                "OpenAI Codex websocket send failed on attempt {}: {error}",
956                                attempt + 1,
957                            );
958                            reset_websocket_connection(&mut websocket_session);
959                            if attempt == 1 {
960                                websocket_session.websocket_disabled = true;
961                                mark_websocket_transport_unhealthy();
962                            }
963                            continue;
964                        }
965
966                        let mut usage: Option<Usage> = None;
967                        let mut tool_calls: HashMap<String, ToolCallAccumulator> = HashMap::new();
968                        let mut response_id: Option<String> = None;
969                        let mut response_items = Vec::new();
970                        let mut streamed_reasoning_summaries = HashSet::new();
971                        let mut emitted_output = false;
972                        let mut refused = false;
973
974                        loop {
975                            let message_result = if let Some(connection) =
976                                websocket_session.connection.as_mut()
977                            {
978                                timeout(WEBSOCKET_IO_TIMEOUT, connection.next()).await.unwrap_or_else(|_| {
979                                    log::warn!("OpenAI Codex websocket read timed out");
980                                    None
981                                })
982                            } else {
983                                None
984                            };
985                            let Some(message_result) = message_result else {
986                                if emitted_output {
987                                    end_websocket_turn(&mut websocket_session);
988                                    yield Ok(StreamDelta::Error {
989                                        message: "websocket closed before response.completed"
990                                            .to_string(),
991                                        kind: StreamErrorKind::ConnectionLost,
992                                    });
993                                    return;
994                                }
995                                reset_websocket_connection(&mut websocket_session);
996                                if attempt == 1 {
997                                    websocket_session.websocket_disabled = true;
998                                    mark_websocket_transport_unhealthy();
999                                }
1000                                continue 'websocket_attempts;
1001                            };
1002
1003                            let message = match message_result {
1004                                Ok(message) => message,
1005                                Err(error) => {
1006                                    if emitted_output {
1007                                        end_websocket_turn(&mut websocket_session);
1008                                        yield Ok(StreamDelta::Error {
1009                                            message: format!("websocket error: {error}"),
1010                                            kind: websocket_transport_error_kind(&error),
1011                                        });
1012                                        return;
1013                                    }
1014                                    reset_websocket_connection(&mut websocket_session);
1015                                    if attempt == 1 {
1016                                        websocket_session.websocket_disabled = true;
1017                                        mark_websocket_transport_unhealthy();
1018                                    }
1019                                    continue 'websocket_attempts;
1020                                }
1021                            };
1022
1023                            match message {
1024                                WebSocketMessage::Text(text) => {
1025                                    if let Some(error) = parse_wrapped_websocket_error_event(&text)
1026                                    {
1027                                        let kind = websocket_error_kind(&error);
1028                                        // A quota rejection carries the delay the service wants
1029                                        // observed, so it is surfaced (with its hint) for the
1030                                        // caller's retry loop rather than re-sent immediately.
1031                                        // The connection-limit signal shares the status but is a
1032                                        // transport condition, so it still falls back at once.
1033                                        if emitted_output || is_websocket_quota_rejection(&error) {
1034                                            end_websocket_turn(&mut websocket_session);
1035                                            yield Ok(StreamDelta::Error {
1036                                                message: error.message,
1037                                                kind,
1038                                            });
1039                                            return;
1040                                        }
1041                                        if error.status == StatusCode::UNAUTHORIZED
1042                                            || error.status == StatusCode::UPGRADE_REQUIRED
1043                                            || error.status.is_client_error()
1044                                        {
1045                                            websocket_session.websocket_disabled = true;
1046                                        }
1047                                        reset_websocket_connection(&mut websocket_session);
1048                                        continue 'websocket_attempts;
1049                                    }
1050                                    let event = match decode_stream_event(&text) {
1051                                        Ok(event) => event,
1052                                        Err(error) => {
1053                                            end_websocket_turn(&mut websocket_session);
1054                                            yield Ok(StreamDelta::Error {
1055                                                message: error.to_string(),
1056                                                kind: StreamErrorKind::ServerError,
1057                                            });
1058                                            return;
1059                                        }
1060                                    };
1061                                    match event.r#type.as_str() {
1062                                            "response.output_text.delta" => {
1063                                                if let Some(delta) = event.delta {
1064                                                    emitted_output = true;
1065                                                    yield Ok(StreamDelta::TextDelta {
1066                                                        delta,
1067                                                        block_index: output_block_index(event.output_index),
1068                                                    });
1069                                                }
1070                                            }
1071                                            "response.refusal.delta" => {
1072                                                refused = true;
1073                                                if let Some(delta) = event.delta {
1074                                                    emitted_output = true;
1075                                                    yield Ok(StreamDelta::TextDelta {
1076                                                        delta,
1077                                                        block_index: output_block_index(
1078                                                            event.output_index,
1079                                                        ),
1080                                                    });
1081                                                }
1082                                            }
1083                                            "response.reasoning_summary_text.delta" => {
1084                                                if let Some(delta) = event.delta {
1085                                                    let output_index = event.output_index.unwrap_or(0);
1086                                                    streamed_reasoning_summaries.insert(output_index);
1087                                                    emitted_output = true;
1088                                                    yield Ok(StreamDelta::ThinkingDelta {
1089                                                        delta,
1090                                                        block_index: reasoning_summary_block_index(
1091                                                            Some(output_index),
1092                                                        ),
1093                                                    });
1094                                                }
1095                                            }
1096                                            "response.function_call_arguments.delta" => {
1097                                                let block_index = event
1098                                                    .output_index
1099                                                    .map(|index| index.saturating_mul(2));
1100                                                if let (Some(call_id), Some(delta)) =
1101                                                    (event.call_id, event.delta)
1102                                                {
1103                                                    emitted_output = true;
1104                                                    let order = tool_calls.len();
1105                                                    let acc = tool_calls
1106                                                        .entry(call_id.clone())
1107                                                        .or_insert_with(|| ToolCallAccumulator {
1108                                                            id: call_id,
1109                                                            name: event.name.unwrap_or_default(),
1110                                                            arguments: String::new(),
1111                                                            order,
1112                                                            block_index,
1113                                                        });
1114                                                    acc.arguments.push_str(&delta);
1115                                                }
1116                                            }
1117                                            "response.output_item.done" => {
1118                                                let item = match decode_output_item(event.item) {
1119                                                    Ok(item) => item,
1120                                                    Err(error) => {
1121                                                        end_websocket_turn(
1122                                                            &mut websocket_session,
1123                                                        );
1124                                                        yield Ok(StreamDelta::Error {
1125                                                            message: error.to_string(),
1126                                                            kind: StreamErrorKind::ServerError,
1127                                                        });
1128                                                        return;
1129                                                    }
1130                                                };
1131                                                let block_index = event.output_index.unwrap_or(0);
1132                                                accumulate_completed_tool_call(
1133                                                    &item,
1134                                                    block_index,
1135                                                    &mut tool_calls,
1136                                                );
1137                                                let include_summary = !streamed_reasoning_summaries
1138                                                    .contains(&block_index);
1139                                                for delta in output_item_stream_deltas(
1140                                                    &item,
1141                                                    block_index,
1142                                                    include_summary,
1143                                                ) {
1144                                                    emitted_output = true;
1145                                                    yield Ok(delta);
1146                                                }
1147                                                if let Some(item) = output_item_to_input_item(item) {
1148                                                    response_items.push(item);
1149                                                }
1150                                            }
1151                                            "response.completed"
1152                                            | "response.incomplete"
1153                                            | "response.done" => {
1154                                                let response_status = event
1155                                                    .response
1156                                                    .as_ref()
1157                                                    .and_then(|response| response.status);
1158                                                let incomplete_reason = event
1159                                                    .response
1160                                                    .as_ref()
1161                                                    .and_then(|response| {
1162                                                        response.incomplete_details.as_ref()
1163                                                    })
1164                                                    .and_then(|details| details.reason.clone());
1165                                                if let Some(resp) = event.response {
1166                                                    if let Some(u) = resp.usage {
1167                                                        usage = Some(usage_from_api_usage(&u));
1168                                                    }
1169                                                    if let Some(id) = resp.id {
1170                                                        response_id = Some(id);
1171                                                    }
1172                                                }
1173                                                let final_status = match event.r#type.as_str() {
1174                                                    "response.incomplete" => {
1175                                                        Some(ApiStatus::Incomplete)
1176                                                    }
1177                                                    "response.done" => response_status
1178                                                        .or(Some(ApiStatus::Completed)),
1179                                                    _ => Some(ApiStatus::Completed),
1180                                                };
1181                                                let stop_reason = stop_reason_from_stream_state(
1182                                                    &tool_calls,
1183                                                    final_status,
1184                                                    refused,
1185                                                    incomplete_reason.as_deref(),
1186                                                );
1187                                                if stop_reason == Some(StopReason::ToolUse) {
1188                                                    for delta in
1189                                                        emit_accumulated_tool_calls(&tool_calls)
1190                                                    {
1191                                                        yield Ok(delta);
1192                                                    }
1193                                                }
1194                                                if let Some(u) = usage.take() {
1195                                                    yield Ok(StreamDelta::Usage(u));
1196                                                }
1197                                                websocket_session.last_request = Some(api_request.clone());
1198                                                websocket_session.last_response_id = response_id;
1199                                                websocket_session.last_response_items = response_items;
1200                                                websocket_session.prewarmed = false;
1201                                                // Clean completion: the turn is no
1202                                                // longer in flight, so the next turn
1203                                                // may reuse this connection/baseline.
1204                                                websocket_session.in_flight = false;
1205                                                yield Ok(StreamDelta::Done {
1206                                                    stop_reason,
1207                                                });
1208                                                return;
1209                                            }
1210                                            "response.failed" => {
1211                                                websocket_session.last_request = None;
1212                                                websocket_session.last_response_id = None;
1213                                                websocket_session.last_response_items.clear();
1214                                                websocket_session.prewarmed = false;
1215                                                // The turn ends here, so the session must stop
1216                                                // counting as in-flight or it can never be evicted.
1217                                                websocket_session.in_flight = false;
1218                                                let failure =
1219                                                    codex_response_failed_error(event.response);
1220                                                if let Some(usage) = failure.usage {
1221                                                    yield Ok(StreamDelta::Usage(usage));
1222                                                }
1223                                                yield Ok(StreamDelta::Error {
1224                                                    message: failure.message,
1225                                                    kind: failure.kind,
1226                                                });
1227                                                return;
1228                                            }
1229                                            _ => {}
1230                                    }
1231                                }
1232                                WebSocketMessage::Binary(bytes) => {
1233                                    let text = match String::from_utf8(bytes.to_vec()) {
1234                                        Ok(text) => text,
1235                                        Err(error) => {
1236                                            end_websocket_turn(&mut websocket_session);
1237                                            yield Ok(StreamDelta::Error {
1238                                                message: format!(
1239                                                    "invalid OpenAI Codex websocket UTF-8: {error}"
1240                                                ),
1241                                                kind: StreamErrorKind::ServerError,
1242                                            });
1243                                            return;
1244                                        }
1245                                    };
1246                                        if let Some(error) =
1247                                            parse_wrapped_websocket_error_event(&text)
1248                                        {
1249                                            let kind = websocket_error_kind(&error);
1250                                            // Same split as the text frame: a quota rejection is
1251                                            // surfaced with its delay; the connection-limit signal
1252                                            // keeps falling back to HTTP immediately.
1253                                            if emitted_output
1254                                                || is_websocket_quota_rejection(&error)
1255                                            {
1256                                                end_websocket_turn(&mut websocket_session);
1257                                                yield Ok(StreamDelta::Error {
1258                                                    message: error.message,
1259                                                    kind,
1260                                                });
1261                                                return;
1262                                            }
1263                                            if error.status == StatusCode::UNAUTHORIZED
1264                                                || error.status == StatusCode::UPGRADE_REQUIRED
1265                                                || error.status.is_client_error()
1266                                            {
1267                                                websocket_session.websocket_disabled = true;
1268                                            }
1269                                            reset_websocket_connection(&mut websocket_session);
1270                                            continue 'websocket_attempts;
1271                                        }
1272
1273                                        let event = match decode_stream_event(&text) {
1274                                            Ok(event) => event,
1275                                            Err(error) => {
1276                                                end_websocket_turn(
1277                                                    &mut websocket_session,
1278                                                );
1279                                                yield Ok(StreamDelta::Error {
1280                                                    message: error.to_string(),
1281                                                    kind: StreamErrorKind::ServerError,
1282                                                });
1283                                                return;
1284                                            }
1285                                        };
1286                                            match event.r#type.as_str() {
1287                                                "response.output_text.delta" => {
1288                                                    if let Some(delta) = event.delta {
1289                                                        emitted_output = true;
1290                                                        yield Ok(StreamDelta::TextDelta {
1291                                                            delta,
1292                                                            block_index: output_block_index(event.output_index),
1293                                                        });
1294                                                    }
1295                                                }
1296                                                "response.refusal.delta" => {
1297                                                    refused = true;
1298                                                    if let Some(delta) = event.delta {
1299                                                        emitted_output = true;
1300                                                        yield Ok(StreamDelta::TextDelta {
1301                                                            delta,
1302                                                            block_index: output_block_index(
1303                                                                event.output_index,
1304                                                            ),
1305                                                        });
1306                                                    }
1307                                                }
1308                                                "response.reasoning_summary_text.delta" => {
1309                                                    if let Some(delta) = event.delta {
1310                                                        let output_index =
1311                                                            event.output_index.unwrap_or(0);
1312                                                        streamed_reasoning_summaries
1313                                                            .insert(output_index);
1314                                                        emitted_output = true;
1315                                                        yield Ok(StreamDelta::ThinkingDelta {
1316                                                            delta,
1317                                                            block_index:
1318                                                                reasoning_summary_block_index(Some(
1319                                                                    output_index,
1320                                                                )),
1321                                                        });
1322                                                    }
1323                                                }
1324                                                "response.function_call_arguments.delta" => {
1325                                                    let block_index = event
1326                                                        .output_index
1327                                                        .map(|index| index.saturating_mul(2));
1328                                                    if let (Some(call_id), Some(delta)) =
1329                                                        (event.call_id, event.delta)
1330                                                    {
1331                                                        emitted_output = true;
1332                                                        let order = tool_calls.len();
1333                                                        let acc = tool_calls
1334                                                            .entry(call_id.clone())
1335                                                            .or_insert_with(|| ToolCallAccumulator {
1336                                                                id: call_id,
1337                                                                name: event.name.unwrap_or_default(),
1338                                                                arguments: String::new(),
1339                                                                order,
1340                                                                block_index,
1341                                                            });
1342                                                        acc.arguments.push_str(&delta);
1343                                                    }
1344                                                }
1345                                                "response.output_item.done" => {
1346                                                    let item =
1347                                                        match decode_output_item(event.item) {
1348                                                            Ok(item) => item,
1349                                                            Err(error) => {
1350                                                                end_websocket_turn(
1351                                                                    &mut websocket_session,
1352                                                                );
1353                                                                yield Ok(StreamDelta::Error {
1354                                                                    message: error.to_string(),
1355                                                                    kind: StreamErrorKind::ServerError,
1356                                                                });
1357                                                                return;
1358                                                            }
1359                                                        };
1360                                                    let block_index =
1361                                                        event.output_index.unwrap_or(0);
1362                                                    accumulate_completed_tool_call(
1363                                                        &item,
1364                                                        block_index,
1365                                                        &mut tool_calls,
1366                                                    );
1367                                                    let include_summary =
1368                                                        !streamed_reasoning_summaries
1369                                                            .contains(&block_index);
1370                                                    for delta in output_item_stream_deltas(
1371                                                        &item,
1372                                                        block_index,
1373                                                        include_summary,
1374                                                    ) {
1375                                                        emitted_output = true;
1376                                                        yield Ok(delta);
1377                                                    }
1378                                                    if let Some(item) =
1379                                                        output_item_to_input_item(item)
1380                                                    {
1381                                                        response_items.push(item);
1382                                                    }
1383                                                }
1384                                                "response.completed"
1385                                                | "response.incomplete"
1386                                                | "response.done" => {
1387                                                    let response_status = event
1388                                                        .response
1389                                                        .as_ref()
1390                                                        .and_then(|response| response.status);
1391                                                    let incomplete_reason = event
1392                                                        .response
1393                                                        .as_ref()
1394                                                        .and_then(|response| {
1395                                                            response.incomplete_details.as_ref()
1396                                                        })
1397                                                        .and_then(|details| details.reason.clone());
1398                                                    if let Some(resp) = event.response {
1399                                                        if let Some(u) = resp.usage {
1400                                                            usage = Some(usage_from_api_usage(&u));
1401                                                        }
1402                                                        if let Some(id) = resp.id {
1403                                                            response_id = Some(id);
1404                                                        }
1405                                                    }
1406                                                    let final_status =
1407                                                        match event.r#type.as_str() {
1408                                                            "response.incomplete" => {
1409                                                                Some(ApiStatus::Incomplete)
1410                                                            }
1411                                                            "response.done" => response_status
1412                                                                .or(Some(ApiStatus::Completed)),
1413                                                            _ => Some(ApiStatus::Completed),
1414                                                        };
1415                                                    let stop_reason =
1416                                                        stop_reason_from_stream_state(
1417                                                            &tool_calls,
1418                                                            final_status,
1419                                                            refused,
1420                                                            incomplete_reason.as_deref(),
1421                                                        );
1422                                                    if stop_reason
1423                                                        == Some(StopReason::ToolUse)
1424                                                    {
1425                                                        for delta in
1426                                                            emit_accumulated_tool_calls(&tool_calls)
1427                                                        {
1428                                                            yield Ok(delta);
1429                                                        }
1430                                                    }
1431                                                    if let Some(u) = usage.take() {
1432                                                        yield Ok(StreamDelta::Usage(u));
1433                                                    }
1434                                                    websocket_session.last_request =
1435                                                        Some(api_request.clone());
1436                                                    websocket_session.last_response_id = response_id;
1437                                                    websocket_session.last_response_items =
1438                                                        response_items;
1439                                                    websocket_session.prewarmed = false;
1440                                                    // Clean completion: the turn is no
1441                                                    // longer in flight, so the next
1442                                                    // turn may reuse the connection.
1443                                                    websocket_session.in_flight = false;
1444                                                    yield Ok(StreamDelta::Done {
1445                                                        stop_reason,
1446                                                    });
1447                                                    return;
1448                                                }
1449                                                "response.failed" => {
1450                                                    websocket_session.last_request = None;
1451                                                    websocket_session.last_response_id = None;
1452                                                    websocket_session.last_response_items.clear();
1453                                                    websocket_session.prewarmed = false;
1454                                                    // The turn ends here, so the session must stop
1455                                                    // counting as in-flight or it can never be
1456                                                    // evicted.
1457                                                    websocket_session.in_flight = false;
1458                                                    let failure =
1459                                                        codex_response_failed_error(event.response);
1460                                                    if let Some(usage) = failure.usage {
1461                                                        yield Ok(StreamDelta::Usage(usage));
1462                                                    }
1463                                                    yield Ok(StreamDelta::Error {
1464                                                        message: failure.message,
1465                                                        kind: failure.kind,
1466                                                    });
1467                                                    return;
1468                                                }
1469                                                _ => {}
1470                                    }
1471                                }
1472                                WebSocketMessage::Ping(payload) => {
1473                                    if let Some(connection) = websocket_session.connection.as_mut()
1474                                        && let Err(error) = connection
1475                                            .send(WebSocketMessage::Pong(payload))
1476                                            .await
1477                                    {
1478                                        if emitted_output {
1479                                            end_websocket_turn(&mut websocket_session);
1480                                            yield Ok(StreamDelta::Error {
1481                                                message: format!("websocket pong failed: {error}"),
1482                                                kind: websocket_transport_error_kind(&error),
1483                                            });
1484                                            return;
1485                                        }
1486                                        reset_websocket_connection(&mut websocket_session);
1487                                        if attempt == 1 {
1488                                            websocket_session.websocket_disabled = true;
1489                                            mark_websocket_transport_unhealthy();
1490                                        }
1491                                        continue 'websocket_attempts;
1492                                    }
1493                                }
1494                                WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {}
1495                                WebSocketMessage::Close(_) => {
1496                                    if emitted_output {
1497                                        end_websocket_turn(&mut websocket_session);
1498                                        yield Ok(StreamDelta::Error {
1499                                            message: "websocket closed before response.completed"
1500                                                .to_string(),
1501                                            kind: StreamErrorKind::ServerError,
1502                                        });
1503                                        return;
1504                                    }
1505                                    reset_websocket_connection(&mut websocket_session);
1506                                    if attempt == 1 {
1507                                        websocket_session.websocket_disabled = true;
1508                                        mark_websocket_transport_unhealthy();
1509                                    }
1510                                    continue 'websocket_attempts;
1511                                }
1512                            }
1513                        }
1514                    }
1515                }
1516                // The websocket turn did not complete (disabled, or attempts
1517                // exhausted); the turn now falls through to the HTTP SSE path, so
1518                // no websocket turn is in flight.
1519                websocket_session.in_flight = false;
1520                sse_turn_state = websocket_session.turn_state.clone();
1521                drop(websocket_session);
1522            }
1523
1524            let headers = match self.build_headers(
1525                true,
1526                request.session_id.as_deref(),
1527                sse_turn_state.as_deref(),
1528            ) {
1529                Ok(headers) => headers,
1530                Err(error) => {
1531                    yield Ok(StreamDelta::Error {
1532                        message: error.to_string(),
1533                        kind: StreamErrorKind::InvalidRequest,
1534                    });
1535                    return;
1536                }
1537            };
1538
1539            let response = match self
1540                .client
1541                .post(codex_url(&self.base_url))
1542                .headers(headers)
1543                .json(&api_request)
1544                .send()
1545                .await
1546            {
1547                Ok(response) => response,
1548                Err(error) => {
1549                    yield Ok(reqwest_error_delta("request failed", &error));
1550                    return;
1551                }
1552            };
1553
1554            let status = response.status();
1555            if !status.is_success() {
1556                // Headers are read before the body: `text()` consumes the response.
1557                let header_hint = crate::http::retry_after_from_headers(response.headers());
1558                let body = response.text().await.unwrap_or_default();
1559                let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1560                    StreamErrorKind::RateLimited(
1561                        header_hint
1562                            .or_else(|| codex_http_reset_after(&body))
1563                            .or_else(|| crate::retry_hints::openai_retry_delay(&body)),
1564                    )
1565                } else if status.is_server_error() {
1566                    StreamErrorKind::ServerError
1567                } else {
1568                    StreamErrorKind::InvalidRequest
1569                };
1570                log::warn!("OpenAI Codex error status={status} body={body}");
1571                yield Ok(StreamDelta::Error { message: body, kind });
1572                return;
1573            }
1574
1575            if let Some(session_id) = request.session_id.as_deref() {
1576                let turn_state = response
1577                    .headers()
1578                    .get(OPENAI_CODEX_TURN_STATE_HEADER)
1579                    .and_then(|value| value.to_str().ok())
1580                    .map(ToOwned::to_owned);
1581                if let Some(turn_state) = turn_state {
1582                    let session = self.websocket_session(session_id).await;
1583                    let mut websocket_session = session.lock().await;
1584                    websocket_session.turn_state = Some(turn_state);
1585                }
1586            }
1587
1588            let mut sse = SseLineBuffer::new();
1589            let mut stream = response.bytes_stream();
1590            let mut usage: Option<Usage> = None;
1591            let mut tool_calls: HashMap<String, ToolCallAccumulator> = HashMap::new();
1592            let mut final_status: Option<ApiStatus> = None;
1593            let mut streamed_reasoning_summaries = HashSet::new();
1594            let mut refused = false;
1595            let mut incomplete_reason: Option<String> = None;
1596
1597            while let Some(chunk_result) = stream.next().await {
1598                let chunk = match chunk_result {
1599                    Ok(chunk) => chunk,
1600                    Err(error) => {
1601                        yield Ok(reqwest_body_error_delta("stream error", &error));
1602                        return;
1603                    }
1604                };
1605                sse.extend(&chunk);
1606
1607                while let Some(line) = sse.next_line() {
1608                    let line = line.trim();
1609                    if line.is_empty() {
1610                        continue;
1611                    }
1612
1613                    let Some(data) = line.strip_prefix("data: ") else {
1614                        continue;
1615                    };
1616
1617                    if data == "[DONE]" {
1618                        let Some(stop_reason) =
1619                            stop_reason_from_stream_state(
1620                                &tool_calls,
1621                                final_status,
1622                                refused,
1623                                incomplete_reason.as_deref(),
1624                            )
1625                        else {
1626                            yield Ok(StreamDelta::Error {
1627                                message: "OpenAI Codex stream sent [DONE] before a terminal response event"
1628                                    .to_owned(),
1629                                kind: StreamErrorKind::ServerError,
1630                            });
1631                            return;
1632                        };
1633                        if stop_reason == StopReason::ToolUse {
1634                            for delta in emit_accumulated_tool_calls(&tool_calls) {
1635                                yield Ok(delta);
1636                            }
1637                        }
1638                        if let Some(u) = usage.take() {
1639                            yield Ok(StreamDelta::Usage(u));
1640                        }
1641                        yield Ok(StreamDelta::Done {
1642                            stop_reason: Some(stop_reason),
1643                        });
1644                        return;
1645                    }
1646
1647                    let event = match decode_stream_event(data) {
1648                        Ok(event) => event,
1649                        Err(error) => {
1650                            yield Ok(StreamDelta::Error {
1651                                message: format!(
1652                                    "invalid OpenAI Codex Responses stream event: {error}"
1653                                ),
1654                                kind: StreamErrorKind::ServerError,
1655                            });
1656                            return;
1657                        }
1658                    };
1659                    match event.r#type.as_str() {
1660                            "response.output_text.delta" => {
1661                                if let Some(delta) = event.delta {
1662                                    yield Ok(StreamDelta::TextDelta {
1663                                        delta,
1664                                        block_index: output_block_index(event.output_index),
1665                                    });
1666                                }
1667                            }
1668                            "response.refusal.delta" => {
1669                                refused = true;
1670                                if let Some(delta) = event.delta {
1671                                    yield Ok(StreamDelta::TextDelta {
1672                                        delta,
1673                                        block_index: output_block_index(event.output_index),
1674                                    });
1675                                }
1676                            }
1677                            "response.reasoning_summary_text.delta" => {
1678                                if let Some(delta) = event.delta {
1679                                    let output_index = event.output_index.unwrap_or(0);
1680                                    streamed_reasoning_summaries.insert(output_index);
1681                                    yield Ok(StreamDelta::ThinkingDelta {
1682                                        delta,
1683                                        block_index: reasoning_summary_block_index(Some(
1684                                            output_index,
1685                                        )),
1686                                    });
1687                                }
1688                            }
1689                            "response.function_call_arguments.delta" => {
1690                                let block_index = event
1691                                    .output_index
1692                                    .map(|index| index.saturating_mul(2));
1693                                if let (Some(call_id), Some(delta)) = (event.call_id, event.delta) {
1694                                    let order = tool_calls.len();
1695                                    let acc = tool_calls.entry(call_id.clone()).or_insert_with(|| {
1696                                        ToolCallAccumulator {
1697                                            id: call_id,
1698                                            name: event.name.unwrap_or_default(),
1699                                            arguments: String::new(),
1700                                            order,
1701                                            block_index,
1702                                        }
1703                                    });
1704                                    acc.arguments.push_str(&delta);
1705                                }
1706                            }
1707                            "response.output_item.done" => {
1708                                let item = match decode_output_item(event.item) {
1709                                    Ok(item) => item,
1710                                    Err(error) => {
1711                                        yield Ok(StreamDelta::Error {
1712                                            message: error.to_string(),
1713                                            kind: StreamErrorKind::ServerError,
1714                                        });
1715                                        return;
1716                                    }
1717                                };
1718                                let block_index = event.output_index.unwrap_or(0);
1719                                accumulate_completed_tool_call(
1720                                    &item,
1721                                    block_index,
1722                                    &mut tool_calls,
1723                                );
1724                                let include_summary =
1725                                    !streamed_reasoning_summaries.contains(&block_index);
1726                                for delta in output_item_stream_deltas(
1727                                    &item,
1728                                    block_index,
1729                                    include_summary,
1730                                ) {
1731                                    yield Ok(delta);
1732                                }
1733                            }
1734                            "response.completed" | "response.incomplete" | "response.done" => {
1735                                let response_status = event
1736                                    .response
1737                                    .as_ref()
1738                                    .and_then(|response| response.status);
1739                                incomplete_reason = event
1740                                    .response
1741                                    .as_ref()
1742                                    .and_then(|response| response.incomplete_details.as_ref())
1743                                    .and_then(|details| details.reason.clone());
1744                                if let Some(resp) = event.response
1745                                    && let Some(u) = resp.usage
1746                                {
1747                                    usage = Some(usage_from_api_usage(&u));
1748                                }
1749                                final_status = match event.r#type.as_str() {
1750                                    "response.incomplete" => Some(ApiStatus::Incomplete),
1751                                    "response.done" => {
1752                                        response_status.or(Some(ApiStatus::Completed))
1753                                    }
1754                                    _ => Some(ApiStatus::Completed),
1755                                };
1756                            }
1757                            "response.failed" => {
1758                                let failure = codex_response_failed_error(event.response);
1759                                if let Some(usage) = failure.usage {
1760                                    yield Ok(StreamDelta::Usage(usage));
1761                                }
1762                                yield Ok(StreamDelta::Error {
1763                                    message: failure.message,
1764                                    kind: failure.kind,
1765                                });
1766                                return;
1767                            }
1768                            _ => {}
1769                    }
1770                }
1771            }
1772
1773            let Some(stop_reason) = stop_reason_from_stream_state(
1774                &tool_calls,
1775                final_status,
1776                refused,
1777                incomplete_reason.as_deref(),
1778            ) else {
1779                yield Ok(StreamDelta::Error {
1780                    message: "OpenAI Codex stream ended before a terminal response event".to_owned(),
1781                    kind: StreamErrorKind::ServerError,
1782                });
1783                return;
1784            };
1785            if stop_reason == StopReason::ToolUse {
1786                for delta in emit_accumulated_tool_calls(&tool_calls) {
1787                    yield Ok(delta);
1788                }
1789            }
1790            if let Some(u) = usage {
1791                yield Ok(StreamDelta::Usage(u));
1792            }
1793            yield Ok(StreamDelta::Done {
1794                stop_reason: Some(stop_reason),
1795            });
1796        })
1797    }
1798
1799    async fn probe_connectivity(&self) -> bool {
1800        crate::provider::probe_http_reachability(&self.client, &self.base_url).await
1801    }
1802
1803    fn model(&self) -> &str {
1804        &self.model
1805    }
1806
1807    fn provider(&self) -> &'static str {
1808        "openai-codex"
1809    }
1810
1811    fn configured_thinking(&self) -> Option<&ThinkingConfig> {
1812        self.thinking.as_ref()
1813    }
1814}
1815
1816// ============================================================================
1817// Input building
1818// ============================================================================
1819
1820fn build_api_input(request: &ChatRequest) -> Vec<ApiInputItem> {
1821    let mut items = Vec::new();
1822
1823    // Convert user/assistant messages. The system prompt is sent separately as
1824    // `instructions`, matching pi's Codex transport.
1825    for msg in &request.messages {
1826        let role = match msg.role {
1827            agent_sdk_foundation::llm::Role::User => ApiRole::User,
1828            agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
1829        };
1830        match &msg.content {
1831            Content::Text(text) => {
1832                items.push(ApiInputItem::Message(ApiMessage {
1833                    role,
1834                    content: ApiMessageContent::Text(text.clone()),
1835                    phase: api_message_phase(role, false),
1836                }));
1837            }
1838            Content::Blocks(blocks) => append_block_input(&mut items, role, blocks),
1839        }
1840    }
1841
1842    items
1843}
1844
1845fn append_block_input(items: &mut Vec<ApiInputItem>, role: ApiRole, blocks: &[ContentBlock]) {
1846    let mut content_parts = Vec::new();
1847    let mut phase = api_message_phase(
1848        role,
1849        blocks
1850            .iter()
1851            .any(|block| matches!(block, ContentBlock::ToolUse { .. })),
1852    );
1853
1854    for block in blocks {
1855        match block {
1856            ContentBlock::Text { text } => {
1857                let part = if matches!(role, ApiRole::Assistant) {
1858                    ApiInputContent::OutputText { text: text.clone() }
1859                } else {
1860                    ApiInputContent::InputText { text: text.clone() }
1861                };
1862                content_parts.push(part);
1863            }
1864            ContentBlock::OpaqueReasoning { provider, data }
1865                if matches!(role, ApiRole::Assistant)
1866                    && is_message_state_marker(provider, data) =>
1867            {
1868                flush_message_parts(items, role, phase.clone(), &mut content_parts);
1869                phase = data
1870                    .get("phase")
1871                    .and_then(serde_json::Value::as_str)
1872                    .map(ToOwned::to_owned);
1873            }
1874            ContentBlock::OpaqueReasoning { provider, data }
1875                if provider == OPENAI_RESPONSES_REASONING_PROVIDER
1876                    && data.get("type").and_then(serde_json::Value::as_str)
1877                        == Some("reasoning") =>
1878            {
1879                flush_message_parts(items, role, phase.clone(), &mut content_parts);
1880                items.push(ApiInputItem::OpaqueReasoning(data.clone()));
1881            }
1882            ContentBlock::Thinking { .. }
1883            | ContentBlock::RedactedThinking { .. }
1884            | ContentBlock::OpaqueReasoning { .. } => {}
1885            ContentBlock::Image { source } => content_parts.push(ApiInputContent::Image {
1886                image_url: format!("data:{};base64,{}", source.media_type, source.data),
1887            }),
1888            ContentBlock::Document { source } => content_parts.push(ApiInputContent::File {
1889                filename: suggested_filename(&source.media_type),
1890                file_data: format!("data:{};base64,{}", source.media_type, source.data),
1891            }),
1892            ContentBlock::ToolUse {
1893                id, name, input, ..
1894            } => {
1895                flush_message_parts(items, role, phase.clone(), &mut content_parts);
1896                items.push(ApiInputItem::FunctionCall(ApiFunctionCall::new(
1897                    id.clone(),
1898                    name.clone(),
1899                    serde_json::to_string(input).unwrap_or_default(),
1900                )));
1901            }
1902            ContentBlock::ToolResult {
1903                tool_use_id,
1904                content,
1905                ..
1906            } => {
1907                flush_message_parts(items, role, phase.clone(), &mut content_parts);
1908                items.push(ApiInputItem::FunctionCallOutput(
1909                    ApiFunctionCallOutput::new(tool_use_id.clone(), content.clone()),
1910                ));
1911            }
1912            _ => log::warn!("Skipping unrecognized OpenAI Responses content block"),
1913        }
1914    }
1915
1916    flush_message_parts(items, role, phase, &mut content_parts);
1917}
1918
1919fn api_message_phase(role: ApiRole, has_tool_use: bool) -> Option<String> {
1920    match (role, has_tool_use) {
1921        (ApiRole::Assistant, true) => Some("commentary".to_owned()),
1922        (ApiRole::Assistant, false) => Some("final_answer".to_owned()),
1923        (ApiRole::User, _) => None,
1924    }
1925}
1926
1927fn message_state_marker(role: &str, phase: Option<&str>) -> serde_json::Value {
1928    let mut marker = serde_json::Map::new();
1929    marker.insert(
1930        "type".to_owned(),
1931        serde_json::Value::String(OPENAI_MESSAGE_ITEM_TYPE.to_owned()),
1932    );
1933    marker.insert(
1934        "role".to_owned(),
1935        serde_json::Value::String(role.to_owned()),
1936    );
1937    if let Some(phase) = phase {
1938        marker.insert(
1939            "phase".to_owned(),
1940            serde_json::Value::String(phase.to_owned()),
1941        );
1942    }
1943    serde_json::Value::Object(marker)
1944}
1945
1946fn is_message_state_marker(provider: &str, data: &serde_json::Value) -> bool {
1947    provider == OPENAI_RESPONSES_REASONING_PROVIDER
1948        && data.get("type").and_then(serde_json::Value::as_str) == Some(OPENAI_MESSAGE_ITEM_TYPE)
1949        && data.get("content").is_none()
1950}
1951
1952fn flush_message_parts(
1953    items: &mut Vec<ApiInputItem>,
1954    role: ApiRole,
1955    phase: Option<String>,
1956    content_parts: &mut Vec<ApiInputContent>,
1957) {
1958    if content_parts.is_empty() {
1959        return;
1960    }
1961    items.push(ApiInputItem::Message(ApiMessage {
1962        role,
1963        content: ApiMessageContent::Parts(std::mem::take(content_parts)),
1964        phase,
1965    }));
1966}
1967
1968/// Recursively fix a JSON schema for `OpenAI` strict mode.
1969///
1970/// Adds `additionalProperties: false`, marks every property required, and — to
1971/// keep previously-optional properties from being forced to fabricated values —
1972/// wraps optional properties in `anyOf: [..., {"type": "null"}]`. This mirrors
1973/// the sibling `openai_responses` provider so a tool schema behaves identically
1974/// across both providers.
1975fn fix_schema_for_strict_mode(schema: &mut serde_json::Value) {
1976    let Some(obj) = schema.as_object_mut() else {
1977        return;
1978    };
1979
1980    // Check if this is an object type schema
1981    let is_object_type = obj
1982        .get("type")
1983        .is_some_and(|t| t.as_str() == Some("object"));
1984
1985    if is_object_type {
1986        // Add additionalProperties: false
1987        obj.insert(
1988            "additionalProperties".to_owned(),
1989            serde_json::Value::Bool(false),
1990        );
1991
1992        // Ensure properties and required exist (strict mode needs them even if empty)
1993        obj.entry("properties".to_owned())
1994            .or_insert_with(|| serde_json::json!({}));
1995        obj.entry("required".to_owned())
1996            .or_insert_with(|| serde_json::json!([]));
1997
1998        // Collect the set of originally required keys
1999        let originally_required: std::collections::HashSet<String> = obj
2000            .get("required")
2001            .and_then(|v| v.as_array())
2002            .map(|arr| {
2003                arr.iter()
2004                    .filter_map(|v| v.as_str().map(String::from))
2005                    .collect()
2006            })
2007            .unwrap_or_default();
2008
2009        // Wrap previously-optional properties in anyOf with null
2010        if let Some(serde_json::Value::Object(props)) = obj.get_mut("properties") {
2011            for (key, prop_schema) in props.iter_mut() {
2012                if !originally_required.contains(key) {
2013                    make_nullable(prop_schema);
2014                }
2015            }
2016        }
2017
2018        // Ensure all properties are marked as required
2019        if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
2020            let all_keys: Vec<serde_json::Value> = props
2021                .keys()
2022                .map(|k| serde_json::Value::String(k.clone()))
2023                .collect();
2024            obj.insert("required".to_owned(), serde_json::Value::Array(all_keys));
2025        }
2026    }
2027
2028    // Recursively process nested schemas
2029    if let Some(props) = obj.get_mut("properties")
2030        && let Some(props_obj) = props.as_object_mut()
2031    {
2032        for prop_schema in props_obj.values_mut() {
2033            fix_schema_for_strict_mode(prop_schema);
2034        }
2035    }
2036
2037    // Process array items
2038    if let Some(items) = obj.get_mut("items") {
2039        fix_schema_for_strict_mode(items);
2040    }
2041
2042    // Process anyOf/oneOf/allOf
2043    for key in ["anyOf", "oneOf", "allOf"] {
2044        if let Some(arr) = obj.get_mut(key)
2045            && let Some(arr_items) = arr.as_array_mut()
2046        {
2047            for item in arr_items {
2048                fix_schema_for_strict_mode(item);
2049            }
2050        }
2051    }
2052}
2053
2054/// Wrap a schema in `anyOf: [{original}, {"type": "null"}]` so the property
2055/// accepts its original type OR null. Appends the null variant when an `anyOf`
2056/// already exists.
2057fn make_nullable(schema: &mut serde_json::Value) {
2058    if let Some(any_of) = schema
2059        .as_object_mut()
2060        .and_then(|o| o.get_mut("anyOf"))
2061        .and_then(|v| v.as_array_mut())
2062    {
2063        let has_null = any_of
2064            .iter()
2065            .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
2066        if !has_null {
2067            any_of.push(serde_json::json!({"type": "null"}));
2068        }
2069        return;
2070    }
2071
2072    let original = schema.clone();
2073    *schema = serde_json::json!({
2074        "anyOf": [original, {"type": "null"}]
2075    });
2076}
2077
2078/// Check whether a JSON schema contains any object-typed schema without a
2079/// `properties` map (a free-form object). These are incompatible with
2080/// `OpenAI` strict mode and must disable it.
2081fn has_freeform_object(schema: &serde_json::Value) -> bool {
2082    let Some(obj) = schema.as_object() else {
2083        return false;
2084    };
2085
2086    let is_object = obj
2087        .get("type")
2088        .is_some_and(|t| t.as_str() == Some("object"));
2089
2090    if is_object && !obj.contains_key("properties") {
2091        return true;
2092    }
2093
2094    if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
2095        for prop in props.values() {
2096            if has_freeform_object(prop) {
2097                return true;
2098            }
2099        }
2100    }
2101
2102    if let Some(items) = obj.get("items")
2103        && has_freeform_object(items)
2104    {
2105        return true;
2106    }
2107
2108    for key in ["anyOf", "oneOf", "allOf"] {
2109        if let Some(arr) = obj.get(key).and_then(|v| v.as_array()) {
2110            for item in arr {
2111                if has_freeform_object(item) {
2112                    return true;
2113                }
2114            }
2115        }
2116    }
2117
2118    false
2119}
2120
2121fn convert_tool(tool: agent_sdk_foundation::llm::Tool) -> ApiTool {
2122    // Strict mode requires additionalProperties: false on all objects and every
2123    // property in required, which is incompatible with free-form object schemas
2124    // (objects with no defined properties). Detect and skip strict for those —
2125    // matching the sibling openai_responses provider.
2126    let mut schema = tool.input_schema;
2127    let use_strict = if has_freeform_object(&schema) {
2128        log::debug!(
2129            "Tool '{}' has free-form object schema — disabling strict mode",
2130            tool.name
2131        );
2132        None
2133    } else {
2134        fix_schema_for_strict_mode(&mut schema);
2135        Some(true)
2136    };
2137
2138    ApiTool {
2139        r#type: "function".to_owned(),
2140        name: tool.name,
2141        description: Some(tool.description),
2142        parameters: Some(schema),
2143        strict: use_strict,
2144    }
2145}
2146
2147fn suggested_filename(media_type: &str) -> String {
2148    match media_type {
2149        "application/pdf" => "attachment.pdf".to_string(),
2150        "image/png" => "image.png".to_string(),
2151        "image/jpeg" => "image.jpg".to_string(),
2152        "image/gif" => "image.gif".to_string(),
2153        "image/webp" => "image.webp".to_string(),
2154        _ => "attachment.bin".to_string(),
2155    }
2156}
2157
2158fn reasoning_output_item(fields: &serde_json::Map<String, serde_json::Value>) -> serde_json::Value {
2159    let mut item = fields.clone();
2160    item.insert(
2161        "type".to_owned(),
2162        serde_json::Value::String("reasoning".to_owned()),
2163    );
2164    serde_json::Value::Object(item)
2165}
2166
2167fn reasoning_summary_texts(fields: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
2168    fields
2169        .get("summary")
2170        .and_then(serde_json::Value::as_array)
2171        .into_iter()
2172        .flatten()
2173        .filter(|summary| {
2174            summary.get("type").and_then(serde_json::Value::as_str) == Some("summary_text")
2175        })
2176        .filter_map(|summary| {
2177            summary
2178                .get("text")
2179                .and_then(serde_json::Value::as_str)
2180                .filter(|text| !text.is_empty())
2181                .map(ToOwned::to_owned)
2182        })
2183        .collect()
2184}
2185
2186#[cfg(test)]
2187fn build_content_blocks(output: &[ApiOutputItem]) -> Vec<ContentBlock> {
2188    let mut blocks = Vec::new();
2189
2190    for item in output {
2191        match item {
2192            ApiOutputItem::Message {
2193                role,
2194                phase,
2195                content,
2196            } => {
2197                blocks.push(ContentBlock::OpaqueReasoning {
2198                    provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2199                    data: message_state_marker(role, phase.as_deref()),
2200                });
2201                for c in content {
2202                    match c {
2203                        ApiOutputContent::Text { text }
2204                        | ApiOutputContent::Refusal { refusal: text }
2205                            if !text.is_empty() =>
2206                        {
2207                            blocks.push(ContentBlock::Text { text: text.clone() });
2208                        }
2209                        ApiOutputContent::Text { .. }
2210                        | ApiOutputContent::Refusal { .. }
2211                        | ApiOutputContent::Unknown => {}
2212                    }
2213                }
2214            }
2215            ApiOutputItem::FunctionCall {
2216                call_id,
2217                name,
2218                arguments,
2219                ..
2220            } => {
2221                let input =
2222                    serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
2223                blocks.push(ContentBlock::ToolUse {
2224                    id: call_id.clone(),
2225                    name: name.clone(),
2226                    input,
2227                    thought_signature: None,
2228                });
2229            }
2230            ApiOutputItem::Reasoning { fields } => {
2231                blocks.push(ContentBlock::OpaqueReasoning {
2232                    provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2233                    data: reasoning_output_item(fields),
2234                });
2235                blocks.extend(reasoning_summary_texts(fields).into_iter().map(|thinking| {
2236                    ContentBlock::Thinking {
2237                        thinking,
2238                        signature: None,
2239                    }
2240                }));
2241            }
2242            ApiOutputItem::Unknown => {
2243                // Skip unknown output types
2244            }
2245        }
2246    }
2247
2248    blocks
2249}
2250
2251#[cfg(test)]
2252fn output_contains_refusal(output: &[ApiOutputItem]) -> bool {
2253    output.iter().any(|item| {
2254        matches!(
2255            item,
2256            ApiOutputItem::Message { content, .. }
2257                if content
2258                    .iter()
2259                    .any(|content| matches!(content, ApiOutputContent::Refusal { .. }))
2260        )
2261    })
2262}
2263
2264fn build_api_reasoning(thinking: Option<&ThinkingConfig>) -> Option<ApiReasoning> {
2265    thinking
2266        .and_then(resolve_reasoning_effort)
2267        .map(|effort| ApiReasoning { effort })
2268}
2269
2270const fn resolve_reasoning_effort(config: &ThinkingConfig) -> Option<ReasoningEffort> {
2271    if let Some(effort) = config.effort {
2272        return Some(map_effort(effort));
2273    }
2274
2275    match &config.mode {
2276        ThinkingMode::Adaptive | ThinkingMode::Default => None,
2277        ThinkingMode::Enabled { budget_tokens } => Some(map_budget_to_reasoning(*budget_tokens)),
2278    }
2279}
2280
2281const fn map_effort(effort: Effort) -> ReasoningEffort {
2282    match effort {
2283        Effort::Low => ReasoningEffort::Low,
2284        Effort::Medium => ReasoningEffort::Medium,
2285        Effort::High => ReasoningEffort::High,
2286        Effort::XHigh => ReasoningEffort::XHigh,
2287        Effort::Max => ReasoningEffort::Max,
2288    }
2289}
2290
2291const fn map_reasoning_effort(effort: ReasoningEffort) -> Effort {
2292    match effort {
2293        ReasoningEffort::Low => Effort::Low,
2294        ReasoningEffort::Medium => Effort::Medium,
2295        ReasoningEffort::High => Effort::High,
2296        ReasoningEffort::XHigh => Effort::XHigh,
2297        ReasoningEffort::Max => Effort::Max,
2298    }
2299}
2300
2301const fn map_budget_to_reasoning(budget_tokens: u32) -> ReasoningEffort {
2302    if budget_tokens <= 4_096 {
2303        ReasoningEffort::Low
2304    } else if budget_tokens <= 16_384 {
2305        ReasoningEffort::Medium
2306    } else if budget_tokens <= 32_768 {
2307        ReasoningEffort::High
2308    } else {
2309        ReasoningEffort::XHigh
2310    }
2311}
2312
2313fn codex_url(base_url: &str) -> String {
2314    let normalized = base_url.trim_end_matches('/');
2315    if normalized.ends_with("/codex/responses") {
2316        normalized.to_string()
2317    } else if normalized.ends_with("/codex") {
2318        format!("{normalized}/responses")
2319    } else {
2320        format!("{normalized}/codex/responses")
2321    }
2322}
2323
2324fn codex_websocket_url(base_url: &str) -> Result<url::Url> {
2325    let mut url = url::Url::parse(&codex_url(base_url))
2326        .context("failed to parse OpenAI Codex websocket URL")?;
2327
2328    let scheme = match url.scheme() {
2329        "http" => Some("ws"),
2330        "https" => Some("wss"),
2331        _ => None,
2332    };
2333
2334    if let Some(scheme) = scheme {
2335        let _ = url.set_scheme(scheme);
2336    }
2337
2338    Ok(url)
2339}
2340
2341fn extract_account_id(token: &str) -> Result<String> {
2342    let payload = token
2343        .split('.')
2344        .nth(1)
2345        .ok_or_else(|| anyhow::anyhow!("invalid OpenAI Codex OAuth token"))?;
2346    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
2347        .decode(payload)
2348        .context("failed to decode OpenAI Codex token payload")?;
2349    let payload: serde_json::Value =
2350        serde_json::from_slice(&decoded).context("failed to parse OpenAI Codex token payload")?;
2351    payload
2352        .get(OPENAI_CODEX_JWT_CLAIM_PATH)
2353        .and_then(|value| value.get("chatgpt_account_id"))
2354        .and_then(serde_json::Value::as_str)
2355        .map(ToOwned::to_owned)
2356        .ok_or_else(|| anyhow::anyhow!("chatgpt_account_id missing from OpenAI Codex token"))
2357}
2358
2359// ============================================================================
2360// Streaming helpers
2361// ============================================================================
2362
2363struct ToolCallAccumulator {
2364    id: String,
2365    name: String,
2366    arguments: String,
2367    /// Registration order, used to assign deterministic, distinct block indices
2368    /// when emitting (`HashMap` iteration order is otherwise nondeterministic).
2369    order: usize,
2370    /// Responses output-item index, when the stream reports it.
2371    block_index: Option<usize>,
2372}
2373
2374fn usage_from_api_usage(usage: &ApiUsage) -> Usage {
2375    Usage {
2376        input_tokens: usage.input_tokens,
2377        output_tokens: usage.output_tokens,
2378        cached_input_tokens: usage
2379            .input_tokens_details
2380            .as_ref()
2381            .map_or(0, |details| details.cached_tokens),
2382        cache_creation_input_tokens: usage
2383            .input_tokens_details
2384            .as_ref()
2385            .map_or(0, |details| details.cache_write_tokens),
2386    }
2387}
2388
2389fn output_block_index(output_index: Option<usize>) -> usize {
2390    output_index.unwrap_or(0).saturating_mul(2)
2391}
2392
2393fn reasoning_summary_block_index(output_index: Option<usize>) -> usize {
2394    output_block_index(output_index).saturating_add(1)
2395}
2396
2397fn decode_stream_event(data: &str) -> Result<ApiStreamEvent> {
2398    serde_json::from_str(data).with_context(|| {
2399        // Include a bounded snippet of the offending event: SSE payloads
2400        // carry no credentials, and "invalid stream event" without the
2401        // event is undiagnosable in the field (2026-07-10, GPT-5.6 rollout).
2402        let mut snippet: String = data.chars().take(512).collect();
2403        if data.chars().count() > 512 {
2404            snippet.push('…');
2405        }
2406        format!("invalid OpenAI Codex Responses stream event: {snippet}")
2407    })
2408}
2409
2410fn decode_output_item(item: Option<serde_json::Value>) -> Result<ApiOutputItem> {
2411    let item = item.context("OpenAI Codex output_item.done omitted item")?;
2412    serde_json::from_value(item).context("invalid OpenAI Codex output item")
2413}
2414
2415fn output_item_stream_deltas(
2416    item: &ApiOutputItem,
2417    output_index: usize,
2418    include_summary: bool,
2419) -> Vec<StreamDelta> {
2420    let block_index = output_index.saturating_mul(2);
2421    match item {
2422        ApiOutputItem::Message { role, phase, .. } => {
2423            vec![StreamDelta::OpaqueReasoning {
2424                provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2425                data: message_state_marker(role, phase.as_deref()),
2426                block_index,
2427            }]
2428        }
2429        ApiOutputItem::Reasoning { fields } => {
2430            let mut deltas = vec![StreamDelta::OpaqueReasoning {
2431                provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2432                data: reasoning_output_item(fields),
2433                block_index,
2434            }];
2435            if include_summary {
2436                let summary_block_index = block_index.saturating_add(1);
2437                deltas.extend(reasoning_summary_texts(fields).into_iter().map(|delta| {
2438                    StreamDelta::ThinkingDelta {
2439                        delta,
2440                        block_index: summary_block_index,
2441                    }
2442                }));
2443            }
2444            deltas
2445        }
2446        ApiOutputItem::FunctionCall { .. } | ApiOutputItem::Unknown => Vec::new(),
2447    }
2448}
2449
2450fn accumulate_completed_tool_call(
2451    item: &ApiOutputItem,
2452    output_index: usize,
2453    tool_calls: &mut HashMap<String, ToolCallAccumulator>,
2454) {
2455    let ApiOutputItem::FunctionCall {
2456        call_id,
2457        name,
2458        arguments,
2459    } = item
2460    else {
2461        return;
2462    };
2463
2464    let order = tool_calls.len();
2465    let accumulator = tool_calls
2466        .entry(call_id.clone())
2467        .or_insert_with(|| ToolCallAccumulator {
2468            id: call_id.clone(),
2469            name: name.clone(),
2470            arguments: String::new(),
2471            order,
2472            block_index: Some(output_index.saturating_mul(2)),
2473        });
2474    accumulator.name.clone_from(name);
2475    accumulator.arguments.clone_from(arguments);
2476    accumulator.block_index = Some(output_index.saturating_mul(2));
2477}
2478
2479fn emit_accumulated_tool_calls(
2480    tool_calls: &HashMap<String, ToolCallAccumulator>,
2481) -> Vec<StreamDelta> {
2482    // Assign distinct, monotonically increasing block indices in registration
2483    // order. The previous code gave every call the same index (1) and iterated
2484    // HashMap::values(), so StreamAccumulator's stable sort preserved
2485    // nondeterministic insertion order for multi-tool turns.
2486    let mut accs: Vec<&ToolCallAccumulator> = tool_calls.values().collect();
2487    accs.sort_by_key(|acc| (acc.block_index.unwrap_or(usize::MAX), acc.order));
2488
2489    let mut deltas = Vec::with_capacity(accs.len() * 2);
2490    for (idx, acc) in accs.iter().enumerate() {
2491        let block_index = acc
2492            .block_index
2493            .unwrap_or_else(|| idx.saturating_add(1).saturating_mul(2));
2494        deltas.push(StreamDelta::ToolUseStart {
2495            id: acc.id.clone(),
2496            name: acc.name.clone(),
2497            block_index,
2498            thought_signature: None,
2499        });
2500        deltas.push(StreamDelta::ToolInputDelta {
2501            id: acc.id.clone(),
2502            delta: acc.arguments.clone(),
2503            block_index,
2504        });
2505    }
2506    deltas
2507}
2508
2509fn stop_reason_from_stream_state(
2510    tool_calls: &HashMap<String, ToolCallAccumulator>,
2511    status: Option<ApiStatus>,
2512    refused: bool,
2513    incomplete_reason: Option<&str>,
2514) -> Option<StopReason> {
2515    let status = status?;
2516    Some(match status {
2517        ApiStatus::Incomplete => {
2518            incomplete_reason.map_or(StopReason::Unknown, incomplete_stop_reason)
2519        }
2520        ApiStatus::Completed if refused => StopReason::Refusal,
2521        ApiStatus::Completed if !tool_calls.is_empty() => StopReason::ToolUse,
2522        ApiStatus::Completed => StopReason::EndTurn,
2523        // Failed, a non-terminal status leaking into the final state, or a
2524        // status this build does not know: same Unknown as the old Failed
2525        // arm. Spelled out per variant so adding a status forces a
2526        // decision here.
2527        ApiStatus::Failed
2528        | ApiStatus::InProgress
2529        | ApiStatus::Queued
2530        | ApiStatus::Cancelled
2531        | ApiStatus::Other => StopReason::Unknown,
2532    })
2533}
2534
2535fn incomplete_stop_reason(reason: &str) -> StopReason {
2536    match reason {
2537        "max_output_tokens" => StopReason::MaxTokens,
2538        "content_filter" => StopReason::Refusal,
2539        "model_context_window_exceeded" => StopReason::ModelContextWindowExceeded,
2540        _ => StopReason::Unknown,
2541    }
2542}
2543
2544const fn websocket_transport_error_kind(
2545    error: &tokio_tungstenite::tungstenite::Error,
2546) -> StreamErrorKind {
2547    match error {
2548        tokio_tungstenite::tungstenite::Error::ConnectionClosed
2549        | tokio_tungstenite::tungstenite::Error::Io(_) => StreamErrorKind::ConnectionLost,
2550        tokio_tungstenite::tungstenite::Error::AlreadyClosed
2551        | tokio_tungstenite::tungstenite::Error::Tls(_)
2552        | tokio_tungstenite::tungstenite::Error::Capacity(_)
2553        | tokio_tungstenite::tungstenite::Error::Protocol(_)
2554        | tokio_tungstenite::tungstenite::Error::WriteBufferFull(_)
2555        | tokio_tungstenite::tungstenite::Error::Utf8(_)
2556        | tokio_tungstenite::tungstenite::Error::AttackAttempt
2557        | tokio_tungstenite::tungstenite::Error::Url(_)
2558        | tokio_tungstenite::tungstenite::Error::Http(_)
2559        | tokio_tungstenite::tungstenite::Error::HttpFormat(_) => StreamErrorKind::ServerError,
2560    }
2561}
2562
2563fn reset_websocket_connection(session: &mut WebsocketSessionState) {
2564    session.connection = None;
2565    if session.prewarmed {
2566        session.last_request = None;
2567        session.last_response_id = None;
2568        session.last_response_items.clear();
2569    }
2570    session.prewarmed = false;
2571}
2572
2573/// Reset the connection *and* end the turn, for a stream that is about to yield
2574/// a terminal error and stop.
2575///
2576/// `in_flight` stays set when a stream is merely *dropped* — that is how the
2577/// next turn for the session learns its state is stale and runs the
2578/// abandoned-turn cleanup on lock acquisition. A stream that finishes by
2579/// reporting an error has no such successor to warn: the turn is over and the
2580/// state is cleared here, so the marker is cleared. Leaving it set makes
2581/// [`evict_idle_sessions`] skip the entry forever, and the bounded session map
2582/// then grows without limit — one stranded entry per session id that ever hit a
2583/// terminal stream error.
2584///
2585/// Because clearing `in_flight` bypasses that abandoned-turn cleanup, this must
2586/// itself clear the incremental baseline (`last_request` / `last_response_id` /
2587/// `last_response_items`). The baseline is a `previous_response_id` valid only
2588/// on the socket that produced it; this turn ended in a terminal error on a
2589/// now-discarded socket, so a retry reusing this idle session must send a FULL
2590/// request on its fresh socket rather than a stale id the new connection
2591/// rejects. (`reset_websocket_connection` clears the baseline only for a
2592/// prewarmed session; a real turn is not prewarmed, so it is cleared here.)
2593fn end_websocket_turn(session: &mut WebsocketSessionState) {
2594    reset_websocket_connection(session);
2595    session.last_request = None;
2596    session.last_response_id = None;
2597    session.last_response_items.clear();
2598    session.in_flight = false;
2599}
2600
2601/// Bound the websocket-session map by evicting idle (not in-flight) sessions,
2602/// oldest-first by last use. The map exists for cross-turn reuse, so completed
2603/// sessions retain a cached baseline indefinitely; without eviction a host
2604/// serving many distinct sessions would leak memory and open sockets without
2605/// bound. Sessions whose lock is currently held (in use) or that are mid-turn
2606/// are retained.
2607fn evict_idle_sessions(sessions: &mut HashMap<String, Arc<Mutex<WebsocketSessionState>>>) {
2608    let mut candidates: Vec<(String, Option<Instant>)> = Vec::new();
2609    for (key, state) in sessions.iter() {
2610        if let Ok(guard) = state.try_lock()
2611            && !guard.in_flight
2612        {
2613            candidates.push((key.clone(), guard.last_used));
2614        }
2615    }
2616    // Oldest first; `None` (never used) sorts before `Some`.
2617    candidates.sort_by_key(|a| a.1);
2618    let evict_count = candidates.len().min(sessions.len() / 2 + 1);
2619    for (key, _) in candidates.into_iter().take(evict_count) {
2620        sessions.remove(&key);
2621    }
2622}
2623
2624/// A decoded wrapped websocket error frame.
2625struct WrappedWebsocketError {
2626    status: StatusCode,
2627    message: String,
2628    /// The frame is the websocket connection-limit signal, which the service
2629    /// reports with a 429 even though no model quota was exhausted: it means
2630    /// *this transport* is full, not that the request must wait. Tracked
2631    /// separately so the immediate HTTP fallback that resolves it is not
2632    /// confused with a quota rejection, which must be waited out.
2633    connection_limit: bool,
2634    /// Delay the frame stated in a structured field, if any.
2635    ///
2636    /// Codex's `usage_limit_reached` frame carries its wait here and leaves the
2637    /// message as fixed prose ("The usage limit has been reached"), so this is
2638    /// the only hint that frame has — parsing the message would find nothing.
2639    reset_after: Option<Duration>,
2640}
2641
2642/// Resolve the wait a Codex quota error states in its structured reset fields.
2643///
2644/// `resets_in_seconds` is preferred: it is relative, so it needs no wall-clock
2645/// arithmetic and cannot be skewed by clock drift. `resets_at` (Unix seconds) is
2646/// the fallback, converted against the wall clock — an instant at or before now
2647/// yields `None` rather than a zero or negative wait. Both are `f64`: the
2648/// service sometimes encodes the timestamp as a JSON float, and rejecting that
2649/// would fail the whole payload parse and lose the hint entirely. Either way the
2650/// value passes through the same [`bounded_delay`](crate::retry_hints::bounded_delay)
2651/// ceiling as every other hint source.
2652fn codex_reset_after(resets_in_seconds: Option<f64>, resets_at: Option<f64>) -> Option<Duration> {
2653    if let Some(seconds) = resets_in_seconds {
2654        return crate::retry_hints::bounded_delay(seconds);
2655    }
2656    let resets_at = resets_at?;
2657    if !resets_at.is_finite() {
2658        return None;
2659    }
2660    let now = std::time::SystemTime::now()
2661        .duration_since(std::time::UNIX_EPOCH)
2662        .ok()?
2663        .as_secs();
2664    // `floor` drops sub-second precision a quota reset never needs; the
2665    // `bounded_delay` sanity checks (finite, positive, ceiling) do the rest.
2666    crate::retry_hints::bounded_delay(resets_at.floor() - f64::from(u32::try_from(now).ok()?))
2667}
2668
2669/// Read the structured reset off a wrapped websocket error frame.
2670fn websocket_reset_after(error: Option<&ApiWrappedWebsocketErrorBody>) -> Option<Duration> {
2671    let error = error?;
2672    codex_reset_after(error.resets_in_seconds, error.resets_at)
2673}
2674
2675/// A Codex HTTP error body, read only for its structured quota reset.
2676///
2677/// The HTTP 429 responses carry no `Retry-After` header and a
2678/// `usage_limit_reached` body whose message is timing-free prose, so the reset
2679/// fields under `error` are the only usable hint there.
2680#[derive(Deserialize)]
2681struct ApiHttpErrorEnvelope {
2682    #[serde(default)]
2683    error: Option<ApiHttpErrorBody>,
2684}
2685
2686#[derive(Deserialize)]
2687struct ApiHttpErrorBody {
2688    #[serde(default)]
2689    resets_in_seconds: Option<f64>,
2690    #[serde(default)]
2691    resets_at: Option<f64>,
2692}
2693
2694/// Resolve the wait a Codex HTTP 429 body states in its structured reset fields,
2695/// or `None` when the body is not JSON or carries no reset.
2696fn codex_http_reset_after(body: &str) -> Option<Duration> {
2697    let envelope: ApiHttpErrorEnvelope = serde_json::from_str(body).ok()?;
2698    let error = envelope.error?;
2699    codex_reset_after(error.resets_in_seconds, error.resets_at)
2700}
2701
2702fn parse_wrapped_websocket_error_event(payload: &str) -> Option<WrappedWebsocketError> {
2703    let event: ApiWrappedWebsocketErrorEvent = serde_json::from_str(payload).ok()?;
2704    if event.kind != "error" {
2705        return None;
2706    }
2707
2708    let reset_after = websocket_reset_after(event.error.as_ref());
2709
2710    if event.error.as_ref().and_then(|error| error.code.as_deref())
2711        == Some(OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE)
2712    {
2713        let message = event
2714            .error
2715            .and_then(|error| error.message)
2716            .unwrap_or_else(|| "Responses websocket connection limit reached".to_string());
2717        return Some(WrappedWebsocketError {
2718            status: StatusCode::TOO_MANY_REQUESTS,
2719            message,
2720            connection_limit: true,
2721            reset_after,
2722        });
2723    }
2724
2725    let status = StatusCode::from_u16(event.status?).ok()?;
2726    let message = event
2727        .error
2728        .and_then(|error| error.message)
2729        .unwrap_or_else(|| payload.to_string());
2730    if status.is_success() {
2731        None
2732    } else {
2733        Some(WrappedWebsocketError {
2734            status,
2735            message,
2736            connection_limit: false,
2737            reset_after,
2738        })
2739    }
2740}
2741
2742/// `true` when the frame is a model-quota rejection rather than the
2743/// websocket connection-limit signal the service reports with the same status.
2744///
2745/// A quota rejection states how long to wait, so re-sending the request now —
2746/// over a fresh socket or the HTTP fallback — would spend the attempt inside
2747/// the window the service just asked us to sit out.
2748fn is_websocket_quota_rejection(error: &WrappedWebsocketError) -> bool {
2749    error.status == StatusCode::TOO_MANY_REQUESTS && !error.connection_limit
2750}
2751
2752/// Resolve the message and kind of an in-band `response.failed` event.
2753///
2754/// The stream already opened with HTTP 200, so a rate limit reported this way
2755/// has neither a status nor a `Retry-After` header: the machine-readable code
2756/// is the only classification signal, and the delay — when the service states
2757/// one — is embedded in the message. A context-window rejection
2758/// (`context_length_exceeded`) is a fatal request-shape error, not a
2759/// transient one: classifying it `InvalidRequest` lets the worker's overflow
2760/// compaction fire instead of retrying the identical oversized payload.
2761/// Every other failure stays a (retriable) server error, as before.
2762fn codex_response_failed_error(response: Option<ApiStreamResponse>) -> CodexResponseFailure {
2763    // A failed response still reports the tokens it burned. They are returned
2764    // alongside the error so the caller can yield them *before* the terminal
2765    // error delta: the consumer's accumulator only sees deltas it was handed,
2766    // and those tokens are billed whether or not the turn survived.
2767    let usage = response
2768        .as_ref()
2769        .and_then(|resp| resp.usage.as_ref())
2770        .map(usage_from_api_usage);
2771    let (code, message) = response
2772        .and_then(|resp| resp.error)
2773        .map_or((None, None), |error| (error.code, error.message));
2774    let message = message.unwrap_or_else(|| "Codex response failed".to_string());
2775    let kind = if matches!(
2776        code.as_deref(),
2777        Some("rate_limit_exceeded" | "rate_limit_error")
2778    ) {
2779        StreamErrorKind::RateLimited(crate::retry_hints::openai_retry_delay(&message))
2780    } else if is_context_window_rejection(code.as_deref(), &message) {
2781        StreamErrorKind::InvalidRequest
2782    } else {
2783        StreamErrorKind::ServerError
2784    };
2785    CodexResponseFailure {
2786        message,
2787        kind,
2788        usage,
2789    }
2790}
2791
2792/// True when a `response.failed` code/message rejects the prompt for
2793/// exceeding the model's context window. The structured
2794/// `context_length_exceeded` code is the primary signal; the prose shapes
2795/// cover wrapped or proxied failures that drop the code.
2796fn is_context_window_rejection(code: Option<&str>, message: &str) -> bool {
2797    if matches!(code, Some("context_length_exceeded")) {
2798        return true;
2799    }
2800    let lower = message.to_lowercase();
2801    lower.contains("exceeds the context window")
2802        || lower.contains("maximum context length")
2803        || lower.contains("context_length_exceeded")
2804}
2805
2806/// A `response.failed` event decoded into what the stream must emit for it.
2807struct CodexResponseFailure {
2808    message: String,
2809    kind: StreamErrorKind,
2810    /// Usage the failed response reported, if any — emitted before the error.
2811    usage: Option<Usage>,
2812}
2813
2814/// Classify a wrapped websocket error event by the status it reports.
2815///
2816/// The frame carries no HTTP headers, so a rate limit's delay comes from the
2817/// frame itself: the structured reset field when the service sent one (Codex's
2818/// `usage_limit_reached` frames do, and their message is fixed prose that says
2819/// nothing about timing), otherwise the message ("Please try again in 20s").
2820fn websocket_error_kind(error: &WrappedWebsocketError) -> StreamErrorKind {
2821    if error.status == StatusCode::TOO_MANY_REQUESTS {
2822        let delay = error
2823            .reset_after
2824            .or_else(|| crate::retry_hints::openai_retry_delay(&error.message));
2825        StreamErrorKind::RateLimited(delay)
2826    } else if error.status.is_server_error() {
2827        StreamErrorKind::ServerError
2828    } else {
2829        StreamErrorKind::InvalidRequest
2830    }
2831}
2832
2833fn output_item_to_input_item(item: ApiOutputItem) -> Option<ApiInputItem> {
2834    match item {
2835        ApiOutputItem::Message {
2836            role,
2837            phase,
2838            content,
2839        } => {
2840            let role = if role == "user" {
2841                ApiRole::User
2842            } else {
2843                ApiRole::Assistant
2844            };
2845            let parts: Vec<ApiInputContent> = content
2846                .into_iter()
2847                .filter_map(|content| match content {
2848                    ApiOutputContent::Text { text }
2849                    | ApiOutputContent::Refusal { refusal: text }
2850                        if !text.is_empty() =>
2851                    {
2852                        Some(ApiInputContent::OutputText { text })
2853                    }
2854                    ApiOutputContent::Unknown
2855                    | ApiOutputContent::Text { .. }
2856                    | ApiOutputContent::Refusal { .. } => None,
2857                })
2858                .collect();
2859            if parts.is_empty() {
2860                None
2861            } else {
2862                Some(ApiInputItem::Message(ApiMessage {
2863                    role,
2864                    content: ApiMessageContent::Parts(parts),
2865                    phase,
2866                }))
2867            }
2868        }
2869        ApiOutputItem::FunctionCall {
2870            call_id,
2871            name,
2872            arguments,
2873        } => Some(ApiInputItem::FunctionCall(ApiFunctionCall::new(
2874            call_id, name, arguments,
2875        ))),
2876        ApiOutputItem::Reasoning { fields } => Some(ApiInputItem::OpaqueReasoning(
2877            reasoning_output_item(&fields),
2878        )),
2879        ApiOutputItem::Unknown => None,
2880    }
2881}
2882
2883fn prepare_websocket_request(
2884    request: &ApiStreamingRequest,
2885    session: &WebsocketSessionState,
2886    allow_empty_delta: bool,
2887) -> ApiWebsocketRequest {
2888    let mut websocket_request = ApiWebsocketRequest::from(request);
2889
2890    let Some(last_request) = session.last_request.as_ref() else {
2891        return websocket_request;
2892    };
2893    let Some(last_response_id) = session.last_response_id.as_ref() else {
2894        return websocket_request;
2895    };
2896
2897    let mut previous_without_input = last_request.clone();
2898    previous_without_input.input.clear();
2899    let mut current_without_input = request.clone();
2900    current_without_input.input.clear();
2901    if previous_without_input != current_without_input {
2902        return websocket_request;
2903    }
2904
2905    let mut baseline = last_request.input.clone();
2906    baseline.extend(session.last_response_items.clone());
2907    if request.input.starts_with(&baseline)
2908        && (allow_empty_delta || baseline.len() < request.input.len())
2909    {
2910        websocket_request.previous_response_id = Some(last_response_id.clone());
2911        websocket_request.input = request.input[baseline.len()..].to_vec();
2912    }
2913
2914    websocket_request
2915}
2916
2917// ============================================================================
2918// API Request Types
2919// ============================================================================
2920
2921#[derive(Clone, PartialEq, Serialize)]
2922struct ApiStreamingRequest {
2923    model: String,
2924    #[serde(skip_serializing_if = "String::is_empty")]
2925    instructions: String,
2926    input: Vec<ApiInputItem>,
2927    #[serde(skip_serializing_if = "Option::is_none")]
2928    tools: Option<Vec<ApiTool>>,
2929    #[serde(skip_serializing_if = "Option::is_none")]
2930    max_output_tokens: Option<u32>,
2931    #[serde(skip_serializing_if = "Option::is_none")]
2932    reasoning: Option<ApiReasoning>,
2933    #[serde(skip_serializing_if = "Option::is_none")]
2934    tool_choice: Option<ApiToolChoice>,
2935    #[serde(skip_serializing_if = "Option::is_none")]
2936    parallel_tool_calls: Option<bool>,
2937    store: bool,
2938    #[serde(skip_serializing_if = "Option::is_none")]
2939    text: Option<ApiTextSettings>,
2940    #[serde(skip_serializing_if = "Option::is_none")]
2941    include: Option<Vec<String>>,
2942    #[serde(skip_serializing_if = "Option::is_none")]
2943    prompt_cache_key: Option<String>,
2944    stream: bool,
2945}
2946
2947#[derive(Clone, Serialize)]
2948struct ApiWebsocketRequest {
2949    #[serde(rename = "type")]
2950    kind: &'static str,
2951    model: String,
2952    #[serde(skip_serializing_if = "String::is_empty")]
2953    instructions: String,
2954    #[serde(skip_serializing_if = "Option::is_none")]
2955    previous_response_id: Option<String>,
2956    input: Vec<ApiInputItem>,
2957    #[serde(skip_serializing_if = "Option::is_none")]
2958    tools: Option<Vec<ApiTool>>,
2959    #[serde(skip_serializing_if = "Option::is_none")]
2960    max_output_tokens: Option<u32>,
2961    #[serde(skip_serializing_if = "Option::is_none")]
2962    reasoning: Option<ApiReasoning>,
2963    #[serde(skip_serializing_if = "Option::is_none")]
2964    tool_choice: Option<ApiToolChoice>,
2965    #[serde(skip_serializing_if = "Option::is_none")]
2966    parallel_tool_calls: Option<bool>,
2967    store: bool,
2968    #[serde(skip_serializing_if = "Option::is_none")]
2969    text: Option<ApiTextSettings>,
2970    #[serde(skip_serializing_if = "Option::is_none")]
2971    include: Option<Vec<String>>,
2972    #[serde(skip_serializing_if = "Option::is_none")]
2973    prompt_cache_key: Option<String>,
2974    stream: bool,
2975    #[serde(skip_serializing_if = "Option::is_none")]
2976    generate: Option<bool>,
2977}
2978
2979impl From<&ApiStreamingRequest> for ApiWebsocketRequest {
2980    fn from(request: &ApiStreamingRequest) -> Self {
2981        Self {
2982            kind: "response.create",
2983            model: request.model.clone(),
2984            instructions: request.instructions.clone(),
2985            previous_response_id: None,
2986            input: request.input.clone(),
2987            tools: request.tools.clone(),
2988            max_output_tokens: request.max_output_tokens,
2989            reasoning: request.reasoning.clone(),
2990            tool_choice: request.tool_choice.clone(),
2991            parallel_tool_calls: request.parallel_tool_calls,
2992            store: request.store,
2993            text: request.text.clone(),
2994            include: request.include.clone(),
2995            prompt_cache_key: request.prompt_cache_key.clone(),
2996            stream: request.stream,
2997            generate: None,
2998        }
2999    }
3000}
3001
3002#[derive(Clone, PartialEq, Serialize)]
3003struct ApiTextSettings {
3004    verbosity: &'static str,
3005    /// Structured-output schema (`text.format`), set when the request carries a
3006    /// `response_format`.
3007    #[serde(skip_serializing_if = "Option::is_none")]
3008    format: Option<ApiResponseTextFormat>,
3009}
3010
3011#[derive(Clone, PartialEq, Serialize)]
3012struct ApiResponseTextFormat {
3013    #[serde(rename = "type")]
3014    format_type: &'static str,
3015    name: String,
3016    schema: serde_json::Value,
3017    strict: bool,
3018}
3019
3020impl From<&ResponseFormat> for ApiResponseTextFormat {
3021    fn from(rf: &ResponseFormat) -> Self {
3022        Self {
3023            format_type: "json_schema",
3024            name: rf.name.clone(),
3025            schema: rf.schema.clone(),
3026            strict: rf.strict,
3027        }
3028    }
3029}
3030
3031/// Responses API `tool_choice` wire format.
3032///
3033/// - `"auto"` — model decides (the Codex default).
3034/// - `{"type": "function", "name": "<name>"}` — force a specific function.
3035#[derive(Clone, PartialEq, Serialize)]
3036#[serde(untagged)]
3037enum ApiToolChoice {
3038    Mode(&'static str),
3039    Function {
3040        #[serde(rename = "type")]
3041        choice_type: &'static str,
3042        name: String,
3043    },
3044}
3045
3046/// Map an optional [`ToolChoice`] onto the Codex wire `tool_choice`, defaulting
3047/// to `"auto"` (the historical Codex behavior) when unset.
3048fn codex_tool_choice(tool_choice: Option<&ToolChoice>) -> ApiToolChoice {
3049    match tool_choice {
3050        Some(ToolChoice::Tool(name)) => ApiToolChoice::Function {
3051            choice_type: "function",
3052            name: name.clone(),
3053        },
3054        _ => ApiToolChoice::Mode("auto"),
3055    }
3056}
3057
3058#[derive(Clone, PartialEq, Serialize)]
3059struct ApiReasoning {
3060    effort: ReasoningEffort,
3061}
3062
3063#[derive(Clone, PartialEq, Serialize)]
3064#[serde(untagged)]
3065enum ApiInputItem {
3066    Message(ApiMessage),
3067    FunctionCall(ApiFunctionCall),
3068    FunctionCallOutput(ApiFunctionCallOutput),
3069    OpaqueReasoning(serde_json::Value),
3070}
3071
3072#[derive(Clone, PartialEq, Serialize)]
3073struct ApiMessage {
3074    role: ApiRole,
3075    content: ApiMessageContent,
3076    #[serde(skip_serializing_if = "Option::is_none")]
3077    phase: Option<String>,
3078}
3079
3080#[derive(Clone, Copy, PartialEq, Serialize)]
3081#[serde(rename_all = "lowercase")]
3082enum ApiRole {
3083    User,
3084    Assistant,
3085}
3086
3087#[derive(Clone, PartialEq, Serialize)]
3088#[serde(untagged)]
3089enum ApiMessageContent {
3090    Text(String),
3091    Parts(Vec<ApiInputContent>),
3092}
3093
3094#[derive(Clone, PartialEq, Serialize)]
3095#[serde(tag = "type")]
3096enum ApiInputContent {
3097    #[serde(rename = "input_text")]
3098    InputText { text: String },
3099    #[serde(rename = "output_text")]
3100    OutputText { text: String },
3101    #[serde(rename = "input_image")]
3102    Image { image_url: String },
3103    #[serde(rename = "input_file")]
3104    File { filename: String, file_data: String },
3105}
3106
3107#[derive(Clone, PartialEq, Serialize)]
3108struct ApiFunctionCall {
3109    r#type: &'static str,
3110    call_id: String,
3111    name: String,
3112    arguments: String,
3113}
3114
3115impl ApiFunctionCall {
3116    const fn new(call_id: String, name: String, arguments: String) -> Self {
3117        Self {
3118            r#type: "function_call",
3119            call_id,
3120            name,
3121            arguments,
3122        }
3123    }
3124}
3125
3126#[derive(Clone, PartialEq, Serialize)]
3127struct ApiFunctionCallOutput {
3128    r#type: &'static str,
3129    call_id: String,
3130    output: String,
3131}
3132
3133impl ApiFunctionCallOutput {
3134    const fn new(call_id: String, output: String) -> Self {
3135        Self {
3136            r#type: "function_call_output",
3137            call_id,
3138            output,
3139        }
3140    }
3141}
3142
3143#[derive(Clone, PartialEq, Serialize)]
3144struct ApiTool {
3145    r#type: String,
3146    name: String,
3147    #[serde(skip_serializing_if = "Option::is_none")]
3148    description: Option<String>,
3149    #[serde(skip_serializing_if = "Option::is_none")]
3150    parameters: Option<serde_json::Value>,
3151    #[serde(skip_serializing_if = "Option::is_none")]
3152    strict: Option<bool>,
3153}
3154
3155// ============================================================================
3156// API Response Types
3157// ============================================================================
3158
3159#[cfg(test)]
3160#[derive(Deserialize)]
3161struct ApiResponse {
3162    id: String,
3163    model: String,
3164    output: Vec<ApiOutputItem>,
3165    #[serde(default)]
3166    status: Option<ApiStatus>,
3167    #[serde(default)]
3168    usage: Option<ApiUsage>,
3169    #[serde(default)]
3170    incomplete_details: Option<ApiIncompleteDetails>,
3171}
3172
3173#[derive(Deserialize)]
3174struct ApiIncompleteDetails {
3175    #[serde(default)]
3176    reason: Option<String>,
3177}
3178
3179#[derive(Clone, Copy, Deserialize)]
3180#[serde(rename_all = "snake_case")]
3181enum ApiStatus {
3182    Completed,
3183    Incomplete,
3184    Failed,
3185    // Non-terminal statuses ride lifecycle events (`response.created`
3186    // streams `"status":"in_progress"` on the GPT-5.6 ChatGPT backend,
3187    // observed live 2026-07-10) — they must parse without killing the
3188    // stream even though no consumer branches on them.
3189    InProgress,
3190    Queued,
3191    Cancelled,
3192    // Forward-compat: a status this build does not know is not a broken
3193    // stream. Consumers treat it like the existing non-completed arms.
3194    #[serde(other)]
3195    Other,
3196}
3197
3198#[derive(Deserialize)]
3199struct ApiUsage {
3200    input_tokens: u32,
3201    output_tokens: u32,
3202    #[serde(default)]
3203    input_tokens_details: Option<ApiInputTokensDetails>,
3204}
3205
3206#[derive(Deserialize)]
3207struct ApiInputTokensDetails {
3208    #[serde(default)]
3209    cached_tokens: u32,
3210    #[serde(default)]
3211    cache_write_tokens: u32,
3212}
3213
3214#[derive(Deserialize)]
3215#[serde(tag = "type")]
3216enum ApiOutputItem {
3217    #[serde(rename = "message")]
3218    Message {
3219        role: String,
3220        #[serde(default)]
3221        phase: Option<String>,
3222        content: Vec<ApiOutputContent>,
3223    },
3224    #[serde(rename = "function_call")]
3225    FunctionCall {
3226        call_id: String,
3227        name: String,
3228        arguments: String,
3229    },
3230    #[serde(rename = "reasoning")]
3231    Reasoning {
3232        #[serde(flatten)]
3233        fields: serde_json::Map<String, serde_json::Value>,
3234    },
3235    #[serde(other)]
3236    Unknown,
3237}
3238
3239#[derive(Deserialize)]
3240#[serde(tag = "type")]
3241enum ApiOutputContent {
3242    #[serde(rename = "output_text")]
3243    Text { text: String },
3244    #[serde(rename = "refusal")]
3245    Refusal { refusal: String },
3246    #[serde(other)]
3247    Unknown,
3248}
3249
3250// ============================================================================
3251// Streaming Types
3252// ============================================================================
3253
3254#[derive(Deserialize)]
3255struct ApiStreamEvent {
3256    r#type: String,
3257    #[serde(default)]
3258    output_index: Option<usize>,
3259    #[serde(default)]
3260    delta: Option<String>,
3261    #[serde(default)]
3262    call_id: Option<String>,
3263    #[serde(default)]
3264    name: Option<String>,
3265    #[serde(default)]
3266    item: Option<serde_json::Value>,
3267    #[serde(default)]
3268    response: Option<ApiStreamResponse>,
3269}
3270
3271#[derive(Deserialize)]
3272struct ApiStreamResponse {
3273    #[serde(default)]
3274    id: Option<String>,
3275    #[serde(default)]
3276    usage: Option<ApiUsage>,
3277    #[serde(default)]
3278    error: Option<ApiErrorBody>,
3279    #[serde(default)]
3280    status: Option<ApiStatus>,
3281    #[serde(default)]
3282    incomplete_details: Option<ApiIncompleteDetails>,
3283}
3284
3285#[derive(Deserialize)]
3286struct ApiErrorBody {
3287    #[serde(default)]
3288    message: Option<String>,
3289    /// Machine-readable error code (e.g. `rate_limit_exceeded`), used to
3290    /// classify a failure the HTTP status cannot describe because the stream
3291    /// already opened with 200.
3292    #[serde(default)]
3293    code: Option<String>,
3294}
3295
3296#[derive(Deserialize)]
3297struct ApiWrappedWebsocketErrorBody {
3298    #[serde(default)]
3299    code: Option<String>,
3300    #[serde(default)]
3301    message: Option<String>,
3302    /// Seconds until the exhausted usage limit resets. Codex states its
3303    /// standard `usage_limit_reached` delay here rather than in the message
3304    /// (which is the fixed prose "The usage limit has been reached"), so it is
3305    /// the only usable hint on that frame.
3306    #[serde(default)]
3307    resets_in_seconds: Option<f64>,
3308    /// Absolute reset instant (Unix seconds), sent by some frames instead of
3309    /// the relative field. `f64` because the service sometimes encodes it as a
3310    /// JSON float — an `i64` field would fail the whole frame parse on such a
3311    /// value, losing the hint and the rate-limit classification with it.
3312    #[serde(default)]
3313    resets_at: Option<f64>,
3314}
3315
3316#[derive(Deserialize)]
3317struct ApiWrappedWebsocketErrorEvent {
3318    #[serde(rename = "type")]
3319    kind: String,
3320    #[serde(alias = "status_code")]
3321    status: Option<u16>,
3322    #[serde(default)]
3323    error: Option<ApiWrappedWebsocketErrorBody>,
3324}
3325
3326// ============================================================================
3327// Tests
3328// ============================================================================
3329
3330#[cfg(test)]
3331mod tests {
3332    use super::*;
3333
3334    #[test]
3335    fn test_model_constant() {
3336        assert_eq!(MODEL_GPT54, "gpt-5.4");
3337        assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
3338        assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
3339    }
3340
3341    #[test]
3342    fn warmup_quota_rejection_is_surfaced_while_connection_limit_still_falls_back()
3343    -> anyhow::Result<()> {
3344        // The warmup (`generate=false`) branches route on the same discriminator
3345        // as the in-flight ones: a quota rejection must be surfaced with its
3346        // delay rather than immediately re-sent inside the wait window, while
3347        // the connection-limit sentinel — reported with the same synthetic 429 —
3348        // must keep falling back to HTTP at once.
3349        let quota = parse_wrapped_websocket_error_event(
3350            r#"{"type":"error","status":429,"error":{"code":"rate_limit_exceeded","message":"Rate limit reached. Please try again in 45s."}}"#,
3351        )
3352        .context("expected a wrapped error")?;
3353        assert!(is_websocket_quota_rejection(&quota));
3354        let kind = websocket_error_kind(&quota);
3355        assert_eq!(
3356            kind,
3357            StreamErrorKind::RateLimited(Some(std::time::Duration::from_secs(45)))
3358        );
3359        assert!(kind.is_recoverable());
3360
3361        // The same warmup path carries a structured reset hint when the frame is
3362        // Codex's standard usage-limit shape, whose prose states no timing.
3363        let usage_limit = parse_wrapped_websocket_error_event(
3364            r#"{"type":"error","status":429,"error":{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_in_seconds":900}}"#,
3365        )
3366        .context("expected a wrapped error")?;
3367        assert!(is_websocket_quota_rejection(&usage_limit));
3368        assert_eq!(
3369            websocket_error_kind(&usage_limit),
3370            StreamErrorKind::RateLimited(Some(std::time::Duration::from_mins(15))),
3371            "the structured reset must reach the retry loop through the warmup path"
3372        );
3373
3374        let connection_limit = parse_wrapped_websocket_error_event(&format!(
3375            r#"{{"type":"error","status":429,"error":{{"code":"{OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE}","message":"limit"}}}}"#,
3376        ))
3377        .context("expected a wrapped error")?;
3378        assert!(
3379            !is_websocket_quota_rejection(&connection_limit),
3380            "the connection-limit sentinel must keep its immediate fallback"
3381        );
3382        Ok(())
3383    }
3384
3385    #[test]
3386    fn in_band_response_failed_rate_limit_keeps_its_hint() -> anyhow::Result<()> {
3387        let event: ApiStreamEvent = serde_json::from_str(
3388            r#"{"type":"response.failed","response":{"error":{"code":"rate_limit_exceeded","message":"Rate limit reached. Please try again in 20s."}}}"#,
3389        )?;
3390        let failure = codex_response_failed_error(event.response);
3391
3392        assert!(failure.message.contains("try again in 20s"));
3393        assert_eq!(
3394            failure.kind,
3395            StreamErrorKind::RateLimited(Some(std::time::Duration::from_secs(20)))
3396        );
3397        assert!(failure.kind.is_recoverable());
3398        Ok(())
3399    }
3400
3401    #[test]
3402    fn in_band_response_failed_without_a_rate_limit_code_is_a_server_error() -> anyhow::Result<()> {
3403        let event: ApiStreamEvent = serde_json::from_str(
3404            r#"{"type":"response.failed","response":{"error":{"code":"server_error","message":"upstream blew up"}}}"#,
3405        )?;
3406        let failure = codex_response_failed_error(event.response);
3407
3408        assert_eq!(failure.message, "upstream blew up");
3409        assert_eq!(failure.kind, StreamErrorKind::ServerError);
3410        Ok(())
3411    }
3412
3413    #[test]
3414    fn in_band_response_failed_context_window_rejection_is_fatal() -> anyhow::Result<()> {
3415        // The exact `response.failed` shape the Responses API emits when the
3416        // staged history overflows the model: a fatal request-shape error so
3417        // the worker's overflow compaction fires instead of blind retries.
3418        let event: ApiStreamEvent = serde_json::from_str(
3419            r#"{"type":"response.failed","response":{"error":{"code":"context_length_exceeded","message":"Your input exceeds the context window of this model. Please adjust your input and try again."}}}"#,
3420        )?;
3421        let failure = codex_response_failed_error(event.response);
3422
3423        assert_eq!(failure.kind, StreamErrorKind::InvalidRequest);
3424        assert!(!failure.kind.is_recoverable());
3425
3426        // A wrapped/proxied failure that drops the code is still caught by
3427        // the prose shape.
3428        let event: ApiStreamEvent = serde_json::from_str(
3429            r#"{"type":"response.failed","response":{"error":{"message":"Your input exceeds the context window of this model. Please adjust your input and try again."}}}"#,
3430        )?;
3431        let failure = codex_response_failed_error(event.response);
3432
3433        assert_eq!(failure.kind, StreamErrorKind::InvalidRequest);
3434        Ok(())
3435    }
3436
3437    #[test]
3438    fn in_band_response_failed_keeps_the_usage_it_reported() -> anyhow::Result<()> {
3439        // The failed response still burned tokens; they must reach the stream so
3440        // the caller bills them, not vanish with the error.
3441        let event: ApiStreamEvent = serde_json::from_str(
3442            r#"{"type":"response.failed","response":{"usage":{"input_tokens":120,"output_tokens":34},"error":{"code":"rate_limit_exceeded","message":"Please try again in 20s."}}}"#,
3443        )?;
3444        let failure = codex_response_failed_error(event.response);
3445
3446        let usage = failure.usage.context("failed response must report usage")?;
3447        assert_eq!(usage.input_tokens, 120);
3448        assert_eq!(usage.output_tokens, 34);
3449        assert_eq!(
3450            failure.kind,
3451            StreamErrorKind::RateLimited(Some(std::time::Duration::from_secs(20)))
3452        );
3453        Ok(())
3454    }
3455
3456    #[test]
3457    fn test_codex_factory() {
3458        let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
3459        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
3460        assert_eq!(provider.provider(), "openai-codex");
3461    }
3462
3463    #[test]
3464    fn test_gpt54_factory() {
3465        let provider = OpenAICodexResponsesProvider::gpt54("test-key".to_string());
3466        assert_eq!(provider.model(), MODEL_GPT54);
3467        assert_eq!(provider.provider(), "openai-codex");
3468    }
3469
3470    #[test]
3471    fn test_gpt53_codex_factory() {
3472        let provider = OpenAICodexResponsesProvider::gpt53_codex("test-key".to_string());
3473        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
3474        assert_eq!(provider.provider(), "openai-codex");
3475    }
3476
3477    #[test]
3478    fn test_reasoning_effort_serialization() {
3479        let low = serde_json::to_string(&ReasoningEffort::Low).unwrap();
3480        assert_eq!(low, "\"low\"");
3481
3482        let xhigh = serde_json::to_string(&ReasoningEffort::XHigh).unwrap();
3483        assert_eq!(xhigh, "\"xhigh\"");
3484    }
3485
3486    #[test]
3487    fn test_with_reasoning_effort() {
3488        let provider = OpenAICodexResponsesProvider::codex("test-key".to_string())
3489            .with_reasoning_effort(ReasoningEffort::High);
3490        let thinking = provider.thinking.as_ref().unwrap();
3491        assert!(matches!(thinking.effort, Some(Effort::High)));
3492    }
3493
3494    #[test]
3495    fn test_build_api_reasoning_uses_explicit_effort() {
3496        let reasoning =
3497            build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::Low))).unwrap();
3498        assert!(matches!(reasoning.effort, ReasoningEffort::Low));
3499    }
3500
3501    #[test]
3502    fn test_build_api_reasoning_passes_effort_for_every_mode() {
3503        for config in [
3504            ThinkingConfig::adaptive_with_effort(Effort::XHigh),
3505            ThinkingConfig::default_with_effort(Effort::XHigh),
3506            ThinkingConfig::new(10_000).with_effort(Effort::XHigh),
3507        ] {
3508            let reasoning = build_api_reasoning(Some(&config)).unwrap();
3509            assert_eq!(
3510                reasoning.effort,
3511                ReasoningEffort::XHigh,
3512                "a configured effort must never be cancelled by the mode",
3513            );
3514        }
3515    }
3516
3517    #[test]
3518    fn test_build_api_reasoning_omits_adaptive_without_effort() {
3519        assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
3520    }
3521
3522    #[test]
3523    fn test_openai_responses_accepts_adaptive_thinking() {
3524        let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
3525        assert!(
3526            provider
3527                .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
3528                .is_ok()
3529        );
3530    }
3531
3532    #[test]
3533    fn test_api_tool_serialization() {
3534        let tool = ApiTool {
3535            r#type: "function".to_owned(),
3536            name: "get_weather".to_owned(),
3537            description: Some("Get weather".to_owned()),
3538            parameters: Some(serde_json::json!({"type": "object"})),
3539            strict: Some(true),
3540        };
3541
3542        let json = serde_json::to_string(&tool).unwrap();
3543        assert!(json.contains("\"type\":\"function\""));
3544        assert!(json.contains("\"name\":\"get_weather\""));
3545        assert!(json.contains("\"strict\":true"));
3546    }
3547
3548    fn test_token() -> String {
3549        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
3550        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(
3551            r#"{{"{OPENAI_CODEX_JWT_CLAIM_PATH}":{{"chatgpt_account_id":"acct_123"}}}}"#
3552        ));
3553        format!("{header}.{payload}.sig")
3554    }
3555
3556    #[test]
3557    fn test_build_headers_match_codex_style_defaults() -> anyhow::Result<()> {
3558        let provider = OpenAICodexResponsesProvider::codex(test_token());
3559
3560        let headers = provider.build_headers(true, Some("session-123"), None)?;
3561        assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
3562        assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
3563        assert_eq!(headers.get("session_id").unwrap(), "session-123");
3564        assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
3565        assert_eq!(
3566            headers.get("OpenAI-Beta").unwrap(),
3567            OPENAI_CODEX_RESPONSES_BETA_HEADER
3568        );
3569
3570        Ok(())
3571    }
3572
3573    #[test]
3574    fn test_build_websocket_headers_match_codex_style_defaults() -> anyhow::Result<()> {
3575        let provider = OpenAICodexResponsesProvider::codex(test_token());
3576
3577        let headers = provider.build_websocket_headers(Some("session-123"), Some("turn-1"))?;
3578        assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
3579        assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
3580        assert_eq!(headers.get("session_id").unwrap(), "session-123");
3581        assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
3582        assert_eq!(
3583            headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
3584            "turn-1"
3585        );
3586        assert_eq!(
3587            headers.get("OpenAI-Beta").unwrap(),
3588            OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER,
3589        );
3590
3591        Ok(())
3592    }
3593
3594    #[test]
3595    fn test_build_headers_uses_configured_account_id_without_jwt_decode() -> anyhow::Result<()> {
3596        let provider = OpenAICodexResponsesProvider::codex("not-a-jwt".to_string())
3597            .with_account_id("acct_stored");
3598
3599        let headers = provider.build_headers(true, Some("session-123"), Some("turn-1"))?;
3600        assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_stored");
3601        assert_eq!(
3602            headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
3603            "turn-1"
3604        );
3605
3606        Ok(())
3607    }
3608
3609    #[test]
3610    fn test_request_serialization_includes_store_false() {
3611        let request = ApiStreamingRequest {
3612            model: MODEL_GPT53_CODEX.to_string(),
3613            instructions: "system".to_string(),
3614            input: Vec::new(),
3615            tools: None,
3616            max_output_tokens: None,
3617            reasoning: None,
3618            tool_choice: Some(ApiToolChoice::Mode("auto")),
3619            parallel_tool_calls: Some(true),
3620            store: false,
3621            text: Some(ApiTextSettings {
3622                verbosity: "medium",
3623                format: None,
3624            }),
3625            include: Some(vec!["reasoning.encrypted_content".to_string()]),
3626            prompt_cache_key: Some("session-123".to_string()),
3627            stream: true,
3628        };
3629
3630        let json = serde_json::to_string(&request).unwrap();
3631        assert!(json.contains("\"store\":false"));
3632        assert!(json.contains("\"stream\":true"));
3633    }
3634
3635    #[test]
3636    fn test_prepare_websocket_request_uses_previous_response_id_for_incremental_input() {
3637        let request = ApiStreamingRequest {
3638            model: MODEL_GPT53_CODEX.to_string(),
3639            instructions: "system".to_string(),
3640            input: vec![
3641                ApiInputItem::Message(ApiMessage {
3642                    role: ApiRole::User,
3643                    content: ApiMessageContent::Text("first".to_string()),
3644                    phase: None,
3645                }),
3646                ApiInputItem::Message(ApiMessage {
3647                    role: ApiRole::Assistant,
3648                    content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3649                        text: "answer".to_string(),
3650                    }]),
3651                    phase: Some("final_answer".to_owned()),
3652                }),
3653                ApiInputItem::Message(ApiMessage {
3654                    role: ApiRole::User,
3655                    content: ApiMessageContent::Text("follow up".to_string()),
3656                    phase: None,
3657                }),
3658            ],
3659            tools: None,
3660            max_output_tokens: None,
3661            reasoning: None,
3662            tool_choice: Some(ApiToolChoice::Mode("auto")),
3663            parallel_tool_calls: None,
3664            store: false,
3665            text: Some(ApiTextSettings {
3666                verbosity: "medium",
3667                format: None,
3668            }),
3669            include: Some(vec!["reasoning.encrypted_content".to_string()]),
3670            prompt_cache_key: Some("thread-1".to_string()),
3671            stream: true,
3672        };
3673        let previous_request = ApiStreamingRequest {
3674            input: vec![ApiInputItem::Message(ApiMessage {
3675                role: ApiRole::User,
3676                content: ApiMessageContent::Text("first".to_string()),
3677                phase: None,
3678            })],
3679            ..request.clone()
3680        };
3681        let session = WebsocketSessionState {
3682            connection: None,
3683            last_request: Some(previous_request),
3684            last_response_id: Some("resp_prev".to_string()),
3685            last_response_items: vec![ApiInputItem::Message(ApiMessage {
3686                role: ApiRole::Assistant,
3687                content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3688                    text: "answer".to_string(),
3689                }]),
3690                phase: Some("final_answer".to_owned()),
3691            })],
3692            turn_state: None,
3693            prewarmed: false,
3694            websocket_disabled: false,
3695            in_flight: false,
3696            last_used: None,
3697        };
3698
3699        let websocket_request = prepare_websocket_request(&request, &session, false);
3700        assert_eq!(
3701            websocket_request.previous_response_id.as_deref(),
3702            Some("resp_prev")
3703        );
3704        assert_eq!(websocket_request.input.len(), 1);
3705        match &websocket_request.input[0] {
3706            ApiInputItem::Message(ApiMessage {
3707                role: ApiRole::User,
3708                content: ApiMessageContent::Text(text),
3709                ..
3710            }) => assert_eq!(text, "follow up"),
3711            _ => panic!("expected incremental follow-up user message"),
3712        }
3713    }
3714
3715    #[test]
3716    fn end_websocket_turn_clears_the_incremental_baseline_for_the_retry() {
3717        // A reused session with an established baseline (turn 1 succeeded, so it
3718        // carries `previous_response_id` + its input prefix). Turn 2 then hits a
3719        // terminal websocket error and ends via `end_websocket_turn`.
3720        let previous_request = ApiStreamingRequest {
3721            model: MODEL_GPT53_CODEX.to_string(),
3722            instructions: "system".to_string(),
3723            input: vec![ApiInputItem::Message(ApiMessage {
3724                role: ApiRole::User,
3725                content: ApiMessageContent::Text("first".to_string()),
3726                phase: None,
3727            })],
3728            tools: None,
3729            max_output_tokens: None,
3730            reasoning: None,
3731            tool_choice: Some(ApiToolChoice::Mode("auto")),
3732            parallel_tool_calls: None,
3733            store: false,
3734            text: Some(ApiTextSettings {
3735                verbosity: "medium",
3736                format: None,
3737            }),
3738            include: None,
3739            prompt_cache_key: Some("thread-1".to_string()),
3740            stream: true,
3741        };
3742        let mut session = WebsocketSessionState {
3743            connection: None,
3744            last_request: Some(previous_request.clone()),
3745            last_response_id: Some("resp_prev".to_string()),
3746            last_response_items: vec![ApiInputItem::Message(ApiMessage {
3747                role: ApiRole::Assistant,
3748                content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3749                    text: "answer".to_string(),
3750                }]),
3751                phase: Some("final_answer".to_owned()),
3752            })],
3753            turn_state: None,
3754            // A real turn established the baseline, so it is not prewarmed —
3755            // the case where `reset_websocket_connection` would NOT clear it.
3756            prewarmed: false,
3757            websocket_disabled: false,
3758            in_flight: true,
3759            last_used: None,
3760        };
3761
3762        // The retry's request EXTENDS the baseline (`[first, answer]`) with a
3763        // follow-up, so an intact baseline would let `prepare_websocket_request`
3764        // emit `previous_response_id` + only the delta. This makes the assertions
3765        // below a true differential: they flip on the baseline clear, not merely
3766        // on a shape mismatch.
3767        let retry_request = ApiStreamingRequest {
3768            input: vec![
3769                ApiInputItem::Message(ApiMessage {
3770                    role: ApiRole::User,
3771                    content: ApiMessageContent::Text("first".to_string()),
3772                    phase: None,
3773                }),
3774                ApiInputItem::Message(ApiMessage {
3775                    role: ApiRole::Assistant,
3776                    content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3777                        text: "answer".to_string(),
3778                    }]),
3779                    phase: Some("final_answer".to_owned()),
3780                }),
3781                ApiInputItem::Message(ApiMessage {
3782                    role: ApiRole::User,
3783                    content: ApiMessageContent::Text("follow up".to_string()),
3784                    phase: None,
3785                }),
3786            ],
3787            ..previous_request
3788        };
3789
3790        // Sanity: with the baseline still present, this same request WOULD build
3791        // an incremental one — so the assertions after the clear are meaningful.
3792        let incremental = prepare_websocket_request(&retry_request, &session, false);
3793
3794        assert_eq!(
3795            incremental.previous_response_id.as_deref(),
3796            Some("resp_prev"),
3797            "precondition: an intact baseline yields an incremental request",
3798        );
3799        assert_eq!(incremental.input.len(), 1);
3800
3801        end_websocket_turn(&mut session);
3802
3803        // End-to-end family (checked first so it is a standalone differential,
3804        // not shadowed by the field asserts): the baseline is gone, so the same
3805        // retry request now builds a FULL request on the fresh socket — no
3806        // `previous_response_id` a new connection would reject, and the whole
3807        // input rather than an incremental delta.
3808        let websocket_request = prepare_websocket_request(&retry_request, &session, false);
3809        assert!(
3810            websocket_request.previous_response_id.is_none(),
3811            "a retry after a terminal WS error must not reuse the dead socket's response id",
3812        );
3813        assert_eq!(
3814            websocket_request.input.len(),
3815            3,
3816            "the retry must send the full input, not an incremental delta",
3817        );
3818
3819        // Field family: the baseline fields themselves are cleared, and the
3820        // session is evictable.
3821        assert!(session.last_request.is_none());
3822        assert!(session.last_response_id.is_none());
3823        assert!(session.last_response_items.is_empty());
3824        assert!(!session.in_flight, "the ended turn must be evictable");
3825    }
3826
3827    #[test]
3828    fn websocket_error_classification_is_network_specific() {
3829        use tokio_tungstenite::tungstenite::Error as WebSocketError;
3830
3831        assert_eq!(
3832            websocket_transport_error_kind(&WebSocketError::ConnectionClosed),
3833            StreamErrorKind::ConnectionLost
3834        );
3835        assert_eq!(
3836            websocket_transport_error_kind(&WebSocketError::Io(std::io::Error::new(
3837                std::io::ErrorKind::ConnectionReset,
3838                "reset",
3839            ))),
3840            StreamErrorKind::ConnectionLost
3841        );
3842        assert_eq!(
3843            websocket_transport_error_kind(&WebSocketError::AlreadyClosed),
3844            StreamErrorKind::ServerError
3845        );
3846        assert_eq!(
3847            websocket_transport_error_kind(&WebSocketError::Utf8("invalid".to_owned())),
3848            StreamErrorKind::ServerError
3849        );
3850    }
3851
3852    #[test]
3853    fn test_parse_wrapped_websocket_error_event_maps_http_status() -> anyhow::Result<()> {
3854        let payload = r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
3855        let parsed =
3856            parse_wrapped_websocket_error_event(payload).context("expected a wrapped error")?;
3857
3858        assert_eq!(parsed.status, StatusCode::UNAUTHORIZED);
3859        assert_eq!(parsed.message, "unauthorized");
3860        assert!(!parsed.connection_limit);
3861        Ok(())
3862    }
3863
3864    #[test]
3865    fn test_parse_wrapped_websocket_error_event_maps_connection_limit() -> anyhow::Result<()> {
3866        let payload = format!(
3867            r#"{{"type":"error","status":429,"error":{{"code":"{OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE}","message":"limit"}}}}"#,
3868        );
3869        let parsed =
3870            parse_wrapped_websocket_error_event(&payload).context("expected a wrapped error")?;
3871
3872        assert_eq!(parsed.status, StatusCode::TOO_MANY_REQUESTS);
3873        assert_eq!(parsed.message, "limit");
3874        assert!(parsed.connection_limit);
3875        // The connection limit is a transport condition, not a model quota: the
3876        // stream must keep falling back to HTTP at once rather than waiting.
3877        assert!(
3878            !is_websocket_quota_rejection(&parsed),
3879            "the connection-limit fallback must not be treated as a quota wait"
3880        );
3881        Ok(())
3882    }
3883
3884    #[test]
3885    fn pre_output_websocket_quota_rejection_surfaces_with_its_hint() -> anyhow::Result<()> {
3886        // A quota 429 normally arrives before any output. It must be surfaced as
3887        // a recoverable rate limit carrying the advertised delay — not swallowed
3888        // into an immediate retry that re-sends inside the wait window.
3889        let payload = r#"{"type":"error","status":429,"error":{"code":"rate_limit_exceeded","message":"Rate limit reached. Please try again in 30s."}}"#;
3890        let parsed =
3891            parse_wrapped_websocket_error_event(payload).context("expected a wrapped error")?;
3892
3893        assert!(!parsed.connection_limit);
3894        assert!(
3895            is_websocket_quota_rejection(&parsed),
3896            "a quota 429 must be surfaced rather than retried immediately"
3897        );
3898
3899        let kind = websocket_error_kind(&parsed);
3900        assert_eq!(
3901            kind,
3902            StreamErrorKind::RateLimited(Some(std::time::Duration::from_secs(30)))
3903        );
3904        assert!(kind.is_recoverable());
3905        Ok(())
3906    }
3907
3908    /// Current Unix time in seconds, for building `resets_at` fixtures.
3909    fn unix_now() -> i64 {
3910        i64::try_from(
3911            std::time::SystemTime::now()
3912                .duration_since(std::time::UNIX_EPOCH)
3913                .map(|since| since.as_secs())
3914                .unwrap_or_default(),
3915        )
3916        .unwrap_or_default()
3917    }
3918
3919    #[test]
3920    fn usage_limit_frame_carries_its_structured_reset() -> anyhow::Result<()> {
3921        // Codex's standard usage-limit frame states the wait in a structured
3922        // field and leaves the message as fixed prose with no timing in it, so
3923        // the field is the only hint there is.
3924        let parsed = parse_wrapped_websocket_error_event(
3925            r#"{"type":"error","status":429,"error":{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_in_seconds":1800}}"#,
3926        )
3927        .context("expected a wrapped error")?;
3928
3929        assert_eq!(
3930            websocket_error_kind(&parsed),
3931            StreamErrorKind::RateLimited(Some(std::time::Duration::from_mins(30)))
3932        );
3933        Ok(())
3934    }
3935
3936    #[test]
3937    fn usage_limit_reset_falls_back_to_the_absolute_instant() -> anyhow::Result<()> {
3938        let future = unix_now() + 600;
3939        let parsed = parse_wrapped_websocket_error_event(&format!(
3940            r#"{{"type":"error","status":429,"error":{{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_at":{future}}}}}"#,
3941        ))
3942        .context("expected a wrapped error")?;
3943
3944        let StreamErrorKind::RateLimited(Some(delay)) = websocket_error_kind(&parsed) else {
3945            anyhow::bail!("a usage-limit frame must be a rate limit with a delay");
3946        };
3947        // Computed against the wall clock, so assert a window rather than an
3948        // exact value.
3949        assert!(
3950            delay <= std::time::Duration::from_mins(10)
3951                && delay >= std::time::Duration::from_secs(590),
3952            "the absolute reset must convert to a ~600s wait, got {delay:?}"
3953        );
3954        Ok(())
3955    }
3956
3957    #[test]
3958    fn usage_limit_reset_in_the_past_or_absent_reports_no_hint() -> anyhow::Result<()> {
3959        // A reset instant that has already elapsed says nothing about how long to
3960        // wait: it must not become a zero (retry-instantly) or negative delay.
3961        let past = unix_now() - 60;
3962        let elapsed = parse_wrapped_websocket_error_event(&format!(
3963            r#"{{"type":"error","status":429,"error":{{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_at":{past}}}}}"#,
3964        ))
3965        .context("expected a wrapped error")?;
3966        assert_eq!(
3967            websocket_error_kind(&elapsed),
3968            StreamErrorKind::RateLimited(None)
3969        );
3970
3971        // No reset fields at all: unchanged behaviour — the message is the only
3972        // source, and this prose carries no delay.
3973        let bare = parse_wrapped_websocket_error_event(
3974            r#"{"type":"error","status":429,"error":{"type":"usage_limit_reached","message":"The usage limit has been reached."}}"#,
3975        )
3976        .context("expected a wrapped error")?;
3977        assert_eq!(
3978            websocket_error_kind(&bare),
3979            StreamErrorKind::RateLimited(None)
3980        );
3981
3982        // A zero reset is not a delay either.
3983        let zero = parse_wrapped_websocket_error_event(
3984            r#"{"type":"error","status":429,"error":{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_in_seconds":0}}"#,
3985        )
3986        .context("expected a wrapped error")?;
3987        assert_eq!(
3988            websocket_error_kind(&zero),
3989            StreamErrorKind::RateLimited(None)
3990        );
3991        Ok(())
3992    }
3993
3994    #[test]
3995    fn structured_reset_wins_over_the_message_prose() -> anyhow::Result<()> {
3996        // Both present: the machine-readable field is authoritative.
3997        let parsed = parse_wrapped_websocket_error_event(
3998            r#"{"type":"error","status":429,"error":{"code":"rate_limit_exceeded","message":"Please try again in 5s.","resets_in_seconds":120}}"#,
3999        )
4000        .context("expected a wrapped error")?;
4001
4002        assert_eq!(
4003            websocket_error_kind(&parsed),
4004            StreamErrorKind::RateLimited(Some(std::time::Duration::from_mins(2)))
4005        );
4006        Ok(())
4007    }
4008
4009    #[test]
4010    fn float_encoded_resets_at_still_parses_and_classifies() -> anyhow::Result<()> {
4011        // A float `resets_at` must not fail the whole frame parse: doing so would
4012        // drop the frame entirely, losing both the hint and the rate-limit
4013        // classification — strictly worse than ignoring the reset field.
4014        let future = f64::from(u32::try_from(unix_now())?) + 600.5;
4015        let parsed = parse_wrapped_websocket_error_event(&format!(
4016            r#"{{"type":"error","status":429,"error":{{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_at":{future}}}}}"#,
4017        ))
4018        .context("a float resets_at must still parse as a wrapped error")?;
4019
4020        let StreamErrorKind::RateLimited(Some(delay)) = websocket_error_kind(&parsed) else {
4021            anyhow::bail!("a usage-limit frame must classify as a rate limit with a delay");
4022        };
4023        assert!(
4024            delay <= std::time::Duration::from_mins(10)
4025                && delay >= std::time::Duration::from_secs(590),
4026            "the float reset must convert to a ~600s wait, got {delay:?}"
4027        );
4028        Ok(())
4029    }
4030
4031    #[test]
4032    fn codex_http_reset_after_reads_the_structured_body() -> anyhow::Result<()> {
4033        // The HTTP 429 branches carry the reset in the body (no Retry-After
4034        // header, timing-free prose), so this is the only hint there.
4035        assert_eq!(
4036            codex_http_reset_after(
4037                r#"{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_in_seconds":900}}"#
4038            ),
4039            Some(std::time::Duration::from_mins(15))
4040        );
4041
4042        // Float-encoded absolute reset — must not fail the parse (item-3 class).
4043        let future = f64::from(u32::try_from(unix_now())?) + 300.0;
4044        let delay = codex_http_reset_after(&format!(
4045            r#"{{"error":{{"type":"usage_limit_reached","resets_at":{future}}}}}"#,
4046        ))
4047        .context("a float resets_at HTTP body must parse")?;
4048        assert!(
4049            delay <= std::time::Duration::from_mins(5)
4050                && delay >= std::time::Duration::from_secs(290),
4051            "got {delay:?}"
4052        );
4053
4054        // Not JSON / no reset fields / past instant → no hint.
4055        assert_eq!(codex_http_reset_after("429 Too Many Requests"), None);
4056        assert_eq!(
4057            codex_http_reset_after(r#"{"error":{"message":"slow down"}}"#),
4058            None
4059        );
4060        let past = f64::from(u32::try_from(unix_now())?) - 60.0;
4061        assert_eq!(
4062            codex_http_reset_after(&format!(r#"{{"error":{{"resets_at":{past}}}}}"#)),
4063            None
4064        );
4065        Ok(())
4066    }
4067
4068    #[test]
4069    fn test_prepare_websocket_request_allows_empty_delta_after_prewarm() {
4070        let request = ApiStreamingRequest {
4071            model: MODEL_GPT53_CODEX.to_string(),
4072            instructions: "system".to_string(),
4073            input: vec![ApiInputItem::Message(ApiMessage {
4074                role: ApiRole::User,
4075                content: ApiMessageContent::Text("first".to_string()),
4076                phase: None,
4077            })],
4078            tools: None,
4079            max_output_tokens: None,
4080            reasoning: None,
4081            tool_choice: Some(ApiToolChoice::Mode("auto")),
4082            parallel_tool_calls: None,
4083            store: false,
4084            text: Some(ApiTextSettings {
4085                verbosity: "medium",
4086                format: None,
4087            }),
4088            include: Some(vec!["reasoning.encrypted_content".to_string()]),
4089            prompt_cache_key: Some("thread-1".to_string()),
4090            stream: true,
4091        };
4092        let session = WebsocketSessionState {
4093            connection: None,
4094            last_request: Some(request.clone()),
4095            last_response_id: Some("resp_prewarm".to_string()),
4096            last_response_items: Vec::new(),
4097            turn_state: None,
4098            prewarmed: true,
4099            websocket_disabled: false,
4100            in_flight: false,
4101            last_used: None,
4102        };
4103
4104        let websocket_request = prepare_websocket_request(&request, &session, true);
4105        assert_eq!(
4106            websocket_request.previous_response_id.as_deref(),
4107            Some("resp_prewarm")
4108        );
4109        assert!(websocket_request.input.is_empty());
4110    }
4111
4112    #[test]
4113    fn test_api_response_deserialization() {
4114        let json = r#"{
4115            "id": "resp_123",
4116            "model": "gpt-5.2-codex",
4117            "output": [
4118                {
4119                    "type": "message",
4120                    "role": "assistant",
4121                    "content": [
4122                        {"type": "output_text", "text": "Hello!"}
4123                    ]
4124                }
4125            ],
4126            "status": "completed",
4127            "usage": {
4128                "input_tokens": 100,
4129                "output_tokens": 50
4130            }
4131        }"#;
4132
4133        let response: ApiResponse = serde_json::from_str(json).unwrap();
4134        assert_eq!(response.id, "resp_123");
4135        assert_eq!(response.model, "gpt-5.2-codex");
4136        assert_eq!(response.output.len(), 1);
4137    }
4138
4139    #[test]
4140    fn test_api_response_with_function_call() {
4141        let json = r#"{
4142            "id": "resp_456",
4143            "model": "gpt-5.2-codex",
4144            "output": [
4145                {
4146                    "type": "function_call",
4147                    "call_id": "call_abc",
4148                    "name": "read_file",
4149                    "arguments": "{\"path\": \"test.txt\"}"
4150                }
4151            ],
4152            "status": "completed"
4153        }"#;
4154
4155        let response: ApiResponse = serde_json::from_str(json).unwrap();
4156        assert_eq!(response.output.len(), 1);
4157
4158        match &response.output[0] {
4159            ApiOutputItem::FunctionCall {
4160                call_id,
4161                name,
4162                arguments,
4163            } => {
4164                assert_eq!(call_id, "call_abc");
4165                assert_eq!(name, "read_file");
4166                assert!(arguments.contains("test.txt"));
4167            }
4168            _ => panic!("Expected FunctionCall"),
4169        }
4170    }
4171
4172    #[test]
4173    fn test_build_api_input_uses_responses_text_types_by_role() {
4174        let request = ChatRequest {
4175            system: "system".to_string(),
4176            messages: vec![
4177                agent_sdk_foundation::llm::Message::user_with_content(vec![ContentBlock::Text {
4178                    text: "question".to_string(),
4179                }]),
4180                agent_sdk_foundation::llm::Message {
4181                    role: agent_sdk_foundation::llm::Role::Assistant,
4182                    content: Content::Blocks(vec![ContentBlock::Text {
4183                        text: "answer".to_string(),
4184                    }]),
4185                },
4186            ],
4187            tools: None,
4188            max_tokens: 512,
4189            max_tokens_explicit: false,
4190            session_id: None,
4191            cached_content: None,
4192            thinking: None,
4193            tool_choice: None,
4194            response_format: None,
4195            cache: None,
4196        };
4197
4198        let input = build_api_input(&request);
4199        assert_eq!(input.len(), 2);
4200
4201        match &input[0] {
4202            ApiInputItem::Message(ApiMessage {
4203                role: ApiRole::User,
4204                content: ApiMessageContent::Parts(parts),
4205                ..
4206            }) => assert!(matches!(
4207                parts.as_slice(),
4208                [ApiInputContent::InputText { text }] if text == "question"
4209            )),
4210            _ => panic!("expected user message with input_text content"),
4211        }
4212
4213        match &input[1] {
4214            ApiInputItem::Message(ApiMessage {
4215                role: ApiRole::Assistant,
4216                content: ApiMessageContent::Parts(parts),
4217                ..
4218            }) => assert!(matches!(
4219                parts.as_slice(),
4220                [ApiInputContent::OutputText { text }] if text == "answer"
4221            )),
4222            _ => panic!("expected assistant message with output_text content"),
4223        }
4224    }
4225
4226    #[test]
4227    fn test_api_input_content_serialization_uses_current_responses_tags() {
4228        let json = serde_json::to_string(&ApiMessageContent::Parts(vec![
4229            ApiInputContent::InputText {
4230                text: "prompt".to_string(),
4231            },
4232            ApiInputContent::OutputText {
4233                text: "reply".to_string(),
4234            },
4235            ApiInputContent::Image {
4236                image_url: "data:image/png;base64,abc".to_string(),
4237            },
4238            ApiInputContent::File {
4239                filename: "notes.txt".to_string(),
4240                file_data: "data:text/plain;base64,abc".to_string(),
4241            },
4242        ]))
4243        .unwrap();
4244
4245        assert!(json.contains("\"type\":\"input_text\""));
4246        assert!(json.contains("\"type\":\"output_text\""));
4247        assert!(json.contains("\"type\":\"input_image\""));
4248        assert!(json.contains("\"type\":\"input_file\""));
4249    }
4250
4251    #[test]
4252    fn test_build_content_blocks_text() {
4253        let output = vec![ApiOutputItem::Message {
4254            role: "assistant".to_owned(),
4255            phase: Some("final_answer".to_owned()),
4256            content: vec![ApiOutputContent::Text {
4257                text: "Hello!".to_owned(),
4258            }],
4259        }];
4260
4261        let blocks = build_content_blocks(&output);
4262        assert_eq!(blocks.len(), 2);
4263        assert!(matches!(
4264            &blocks[0],
4265            ContentBlock::OpaqueReasoning { data, .. }
4266                if data["phase"] == "final_answer"
4267        ));
4268        assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Hello!"));
4269    }
4270
4271    #[test]
4272    fn assistant_message_phases_round_trip_without_duplicate_text() -> anyhow::Result<()> {
4273        let output: Vec<ApiOutputItem> = serde_json::from_value(serde_json::json!([
4274            {
4275                "type": "message",
4276                "role": "assistant",
4277                "phase": "commentary",
4278                "content": [{"type": "output_text", "text": "Working."}]
4279            },
4280            {
4281                "type": "message",
4282                "role": "assistant",
4283                "phase": "final_answer",
4284                "content": [{"type": "output_text", "text": "Done."}]
4285            }
4286        ]))?;
4287        let blocks = build_content_blocks(&output);
4288        let request =
4289            ChatRequest::new(String::new(), vec![Message::assistant_with_content(blocks)]);
4290        let value = serde_json::to_value(build_api_input(&request))?;
4291        let items = value
4292            .as_array()
4293            .context("Codex input must serialize as an array")?;
4294
4295        assert_eq!(items.len(), 2);
4296        assert_eq!(items[0]["phase"], "commentary");
4297        assert_eq!(items[0]["content"][0]["text"], "Working.");
4298        assert_eq!(items[1]["phase"], "final_answer");
4299        assert_eq!(items[1]["content"][0]["text"], "Done.");
4300        assert_eq!(value.to_string().matches("Working.").count(), 1);
4301        assert_eq!(value.to_string().matches("Done.").count(), 1);
4302
4303        let direct: ApiOutputItem = serde_json::from_value(serde_json::json!({
4304            "type": "message",
4305            "role": "assistant",
4306            "phase": "commentary",
4307            "content": [{"type": "output_text", "text": "Working."}]
4308        }))?;
4309        let direct = output_item_to_input_item(direct)
4310            .context("message output should become a continuation input")?;
4311        assert_eq!(serde_json::to_value(direct)?["phase"], "commentary");
4312        Ok(())
4313    }
4314
4315    #[test]
4316    fn streamed_phase_and_reasoning_summary_preserve_block_order() -> anyhow::Result<()> {
4317        let message: ApiOutputItem = serde_json::from_value(serde_json::json!({
4318            "type": "message",
4319            "role": "assistant",
4320            "phase": "commentary",
4321            "content": [{"type": "output_text", "text": "Working."}]
4322        }))?;
4323        let mut accumulator = crate::streaming::StreamAccumulator::new();
4324        accumulator.apply(&StreamDelta::TextDelta {
4325            delta: "Working.".to_owned(),
4326            block_index: output_block_index(Some(0)),
4327        });
4328        for delta in output_item_stream_deltas(&message, 0, true) {
4329            accumulator.apply(&delta);
4330        }
4331        let message_blocks = accumulator.into_content_blocks();
4332        assert!(matches!(
4333            message_blocks.as_slice(),
4334            [ContentBlock::OpaqueReasoning { data, .. }, ContentBlock::Text { text }]
4335                if data["phase"] == "commentary" && text == "Working."
4336        ));
4337
4338        let reasoning: ApiOutputItem = serde_json::from_value(serde_json::json!({
4339            "type": "reasoning",
4340            "id": "rs_1",
4341            "encrypted_content": "ciphertext",
4342            "summary": [{"type": "summary_text", "text": "Checked."}]
4343        }))?;
4344        let mut accumulator = crate::streaming::StreamAccumulator::new();
4345        for delta in output_item_stream_deltas(&reasoning, 1, true) {
4346            accumulator.apply(&delta);
4347        }
4348        let reasoning_blocks = accumulator.into_content_blocks();
4349        assert!(matches!(
4350            reasoning_blocks.as_slice(),
4351            [ContentBlock::OpaqueReasoning { data, .. }, ContentBlock::Thinking { thinking, .. }]
4352                if data["encrypted_content"] == "ciphertext" && thinking == "Checked."
4353        ));
4354        Ok(())
4355    }
4356
4357    #[test]
4358    fn test_build_content_blocks_function_call() {
4359        let output = vec![ApiOutputItem::FunctionCall {
4360            call_id: "call_123".to_owned(),
4361            name: "test_tool".to_owned(),
4362            arguments: r#"{"key": "value"}"#.to_owned(),
4363        }];
4364
4365        let blocks = build_content_blocks(&output);
4366        assert_eq!(blocks.len(), 1);
4367        assert!(
4368            matches!(&blocks[0], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "test_tool")
4369        );
4370    }
4371
4372    #[test]
4373    fn incomplete_and_refusal_responses_suppress_partial_tools() -> anyhow::Result<()> {
4374        let incomplete: ApiResponse = serde_json::from_value(serde_json::json!({
4375            "id": "resp_incomplete",
4376            "model": "gpt-5.3-codex",
4377            "status": "incomplete",
4378            "incomplete_details": {"reason": "model_context_window_exceeded"},
4379            "output": [{
4380                "type": "function_call",
4381                "call_id": "call_partial",
4382                "name": "lookup",
4383                "arguments": "{"
4384            }]
4385        }))?;
4386        let incomplete = OpenAICodexResponsesProvider::map_response(incomplete);
4387        assert_eq!(
4388            incomplete.stop_reason,
4389            Some(StopReason::ModelContextWindowExceeded)
4390        );
4391        assert!(
4392            !incomplete
4393                .content
4394                .iter()
4395                .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
4396        );
4397
4398        let refusal: ApiResponse = serde_json::from_value(serde_json::json!({
4399            "id": "resp_refusal",
4400            "model": "gpt-5.3-codex",
4401            "status": "completed",
4402            "output": [
4403                {
4404                    "type": "function_call",
4405                    "call_id": "call_partial",
4406                    "name": "lookup",
4407                    "arguments": "{}"
4408                },
4409                {
4410                    "type": "message",
4411                    "role": "assistant",
4412                    "phase": "final_answer",
4413                    "content": [{"type": "refusal", "refusal": "Cannot comply."}]
4414                }
4415            ]
4416        }))?;
4417        let refusal = OpenAICodexResponsesProvider::map_response(refusal);
4418        assert_eq!(refusal.stop_reason, Some(StopReason::Refusal));
4419        assert!(
4420            !refusal
4421                .content
4422                .iter()
4423                .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
4424        );
4425        assert!(matches!(
4426            refusal.content.last(),
4427            Some(ContentBlock::Text { text }) if text == "Cannot comply."
4428        ));
4429        Ok(())
4430    }
4431
4432    #[test]
4433    fn reasoning_output_item_is_preserved_and_summary_is_visible() -> anyhow::Result<()> {
4434        let raw = serde_json::json!({
4435            "type": "reasoning",
4436            "id": "rs_123",
4437            "status": "completed",
4438            "encrypted_content": "ciphertext",
4439            "summary": [
4440                {"type": "summary_text", "text": "Checked the relevant constraints."}
4441            ]
4442        });
4443        let item: ApiOutputItem = serde_json::from_value(raw.clone())?;
4444        let replay_item = output_item_to_input_item(item);
4445        let Some(replay_item) = replay_item else {
4446            anyhow::bail!("reasoning item was not converted to a replay item");
4447        };
4448        assert_eq!(serde_json::to_value(replay_item)?, raw);
4449
4450        let item: ApiOutputItem = serde_json::from_value(raw.clone())?;
4451        let blocks = build_content_blocks(&[item]);
4452        assert_eq!(blocks.len(), 2);
4453        assert!(matches!(
4454            &blocks[0],
4455            ContentBlock::OpaqueReasoning { provider, data, .. }
4456                if provider == OPENAI_RESPONSES_REASONING_PROVIDER && data == &raw
4457        ));
4458        assert!(matches!(
4459            &blocks[1],
4460            ContentBlock::Thinking { thinking, signature, .. }
4461                if thinking == "Checked the relevant constraints." && signature.is_none()
4462        ));
4463        Ok(())
4464    }
4465
4466    #[test]
4467    fn matching_opaque_reasoning_replays_as_a_top_level_item_in_source_order() -> anyhow::Result<()>
4468    {
4469        let raw = serde_json::json!({
4470            "type": "reasoning",
4471            "id": "rs_123",
4472            "encrypted_content": "ciphertext",
4473            "summary": []
4474        });
4475        let request = ChatRequest::new(
4476            "",
4477            vec![agent_sdk_foundation::llm::Message {
4478                role: agent_sdk_foundation::llm::Role::Assistant,
4479                content: Content::Blocks(vec![
4480                    ContentBlock::Text {
4481                        text: "before".to_owned(),
4482                    },
4483                    ContentBlock::OpaqueReasoning {
4484                        provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
4485                        data: raw.clone(),
4486                    },
4487                    ContentBlock::OpaqueReasoning {
4488                        provider: "another-provider".to_owned(),
4489                        data: serde_json::json!({"type": "reasoning", "id": "ignored"}),
4490                    },
4491                    ContentBlock::Text {
4492                        text: "after".to_owned(),
4493                    },
4494                ]),
4495            }],
4496        );
4497
4498        assert_eq!(
4499            serde_json::to_value(build_api_input(&request))?,
4500            serde_json::json!([
4501                {
4502                    "role": "assistant",
4503                    "phase": "final_answer",
4504                    "content": [{"type": "output_text", "text": "before"}]
4505                },
4506                raw,
4507                {
4508                    "role": "assistant",
4509                    "phase": "final_answer",
4510                    "content": [{"type": "output_text", "text": "after"}]
4511                }
4512            ])
4513        );
4514        Ok(())
4515    }
4516
4517    #[test]
4518    fn usage_maps_cache_write_tokens() -> anyhow::Result<()> {
4519        let usage: ApiUsage = serde_json::from_value(serde_json::json!({
4520            "input_tokens": 2048,
4521            "output_tokens": 128,
4522            "input_tokens_details": {
4523                "cached_tokens": 1024,
4524                "cache_write_tokens": 512
4525            }
4526        }))?;
4527
4528        let usage = usage_from_api_usage(&usage);
4529        assert_eq!(usage.cached_input_tokens, 1024);
4530        assert_eq!(usage.cache_creation_input_tokens, 512);
4531        Ok(())
4532    }
4533
4534    #[test]
4535    fn stream_stop_reason_requires_a_semantic_terminal_event() {
4536        let tool_calls = HashMap::new();
4537        assert!(stop_reason_from_stream_state(&tool_calls, None, false, None).is_none());
4538        assert!(matches!(
4539            stop_reason_from_stream_state(&tool_calls, Some(ApiStatus::Completed), false, None,),
4540            Some(StopReason::EndTurn)
4541        ));
4542        assert!(matches!(
4543            stop_reason_from_stream_state(
4544                &tool_calls,
4545                Some(ApiStatus::Incomplete),
4546                false,
4547                Some("max_output_tokens"),
4548            ),
4549            Some(StopReason::MaxTokens)
4550        ));
4551    }
4552
4553    #[test]
4554    fn test_request_serializes_response_format_text_and_forced_tool_choice() {
4555        let request = ApiStreamingRequest {
4556            model: MODEL_GPT53_CODEX.to_string(),
4557            instructions: String::new(),
4558            input: Vec::new(),
4559            tools: None,
4560            max_output_tokens: None,
4561            reasoning: None,
4562            tool_choice: Some(codex_tool_choice(Some(&ToolChoice::Tool(
4563                "respond".to_owned(),
4564            )))),
4565            parallel_tool_calls: None,
4566            store: false,
4567            text: Some(ApiTextSettings {
4568                verbosity: "medium",
4569                format: Some(ApiResponseTextFormat::from(&ResponseFormat::new(
4570                    "person",
4571                    serde_json::json!({"type": "object"}),
4572                ))),
4573            }),
4574            include: None,
4575            prompt_cache_key: None,
4576            stream: true,
4577        };
4578
4579        let json = serde_json::to_value(&request).unwrap();
4580        assert_eq!(json["text"]["format"]["type"], "json_schema");
4581        assert_eq!(json["text"]["format"]["name"], "person");
4582        assert_eq!(json["text"]["format"]["strict"], true);
4583        assert_eq!(json["tool_choice"]["type"], "function");
4584        assert_eq!(json["tool_choice"]["name"], "respond");
4585    }
4586
4587    #[test]
4588    fn test_codex_tool_choice_defaults_to_auto() {
4589        assert_eq!(
4590            serde_json::to_value(codex_tool_choice(None)).unwrap(),
4591            serde_json::json!("auto")
4592        );
4593        assert_eq!(
4594            serde_json::to_value(codex_tool_choice(Some(&ToolChoice::Auto))).unwrap(),
4595            serde_json::json!("auto")
4596        );
4597    }
4598
4599    #[test]
4600    fn test_convert_tool_makes_optional_params_nullable() {
4601        let tool = agent_sdk_foundation::llm::Tool {
4602            name: "t".to_string(),
4603            description: "d".to_string(),
4604            input_schema: serde_json::json!({
4605                "type": "object",
4606                "properties": {
4607                    "req": {"type": "string"},
4608                    "opt": {"type": "string"}
4609                },
4610                "required": ["req"]
4611            }),
4612            display_name: "T".to_string(),
4613            tier: agent_sdk_foundation::ToolTier::Observe,
4614        };
4615
4616        let api_tool = convert_tool(tool);
4617        assert_eq!(api_tool.strict, Some(true));
4618        let schema = api_tool.parameters.unwrap();
4619
4620        let required: Vec<&str> = schema["required"]
4621            .as_array()
4622            .unwrap()
4623            .iter()
4624            .filter_map(|v| v.as_str())
4625            .collect();
4626        assert!(required.contains(&"req"));
4627        assert!(required.contains(&"opt"));
4628
4629        // The previously-optional `opt` must be wrapped in anyOf with a null
4630        // variant so the model is not forced to fabricate a value for it.
4631        let any_of = schema["properties"]["opt"]["anyOf"].as_array().unwrap();
4632        assert!(
4633            any_of
4634                .iter()
4635                .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"))
4636        );
4637    }
4638
4639    #[test]
4640    fn test_convert_tool_disables_strict_for_freeform_object() {
4641        let tool = agent_sdk_foundation::llm::Tool {
4642            name: "t".to_string(),
4643            description: "d".to_string(),
4644            input_schema: serde_json::json!({"type": "object"}),
4645            display_name: "T".to_string(),
4646            tier: agent_sdk_foundation::ToolTier::Observe,
4647        };
4648
4649        let api_tool = convert_tool(tool);
4650        assert_eq!(api_tool.strict, None);
4651    }
4652
4653    #[test]
4654    fn test_emit_accumulated_tool_calls_assigns_distinct_ordered_indices() {
4655        let mut tool_calls = HashMap::new();
4656        tool_calls.insert(
4657            "b".to_string(),
4658            ToolCallAccumulator {
4659                id: "b".to_string(),
4660                name: "second".to_string(),
4661                arguments: "{}".to_string(),
4662                order: 1,
4663                block_index: None,
4664            },
4665        );
4666        tool_calls.insert(
4667            "a".to_string(),
4668            ToolCallAccumulator {
4669                id: "a".to_string(),
4670                name: "first".to_string(),
4671                arguments: "{}".to_string(),
4672                order: 0,
4673                block_index: None,
4674            },
4675        );
4676
4677        let deltas = emit_accumulated_tool_calls(&tool_calls);
4678        let starts: Vec<(String, usize)> = deltas
4679            .iter()
4680            .filter_map(|d| match d {
4681                StreamDelta::ToolUseStart {
4682                    name, block_index, ..
4683                } => Some((name.clone(), *block_index)),
4684                _ => None,
4685            })
4686            .collect();
4687        assert_eq!(
4688            starts,
4689            vec![("first".to_string(), 2), ("second".to_string(), 4)]
4690        );
4691    }
4692
4693    // ────────────────────────────────────────────────────────────────────
4694    // Transport selection: force-HTTP knob + cross-session WS-unhealthy memory
4695    // ────────────────────────────────────────────────────────────────────
4696
4697    use crate::provider::LlmProvider;
4698    use agent_sdk_foundation::llm::{ChatRequest, Message};
4699    use std::sync::atomic::AtomicUsize;
4700    use tokio::io::{AsyncReadExt, AsyncWriteExt};
4701    use tokio::net::TcpListener;
4702
4703    /// A truthy OAuth-shaped token so `build_headers` can extract an account id
4704    /// without a real network call.
4705    fn oauth_token() -> String {
4706        test_token()
4707    }
4708
4709    fn streaming_request(session_id: &str) -> ChatRequest {
4710        ChatRequest::new("You are helpful.", vec![Message::user("hello")])
4711            .with_max_tokens(1024)
4712            .with_session_id(session_id)
4713    }
4714
4715    /// Read a single HTTP/1.1 request head (up to the blank line) from a stream.
4716    async fn read_http_head(stream: &mut tokio::net::TcpStream) -> String {
4717        let mut buf = Vec::new();
4718        let mut byte = [0u8; 1];
4719        while stream.read_exact(&mut byte).await.is_ok() {
4720            buf.push(byte[0]);
4721            if buf.ends_with(b"\r\n\r\n") {
4722                break;
4723            }
4724            if buf.len() > 16 * 1024 {
4725                break;
4726            }
4727        }
4728        String::from_utf8_lossy(&buf).into_owned()
4729    }
4730
4731    /// Minimal SSE body that drives the HTTP fallback to a clean `Done`.
4732    const HTTP_SSE_BODY: &str = concat!(
4733        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n",
4734        "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\"}}\n\n",
4735        "data: [DONE]\n\n",
4736    );
4737
4738    /// Spawn an HTTP-only server that records whether any WebSocket upgrade was
4739    /// attempted and serves a fixed SSE body to plain POSTs. Returns the base
4740    /// URL plus the (`ws_attempts`, `http_requests`) counters.
4741    async fn spawn_http_only_server_with_body(
4742        sse_body: &'static str,
4743    ) -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
4744        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4745        let addr = listener.local_addr().unwrap();
4746        let ws_attempts = Arc::new(AtomicUsize::new(0));
4747        let http_requests = Arc::new(AtomicUsize::new(0));
4748        let ws_attempts_task = ws_attempts.clone();
4749        let http_requests_task = http_requests.clone();
4750
4751        tokio::spawn(async move {
4752            loop {
4753                let Ok((mut stream, _)) = listener.accept().await else {
4754                    break;
4755                };
4756                let ws_attempts = ws_attempts_task.clone();
4757                let http_requests = http_requests_task.clone();
4758                tokio::spawn(async move {
4759                    let head = read_http_head(&mut stream).await;
4760                    if head.to_ascii_lowercase().contains("upgrade: websocket") {
4761                        ws_attempts.fetch_add(1, Ordering::Relaxed);
4762                        // Refuse the upgrade; a black-holed proxy would simply
4763                        // never complete it, but refusing fast keeps the test
4764                        // deterministic.
4765                        let _ = stream
4766                            .write_all(
4767                                b"HTTP/1.1 426 Upgrade Required\r\ncontent-length: 0\r\n\r\n",
4768                            )
4769                            .await;
4770                        return;
4771                    }
4772                    http_requests.fetch_add(1, Ordering::Relaxed);
4773                    let response = format!(
4774                        "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
4775                        sse_body.len(),
4776                        sse_body,
4777                    );
4778                    let _ = stream.write_all(response.as_bytes()).await;
4779                });
4780            }
4781        });
4782
4783        (
4784            format!("http://{addr}/backend-api"),
4785            ws_attempts,
4786            http_requests,
4787        )
4788    }
4789
4790    async fn spawn_http_only_server() -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
4791        spawn_http_only_server_with_body(HTTP_SSE_BODY).await
4792    }
4793
4794    /// An HTTP-only server that answers every non-upgrade request with a fixed
4795    /// status line and body — used to drive the 429 quota branches.
4796    async fn spawn_http_status_server(status_line: &'static str, body: &'static str) -> String {
4797        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4798        let addr = listener.local_addr().unwrap();
4799        tokio::spawn(async move {
4800            loop {
4801                let Ok((mut stream, _)) = listener.accept().await else {
4802                    break;
4803                };
4804                tokio::spawn(async move {
4805                    let head = read_http_head(&mut stream).await;
4806                    if head.to_ascii_lowercase().contains("upgrade: websocket") {
4807                        let _ = stream
4808                            .write_all(
4809                                b"HTTP/1.1 426 Upgrade Required\r\ncontent-length: 0\r\n\r\n",
4810                            )
4811                            .await;
4812                        return;
4813                    }
4814                    let response = format!(
4815                        "HTTP/1.1 {status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}",
4816                        body.len(),
4817                    );
4818                    let _ = stream.write_all(response.as_bytes()).await;
4819                });
4820            }
4821        });
4822        format!("http://{addr}/backend-api")
4823    }
4824
4825    #[tokio::test]
4826    async fn chat_streams_and_collects_for_non_streaming_callers() -> anyhow::Result<()> {
4827        let server = wiremock::MockServer::start().await;
4828        let sse_body = concat!(
4829            "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"compacted summary\"}\n\n",
4830            "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":265,\"output_tokens\":42}}}\n\n",
4831            "data: [DONE]\n\n",
4832        );
4833        wiremock::Mock::given(wiremock::matchers::method("POST"))
4834            .and(wiremock::matchers::path("/backend-api/codex/responses"))
4835            .and(wiremock::matchers::header("accept", "text/event-stream"))
4836            .and(wiremock::matchers::body_partial_json(
4837                serde_json::json!({"stream": true}),
4838            ))
4839            .respond_with(
4840                wiremock::ResponseTemplate::new(200)
4841                    .insert_header("content-type", "text/event-stream")
4842                    .set_body_string(sse_body),
4843            )
4844            .expect(1)
4845            .mount(&server)
4846            .await;
4847        let provider = OpenAICodexResponsesProvider::with_base_url(
4848            oauth_token(),
4849            MODEL_GPT53_CODEX.to_owned(),
4850            format!("{}/backend-api", server.uri()),
4851        );
4852
4853        let outcome = provider
4854            .chat(ChatRequest::new(
4855                "You are a precise summarizer.",
4856                vec![Message::user("summarize this history")],
4857            ))
4858            .await?;
4859        let ChatOutcome::Success(response) = outcome else {
4860            anyhow::bail!("streamed chat did not produce a successful outcome: {outcome:?}");
4861        };
4862
4863        assert_eq!(response.first_text(), Some("compacted summary"));
4864        assert_eq!(response.stop_reason, Some(StopReason::EndTurn));
4865        assert_eq!(response.usage.input_tokens, 265);
4866        assert_eq!(response.usage.output_tokens, 42);
4867        Ok(())
4868    }
4869
4870    #[tokio::test]
4871    async fn http_chat_429_reads_the_structured_reset_body() -> anyhow::Result<()> {
4872        // The collected streaming chat() path: no Retry-After header, a
4873        // usage-limit body whose only usable hint is `resets_in_seconds`.
4874        let base_url = spawn_http_status_server(
4875            "429 Too Many Requests",
4876            r#"{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_in_seconds":600}}"#,
4877        )
4878        .await;
4879        let provider = OpenAICodexResponsesProvider::with_base_url(
4880            oauth_token(),
4881            MODEL_GPT53_CODEX.to_string(),
4882            base_url,
4883        );
4884
4885        let outcome = provider
4886            .chat(ChatRequest::new(
4887                "You are helpful.",
4888                vec![Message::user("hi")],
4889            ))
4890            .await?;
4891        assert!(
4892            matches!(
4893                outcome,
4894                ChatOutcome::RateLimited(Some(delay)) if delay == std::time::Duration::from_mins(10)
4895            ),
4896            "the HTTP chat 429 must carry the structured reset, got {outcome:?}"
4897        );
4898        Ok(())
4899    }
4900
4901    #[tokio::test]
4902    async fn http_stream_429_reads_the_structured_reset_body() -> anyhow::Result<()> {
4903        // The HTTP streaming branch (websockets disabled → HTTP SSE): a 429 at
4904        // stream open must surface a rate limit carrying the body's reset.
4905        let base_url = spawn_http_status_server(
4906            "429 Too Many Requests",
4907            r#"{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_in_seconds":600}}"#,
4908        )
4909        .await;
4910        let provider = OpenAICodexResponsesProvider::with_base_url(
4911            oauth_token(),
4912            MODEL_GPT53_CODEX.to_string(),
4913            base_url,
4914        )
4915        .with_websockets_disabled(true);
4916
4917        let mut stream = std::pin::pin!(provider.chat_stream(streaming_request("session-429")));
4918        let mut kind = None;
4919        while let Some(item) = stream.next().await {
4920            if let StreamDelta::Error { kind: k, .. } = item? {
4921                kind = Some(k);
4922                break;
4923            }
4924        }
4925        assert_eq!(
4926            kind.context("the stream must surface an error")?,
4927            StreamErrorKind::RateLimited(Some(std::time::Duration::from_mins(10)))
4928        );
4929        Ok(())
4930    }
4931
4932    /// Drain a stream, returning whether it completed without a transport error.
4933    async fn drain_ok(provider: &OpenAICodexResponsesProvider, request: ChatRequest) -> bool {
4934        let mut stream = std::pin::pin!(provider.chat_stream(request));
4935        let mut saw_error = false;
4936        while let Some(item) = stream.next().await {
4937            match item {
4938                Ok(StreamDelta::Error { .. }) | Err(_) => saw_error = true,
4939                Ok(_) => {}
4940            }
4941        }
4942        !saw_error
4943    }
4944
4945    #[tokio::test]
4946    async fn websockets_disabled_builder_goes_straight_to_http() {
4947        let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
4948        let provider = OpenAICodexResponsesProvider::with_base_url(
4949            oauth_token(),
4950            MODEL_GPT53_CODEX.to_string(),
4951            base_url,
4952        )
4953        .with_websockets_disabled(true);
4954
4955        assert!(drain_ok(&provider, streaming_request("session-a")).await);
4956
4957        assert_eq!(
4958            ws_attempts.load(Ordering::Relaxed),
4959            0,
4960            "no websocket upgrade may be attempted when websockets are disabled",
4961        );
4962        assert_eq!(http_requests.load(Ordering::Relaxed), 1);
4963    }
4964
4965    #[tokio::test]
4966    async fn premature_http_stream_termination_is_an_error() {
4967        for (case, sse_body) in [
4968            ("done", "data: [DONE]\n\n"),
4969            (
4970                "eof",
4971                "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
4972            ),
4973        ] {
4974            let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
4975            let provider = OpenAICodexResponsesProvider::with_base_url(
4976                oauth_token(),
4977                MODEL_GPT53_CODEX.to_string(),
4978                base_url,
4979            )
4980            .with_websockets_disabled(true);
4981            let mut stream = std::pin::pin!(
4982                provider.chat_stream(streaming_request(&format!("premature-{case}")))
4983            );
4984            let mut saw_error = false;
4985            let mut saw_done = false;
4986            while let Some(item) = stream.next().await {
4987                match item {
4988                    Ok(StreamDelta::Error { .. }) | Err(_) => saw_error = true,
4989                    Ok(StreamDelta::Done { .. }) => saw_done = true,
4990                    Ok(_) => {}
4991                }
4992            }
4993            assert!(saw_error, "case={case} must surface a stream error");
4994            assert!(!saw_done, "case={case} must not synthesize success");
4995        }
4996    }
4997
4998    #[tokio::test]
4999    async fn malformed_http_events_and_items_fail_closed() -> anyhow::Result<()> {
5000        for (case, sse_body) in [
5001            ("event", "data: {not-json}\n\n"),
5002            (
5003                "item",
5004                "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\"}}\n\n",
5005            ),
5006        ] {
5007            let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
5008            let provider = OpenAICodexResponsesProvider::with_base_url(
5009                oauth_token(),
5010                MODEL_GPT53_CODEX.to_owned(),
5011                base_url,
5012            )
5013            .with_websockets_disabled(true);
5014            let mut stream = std::pin::pin!(
5015                provider.chat_stream(streaming_request(&format!("malformed-{case}")))
5016            );
5017            let first = stream
5018                .next()
5019                .await
5020                .context("malformed stream must emit an error")??;
5021            assert!(matches!(
5022                first,
5023                StreamDelta::Error {
5024                    kind: StreamErrorKind::ServerError,
5025                    ..
5026                }
5027            ));
5028            assert!(stream.next().await.is_none());
5029        }
5030        Ok(())
5031    }
5032
5033    #[tokio::test]
5034    async fn atomic_http_function_call_is_not_dropped() -> anyhow::Result<()> {
5035        let sse_body = concat!(
5036            "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\",\"encrypted_content\":\"ciphertext\",\"summary\":[]}}\n\n",
5037            "data: {\"type\":\"response.output_item.done\",\"output_index\":1,\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"read\",\"arguments\":\"{\\\"path\\\":\\\"src/lib.rs\\\"}\"}}\n\n",
5038            "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n",
5039            "data: [DONE]\n\n",
5040        );
5041        let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
5042        let provider = OpenAICodexResponsesProvider::with_base_url(
5043            oauth_token(),
5044            MODEL_GPT53_CODEX.to_owned(),
5045            base_url,
5046        )
5047        .with_websockets_disabled(true);
5048        let mut stream =
5049            std::pin::pin!(provider.chat_stream(streaming_request("atomic-function-call")));
5050        let mut deltas = Vec::new();
5051        while let Some(delta) = stream.next().await {
5052            deltas.push(delta?);
5053        }
5054
5055        assert!(deltas.iter().any(|delta| matches!(
5056            delta,
5057            StreamDelta::ToolUseStart { id, name, .. }
5058                if id == "call_1" && name == "read"
5059        )));
5060        assert!(deltas.iter().any(|delta| matches!(
5061            delta,
5062            StreamDelta::ToolInputDelta { id, delta, .. }
5063                if id == "call_1" && delta == r#"{"path":"src/lib.rs"}"#
5064        )));
5065        assert!(matches!(
5066            deltas.last(),
5067            Some(StreamDelta::Done {
5068                stop_reason: Some(StopReason::ToolUse)
5069            })
5070        ));
5071        Ok(())
5072    }
5073
5074    #[tokio::test]
5075    async fn non_tool_http_terminals_suppress_partial_tool_calls() -> anyhow::Result<()> {
5076        for (case, sse_body, expected_stop) in [
5077            (
5078                "incomplete",
5079                concat!(
5080                    "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"call_id\":\"call_1\",\"name\":\"lookup\",\"delta\":\"{\"}\n\n",
5081                    "data: {\"type\":\"response.incomplete\",\"response\":{\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
5082                    "data: [DONE]\n\n",
5083                ),
5084                StopReason::MaxTokens,
5085            ),
5086            (
5087                "refusal",
5088                concat!(
5089                    "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"call_id\":\"call_1\",\"name\":\"lookup\",\"delta\":\"{}\"}\n\n",
5090                    "data: {\"type\":\"response.refusal.delta\",\"output_index\":1,\"delta\":\"Cannot comply.\"}\n\n",
5091                    "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n",
5092                    "data: [DONE]\n\n",
5093                ),
5094                StopReason::Refusal,
5095            ),
5096        ] {
5097            let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
5098            let provider = OpenAICodexResponsesProvider::with_base_url(
5099                oauth_token(),
5100                MODEL_GPT53_CODEX.to_owned(),
5101                base_url,
5102            )
5103            .with_websockets_disabled(true);
5104            let mut stream = std::pin::pin!(
5105                provider.chat_stream(streaming_request(&format!("terminal-{case}")))
5106            );
5107            let mut deltas = Vec::new();
5108            while let Some(delta) = stream.next().await {
5109                deltas.push(delta?);
5110            }
5111            assert!(matches!(
5112                deltas.last(),
5113                Some(StreamDelta::Done {
5114                    stop_reason: Some(stop_reason)
5115                }) if *stop_reason == expected_stop
5116            ));
5117            assert!(!deltas.iter().any(|delta| matches!(
5118                delta,
5119                StreamDelta::ToolUseStart { .. } | StreamDelta::ToolInputDelta { .. }
5120            )));
5121        }
5122        Ok(())
5123    }
5124
5125    #[test]
5126    fn parse_disable_websockets_value_recognizes_truthy_values() {
5127        for value in ["1", "true", "TRUE", " yes ", "on"] {
5128            assert!(
5129                parse_disable_websockets_value(Some(value)),
5130                "value={value:?} should disable websockets",
5131            );
5132        }
5133        for value in ["0", "false", "no", "off", "", "maybe"] {
5134            assert!(
5135                !parse_disable_websockets_value(Some(value)),
5136                "value={value:?} should NOT disable websockets",
5137            );
5138        }
5139        assert!(!parse_disable_websockets_value(None));
5140    }
5141
5142    #[tokio::test]
5143    async fn websockets_disabled_via_env_value_goes_straight_to_http() {
5144        // The env var feeds `with_websockets_disabled` through
5145        // `parse_disable_websockets_value` at construction. `std::env::set_var`
5146        // is `unsafe` and rejected by `#![forbid(unsafe_code)]`, so we drive the
5147        // exact value the env reader would produce for `"1"` end-to-end here.
5148        let disabled = parse_disable_websockets_value(Some("1"));
5149        assert!(disabled);
5150
5151        let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
5152        let provider = OpenAICodexResponsesProvider::with_base_url(
5153            oauth_token(),
5154            MODEL_GPT53_CODEX.to_string(),
5155            base_url,
5156        )
5157        .with_websockets_disabled(disabled);
5158
5159        assert!(drain_ok(&provider, streaming_request("session-env")).await);
5160
5161        assert_eq!(
5162            ws_attempts.load(Ordering::Relaxed),
5163            0,
5164            "no websocket upgrade may be attempted when the env var forces HTTP-only",
5165        );
5166        assert_eq!(http_requests.load(Ordering::Relaxed), 1);
5167    }
5168
5169    #[tokio::test]
5170    async fn provider_marked_ws_unhealthy_skips_websocket_on_new_session() {
5171        let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
5172        let provider = OpenAICodexResponsesProvider::with_base_url(
5173            oauth_token(),
5174            MODEL_GPT53_CODEX.to_string(),
5175            base_url,
5176        );
5177
5178        // Simulate a prior session having latched the provider-level transport
5179        // signal after a connectivity failure.
5180        provider.websockets_unhealthy.store(true, Ordering::Relaxed);
5181
5182        // A brand-new session must skip the websocket attempt entirely.
5183        assert!(drain_ok(&provider, streaming_request("fresh-session")).await);
5184
5185        assert_eq!(
5186            ws_attempts.load(Ordering::Relaxed),
5187            0,
5188            "a websocket-unhealthy provider must not attempt a new upgrade",
5189        );
5190        assert_eq!(http_requests.load(Ordering::Relaxed), 1);
5191    }
5192
5193    /// Spawn a server that completes the WebSocket handshake then emits a
5194    /// wrapped 401 error event. Plain POSTs get the HTTP SSE body. Returns the
5195    /// base URL plus the http-request counter (for the fallback assertion).
5196    async fn spawn_ws_unauthorized_server() -> (String, Arc<AtomicUsize>) {
5197        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5198        let addr = listener.local_addr().unwrap();
5199        let http_requests = Arc::new(AtomicUsize::new(0));
5200        let http_requests_task = http_requests.clone();
5201
5202        tokio::spawn(async move {
5203            loop {
5204                let Ok((stream, _)) = listener.accept().await else {
5205                    break;
5206                };
5207                let http_requests = http_requests_task.clone();
5208                tokio::spawn(async move {
5209                    let mut stream = stream;
5210                    // Non-destructively peek the head to route WS vs HTTP, then
5211                    // hand the still-unread stream to the right handler.
5212                    let mut peek = [0u8; 1024];
5213                    let Ok(n) = stream.peek(&mut peek).await else {
5214                        return;
5215                    };
5216                    let head = String::from_utf8_lossy(&peek[..n]).to_ascii_lowercase();
5217
5218                    if head.contains("upgrade: websocket") {
5219                        // `accept_async` reads and completes the handshake from
5220                        // the untouched stream, so no manual SHA-1 is needed.
5221                        let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5222                            return;
5223                        };
5224                        let payload =
5225                            r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
5226                        let _ = ws
5227                            .send(WebSocketMessage::Text(payload.to_string().into()))
5228                            .await;
5229                        let _ = ws.send(WebSocketMessage::Close(None)).await;
5230                        return;
5231                    }
5232
5233                    // Plain HTTP POST: drain the request head, then reply.
5234                    let _ = read_http_head(&mut stream).await;
5235                    http_requests.fetch_add(1, Ordering::Relaxed);
5236                    let response = format!(
5237                        "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
5238                        HTTP_SSE_BODY.len(),
5239                        HTTP_SSE_BODY,
5240                    );
5241                    let _ = stream.write_all(response.as_bytes()).await;
5242                });
5243            }
5244        });
5245
5246        (format!("http://{addr}/backend-api"), http_requests)
5247    }
5248
5249    #[tokio::test]
5250    async fn ws_unauthorized_disables_session_but_not_provider() {
5251        let (base_url, http_requests) = spawn_ws_unauthorized_server().await;
5252        let provider = OpenAICodexResponsesProvider::with_base_url(
5253            oauth_token(),
5254            MODEL_GPT53_CODEX.to_string(),
5255            base_url,
5256        );
5257
5258        // The websocket warmup hits a wrapped 401, disables the session's
5259        // websocket, and falls back to HTTP — which completes cleanly.
5260        assert!(drain_ok(&provider, streaming_request("auth-session")).await);
5261
5262        // The auth failure is a request problem, NOT a transport problem: the
5263        // provider-level flag must stay clear so a transient blip does not
5264        // force HTTP-only forever.
5265        assert!(
5266            !provider.websockets_unhealthy.load(Ordering::Relaxed),
5267            "a 401 must not mark the provider websocket-transport-unhealthy",
5268        );
5269        // The per-session flag was set, so this same session now goes straight
5270        // to HTTP without another websocket attempt.
5271        let session = provider.websocket_session("auth-session").await;
5272        assert!(session.lock().await.websocket_disabled);
5273
5274        assert!(http_requests.load(Ordering::Relaxed) >= 1);
5275    }
5276}