Skip to main content

agent_sdk_providers/impls/
openai_codex_responses.rs

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