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